On this page
You can use an iPad to ask OpenCode about a project, approve a small edit, and pick up the same work later from your laptop. The project, development tools and coding agent live on a cloud server. Safari gives you the controls.
The useful part is having one workspace that stays put. You don't need to copy a repository onto the iPad or keep your laptop awake for the server to be available.
This guide builds a private browser connection to OpenCode on a DigitalOcean Droplet. It starts with a tiny test project, then explains how to move to your own repository. You can send prompts and approve proposed edits in the browser; this walkthrough verifies the resulting change through SSH. Setup, Git checks and server maintenance still use a terminal. This is for readers comfortable with Git, SSH and basic Linux administration.
Scope: This is a documentation-based OpenCode 1.x walkthrough, checked on 14 October 2026. The commands have not been run on a live Droplet or tested in iPad Safari for this article. OpenCode 2 has a separate installation path; don't mix the two installations.
Affiliate disclosure: If you sign up through a DigitalOcean link in this article, I may earn a commission. Check the current offer and eligibility on DigitalOcean's site; this guide doesn't assume promotional credit.
What you will have when it works
Your iPad connects through Tailscale, a private network for your devices, to an HTTPS address. That address forwards to OpenCode running on the server. OpenCode asks for its own password too.
The boundaries are straightforward:
- On the iPad: Safari, the Tailscale app, and an internet connection. An SSH app is also needed if you want to do setup and file checks from the iPad.
- On the Droplet: Your project files, Git, OpenCode, and any development tools you install.
- At the model provider: The AI inference. Requests can include prompts and relevant source code, subject to that provider's terms.
Private access protects the route to your workspace. It does not make model inference local or stop an authorized agent from changing files it can access.
This is a good fit for documentation changes, investigating code, and small reviewed fixes away from your desk. Large refactors, complicated merge conflicts, and detailed visual debugging are easier with a keyboard and larger screen. An iPad doesn't become a Mac build machine; the server runs Linux tools.
Before paying for a server
You need a DigitalOcean account, a model-provider account with usable billing or quota, a Tailscale account, and SSH access for the initial setup. For iPad-only setup, choose an SSH app that supports SSH keys, multi-line paste, arrow keys, Escape and Ctrl combinations. A hardware keyboard is strongly recommended. A laptop terminal also works and can make setup easier; it does not need to stay awake afterward. The public-access tests later need a separate test client, not a terminal session inside the Droplet. Check the current iPadOS requirement on Tailscale's official App Store listing and current plans, including commercial-use eligibility.
For the small demo below, a modest CPU Droplet is enough to explore. For real projects, size the server for the build and test tools: dependency installation, a development server and browser tests can need much more memory than the coding agent alone. A 2–4 GB RAM budget is a starting estimate for small projects, not a benchmark or universal minimum. Hosted inference doesn't require a GPU Droplet.
Budget for three separate things:
- The server: The price shown for your chosen Droplet and region.
- Model usage: Separate API or supported subscription charges. Don't assume a consumer chat subscription pays for API use.
- Extras: Backups, snapshots, additional storage, transfer overages and any paid private-network plan.
For example, if you choose a server quoted at US$12/month and set yourself a US$10 model-use budget, your planning total is US$22 before extras and taxes. Those are illustrative budget inputs, not a quote for a particular plan or a guaranteed spend cap. Check Droplet pricing and, if you use it, DigitalOcean inference pricing. Spend alerts are warnings; confirm whether your provider offers an actual enforced limit.
Already have a suitable private Linux server? Use it. Before buying anything, read the private-server prerequisite and check that you can complete its SSH, firewall and recovery steps. If an always-available workspace fits your needs, compare DigitalOcean's current options and choose a plan within your budget. Budget time for both guides.
1. Prepare a private Ubuntu server
Use a fresh Ubuntu 24.04 LTS Droplet for this recipe. Follow Lock down a Droplet with Tailscale and private SSH first. That establishes a named administrator called operator, private SSH, a recovery route and public inbound blocking. Keep that recovery route available.
Install Tailscale on the iPad, sign in to the same private network, and turn its connection on. Before proceeding, confirm you can reconnect to the server through your SSH client. The existing guide's SSH-only policy doesn't yet allow the browser connection; we add that in step 5.
DigitalOcean also offers an OpenCode Marketplace image. Its documented setup is terminal-first, with SSH exposed and provider configuration under root. It doesn't give you a ready-to-use private browser address. This article uses plain Ubuntu so the coding agent can run under a separate, unprivileged account. Don't apply these paths unchanged to the Marketplace image.
Run the following in your operator SSH session on the Droplet. The coder account has no sudo privileges and no password login. You administer it through operator.
sudo apt-get update
sudo apt-get install -y git curl unzip openssl less
sudo adduser --disabled-password --gecos "" coder
sudo -iu coder
You are now in the coder account. Run whoami whenever you are unsure: it should print coder here, and operator in administration steps. A plain exit returns from coder to operator. Running the agent as root would give it unnecessary access.
2. Install OpenCode and make a disposable project
In the coder shell, download the official OpenCode 1 installer, read it, then run it if you are comfortable with its contents. These are server commands; nothing installs into Safari.
curl -fsSL https://opencode.ai/install -o /tmp/opencode-install.sh
less /tmp/opencode-install.sh
Press q to leave less after reading the installer. Then run:
bash /tmp/opencode-install.sh
export PATH="$HOME/.opencode/bin:$PATH"
opencode --version
command -v opencode
opencode web --help
Record the version. Continue only with a 1.x installation that supports web, hostname and port. The service below assumes command -v prints /home/coder/.opencode/bin/opencode; use the actual absolute path if yours differs. Don't install OpenCode 1 over an existing OpenCode 2 environment.
Create a harmless project before connecting private source code:
mkdir -p ~/projects/ipad-demo
cd ~/projects/ipad-demo
git init
printf '<h1>Hello from the cloud</h1>\n' > index.html
printf 'A single static greeting page.\n' > README.md
git add index.html README.md
The staged files provide a baseline for git diff even before your first commit.
Still in the demo folder, paste this entire block into the coder shell, including the final EOF on its own line. It writes opencode.json, allows ordinary file reads, asks before edits and other tools, denies agent shell commands and outside-directory access, and disables session sharing and automatic updates. This limits the first exercise; it is not an operating-system sandbox.
cat > opencode.json <<'EOF'
{
"$schema": "https://opencode.ai/config.json",
"share": "disabled",
"autoupdate": false,
"permission": {
"*": "ask",
"read": {
"*": "allow",
"*.env": "deny",
"*.env.*": "deny"
},
"edit": "ask",
"bash": "deny",
"external_directory": "deny"
}
}
EOF
OpenCode merges configuration from several locations, and agent-specific rules can change effective permissions. Start with this fresh account and demo, inspect any managed settings, and leave automatic approval off. Review settings again before opening a real repository, which can bring its own instructions, plugins and configuration. See the permission reference.
Start the terminal interface from the coder shell:
opencode
Inside OpenCode, enter /connect to configure your provider, then /models to choose a model your account can access. These are OpenCode commands, not shell commands. Enter credentials only in its provider-connection flow, never in project files or prompts. For DigitalOcean-specific setup, use the existing serverless-inference guide.
Enter /exit inside OpenCode to return to the coder shell; /quit and /q are aliases. Then type exit at the shell prompt to return to operator. Run whoami and confirm it prints operator before step 3. See OpenCode's terminal command reference.
3. Give the browser server a password and keep it running
A foreground command can stop when its terminal closes. We'll use systemd, Ubuntu's service manager, to start OpenCode independently of that SSH session and restart the service after a server reboot.
In the operator session, generate a random password in a root-owned file outside the project:
sudo install -d -m 700 /etc/opencode-web
sudo sh -c 'umask 077
printf "OPENCODE_SERVER_PASSWORD=%s\n" "$(openssl rand -hex 24)" \
> /etc/opencode-web/credentials'
View it privately with sudo cat /etc/opencode-web/credentials and save the value after the equals sign in your password manager. Don't paste it into a chat, repository or screenshot. OpenCode's default username is opencode. The server documentation describes this HTTP Basic authentication.
Paste this block into the operator shell to write the service file. Change ExecStart before pasting if your verified installation path differs. Include the final EOF line.
sudo tee /etc/systemd/system/opencode-web.service >/dev/null <<'EOF'
[Unit]
Description=Private OpenCode workspace
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=coder
Group=coder
WorkingDirectory=/home/coder/projects/ipad-demo
Environment=HOME=/home/coder
Environment=PATH=/home/coder/.opencode/bin:/usr/local/bin:/usr/bin:/bin
EnvironmentFile=/etc/opencode-web/credentials
ExecStartPre=/bin/sh -c 'test -n "$${OPENCODE_SERVER_PASSWORD}"'
ExecStart=/home/coder/.opencode/bin/opencode web --hostname 127.0.0.1 --port 4096
Restart=on-failure
RestartSec=5
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
The pre-start check refuses to launch without a nonempty password. The environment file keeps the password out of the unit and repository; it is not a secret vault. Processes running as coder can potentially access that account's credentials. Use this as a single-user workspace, without production credentials. See systemd service behavior and environment-file caveats.
Start it and inspect the result:
sudo systemd-analyze verify /etc/systemd/system/opencode-web.service
sudo systemctl daemon-reload
sudo systemctl enable --now opencode-web
sudo systemctl status opencode-web --no-pager
sudo systemctl is-active opencode-web
sudo ss -ltnp | grep ':4096'
curl -sS --max-time 10 -o /dev/null -w '%{http_code}\n' \
http://127.0.0.1:4096/global/health
The status should say active (running), and is-active should print active. Give a first start a few seconds, then repeat the checks if necessary. Active alone does not prove readiness. The listener must be 127.0.0.1:4096, with no 0.0.0.0 or [::] listener on that port. An unauthenticated health request should return 401, not 200 or 000. A 000 result means no HTTP response was received. Stop with sudo systemctl stop opencode-web and investigate unexpected results before continuing.
Now test with the password:
curl -sS --max-time 10 -u opencode -w '\nHTTP %{http_code}\n' \
http://127.0.0.1:4096/global/health
curl prompts for the password without putting it in the command. Expect HTTP 200 and JSON containing healthy: true and the installed version.
The web documentation includes network-wide binding examples. For this setup, keep localhost binding. Don't open port 4096 publicly or enable mDNS.
4. Add a private HTTPS address
Before enabling HTTPS, check the server's Tailscale machine name and your tailnet DNS name. The resulting full hostname can appear permanently in public certificate-transparency records, even though the application remains private. Use a neutral machine name without personal, customer or project details. Read Tailscale's HTTPS guidance before accepting the HTTPS prompt.
In the operator session, run:
sudo tailscale serve --bg http://127.0.0.1:4096
sudo tailscale serve status
Follow any Tailscale prompt to enable HTTPS for your private network. Copy the HTTPS address shown by the command, including the full .ts.net hostname. You don't need to buy a domain.
Tailscale Serve makes this address available within your private network. Its background setting persists across reboot; OpenCode's separate systemd service keeps the application running. Verify Serve reports availability within your private network. Use Serve, not Funnel, which is designed for internet exposure.
5. Allow private HTTPS, then test the boundary
The prerequisite's SSH-only policy deliberately denies TCP 443. Open the Tailscale admin console's access-policy editor and save a copy of the current policy before editing it.
For the single-user teaching tailnet with one tagged server from that guide, this is a complete replacement policy. Replace operator@example.com with your exact Tailscale login and outsider@example.com with a real enrolled test user who must not have access. Keep the server tagged tag:private-server and keep your iPad user-owned. The operators group below contains only you.
{
"groups": {
"group:operators": ["operator@example.com"]
},
"tagOwners": {
"tag:private-server": ["group:operators"]
},
"grants": [
{
"src": ["group:operators"],
"dst": ["tag:private-server"],
"ip": ["tcp:22", "tcp:443"]
}
],
"tests": [
{
"src": "operator@example.com",
"accept": ["tag:private-server:22", "tag:private-server:443"],
"deny": ["tag:private-server:4096", "tag:private-server:8123"]
},
{
"src": "outsider@example.com",
"deny": [
"tag:private-server:22", "tag:private-server:443",
"tag:private-server:4096", "tag:private-server:8123"
]
}
]
}
This changes both the grant and the operator test: 443 moves from deny to accept, while direct backend access on 4096 stays denied. Run the policy editor's validation before saving. Reconnect to private SSH afterward to confirm that you kept access.
For an existing/shared tailnet, adapt the grant and tests instead of replacing unrelated settings. A tag matches every device carrying it, and the group matches every listed member. Use a dedicated server tag and a one-person source if your existing tag or group is broader. Keep their tag-owner definitions consistent. Existing broad grants and legacy ACL allows are additive; a narrow rule does not cancel them. See Tailscale access controls.
If you have no second test identity, omit its test object rather than inventing an account. Record that check as untested and arrange a real denied-user test before adding sensitive code. Policy validation checks intended permissions; it is not a substitute for connection tests.
There is no new public DigitalOcean firewall rule for port 443 or 4096. Tailscale carries the private connection.
Open Safari
On the iPad:
- Turn Tailscale on and confirm the server is online.
- Open Safari and paste the exact HTTPS address from Serve.
- Sign in with username opencode and the password saved in step 3.
- Open the demo project/session and check that the working directory is /home/coder/projects/ipad-demo.
- Bookmark the address. Another trusted device belonging to your permitted identity can use the same address after joining the private network.
Check who cannot get in
Keep the demo disposable until these checks pass. Record the source device, destination, time and result.
- Password required: A fresh browser session should request the OpenCode password. Browsers can cache HTTP Basic credentials, so an independent curl request without -u is a clearer check: it must return 401. Run it from an authorized test client with Tailscale on, using the URL below.
- Tailscale required: Disconnect Tailscale on that client and make a new request. It must fail to connect. An already loaded Safari page is not a new connection. Reconnect Tailscale afterward.
- Your identity required: From a device signed in as the real denied test user, the private URL must fail before an HTTP response. Even a 401 means the network policy admitted that user.
- No public application ports: From a separate client outside the tailnet, test the Droplet's public IPv4 and any assigned public IPv6 on TCP 443 and 4096. Do not run this inside your SSH session on the Droplet.
On a test computer with curl, replace the example with the exact Serve URL:
WORKSPACE_URL='https://YOUR-SERVER.YOUR-TAILNET.ts.net'
curl --noproxy '*' --connect-timeout 5 --max-time 10 \
-sS -o /dev/null -w 'HTTP %{http_code}\n' \
"$WORKSPACE_URL/global/health"
Expect HTTP 401 while authorized and connected. Off-tailnet or denied-user attempts should produce a connection/DNS failure and HTTP 000. A 000 alone is inconclusive: first confirm that the same URL worked from the authorized client, and inspect the error. Certificate errors also mean a connection was reached; don't bypass a browser security warning.
For public TCP tests, use netcat on the outside client, substituting your actual addresses:
PUBLIC_IPV4='YOUR_DROPLET_PUBLIC_IPV4'
nc -4 -vz -w 5 "$PUBLIC_IPV4" 443
nc -4 -vz -w 5 "$PUBLIC_IPV4" 4096
If the Droplet has public IPv6 and the test client has a working IPv6 route:
PUBLIC_IPV6='YOUR_DROPLET_PUBLIC_IPV6'
nc -6 -vz -w 5 "$PUBLIC_IPV6" 443
nc -6 -vz -w 5 "$PUBLIC_IPV6" 4096
A successful TCP connection is a failure of the intended boundary, even if the application then asks for a password. Refusal or timeout is the expected negative result from a working test client. DNS, routing or netcat-option errors do not prove the firewall works. If no public IPv6 is assigned, mark that check not applicable; if the client lacks IPv6, mark it untested and use a capable client later. Netcat options vary; use the equivalent TCP connection test for your client.
An iPad SSH app's remote shell runs these commands on the server, so it cannot establish the outside-client result. Use another test computer if your iPad app has no local network-testing tools. Don't count an unavailable test as a pass.
If any denied-access check connects, run sudo systemctl stop opencode-web as operator while fixing the policy. A working Safari page alone isn't proof that access is private.
6. Make one small change from Safari
Start with this request:
Read README.md and index.html. Explain what this demo does.
Do not edit files or run commands.
Look for the actual file-read activity and check the explanation matches the two files. Deny any unexpected approval request. Then ask:
Change only the heading in index.html to "Hello from my iPad".
Ask for approval before editing. Do not run commands or change other files.
Approve only that edit. This walkthrough uses SSH for the independent file check rather than assuming a particular Safari diff layout. In an operator SSH session, switch to coder and inspect the file:
sudo -iu coder
cd ~/projects/ipad-demo
git --no-pager diff -- index.html
cat index.html
The expected diff changes one line. A model's claim that it finished is not a substitute for checking the file.
Close Safari, reopen your bookmark, and find the same project and session. The file lives on the server's disk, so closing the browser doesn't remove it. A service restart or network interruption can still interrupt a running request; check the result before asking the agent to repeat an action. Don't leave a destructive or ambiguous task running just because the server stays on.
7. Move to a real project after the demo passes
- Finish the current request and save anything you need. In coder, check git status. Type exit to return to operator, then stop the service with sudo systemctl stop opencode-web.
- Switch back with sudo -iu coder. Clone a repository you trust into /home/coder/projects, using narrowly scoped Git credentials and the repository's own clone URL. Never put a token directly in a clone URL or shell command. Create a working branch.
- Inspect its OpenCode configuration, AGENTS.md instructions, plugins, scripts and dependencies before opening the project or installing its toolchain. The demo denies agent shell commands; allow only the specific commands your real workflow needs after reviewing them. Repository configuration can override other settings. Merge the permission and update settings you intend to keep into the new project's reviewed configuration; changing WorkingDirectory does not carry over the demo's opencode.json. Keep automatic approval off.
- Install the project's documented tools as coder. Confirm builds and tests work, and keep production secrets and deployment credentials off the workspace. Runtime version managers may need an explicit PATH in the systemd service; an interactive shell's configuration is not automatically loaded there.
- Type exit to return to operator. Write a WorkingDirectory override for the actual project path. In this example the new folder is /home/coder/projects/my-project; replace it before running the block.
sudo install -d -m 755 /etc/systemd/system/opencode-web.service.d
sudo tee /etc/systemd/system/opencode-web.service.d/project.conf \
>/dev/null <<'EOF'
[Service]
WorkingDirectory=/home/coder/projects/my-project
EOF
sudo systemctl daemon-reload
sudo systemd-analyze verify /etc/systemd/system/opencode-web.service
sudo systemctl restart opencode-web
sudo systemctl status opencode-web --no-pager
- Repeat the listener and authenticated/unauthenticated health checks from step 3. Reopen Safari and explicitly select the new project path; an old bookmark may reopen the demo session. Start with a read-only explanation, then one reviewed change. Repeat the access checks after network or policy changes.
Your app preview is separate from the OpenCode interface. A development server's localhost address refers to the Droplet, not the iPad. Add a separate private proxy and access rule only when you need that preview; opening your development port publicly isn't the shortcut.
Keep the workspace, and keep a way to recover it
A persistent server isn't a backup. Push reviewed commits to your Git remote and back up uncommitted work or other files you can't recreate. On this default Linux account, OpenCode's data is under /home/coder/.local/share/opencode/ and provider credentials are in its auth.json file. These live outside the repository; decide what needs an encrypted backup and what should be re-created instead. See the storage paths and provider credentials documentation. Treat a full-server snapshot as sensitive because it can contain both code and credentials.
DigitalOcean backups and snapshots have separate costs and retention behavior. Test a restore before trusting it. Git doesn't preserve ignored files, databases, local secrets or every piece of OpenCode state.
The demo turns automatic OpenCode updates off for predictable behavior. Apply server and OpenCode updates deliberately, verify the recorded major version, and repeat the login and denied-access tests after changes. Review this setting again when moving to a different project. Remove old devices and credentials when they're no longer needed.
Powering off a Droplet does not stop its server bill. To end Droplet billing, you must destroy it. That also deletes its local disk: export anything you need first. Check separately for billable volumes, snapshots and other retained resources; see DigitalOcean's deletion instructions. Remove the retired Tailscale device and revoke credentials that were only needed there.
If something doesn't work
- Safari can't connect: Confirm Tailscale is connected, the hostname is exact, Serve is configured, and your identity can reach this server on TCP 443. Don't fix it by opening public ports.
- The proxy responds, but OpenCode doesn't: In operator, check sudo systemctl status opencode-web and sudo journalctl -u opencode-web -n 50 --no-pager. Confirm the executable path, project folder and localhost listener. Review logs for secrets before sharing them.
- Login repeats: Use opencode as the username and the stored server password. This is separate from your model-provider credentials. If you rotate it, update the credential file and restart the service.
- The interface loads, but the model fails: Check provider selection, account access and spending limits under coder. Credentials configured as operator or root won't automatically belong to coder. Use the OpenCode provider troubleshooting guide.
- It worked before a reboot: Check both the OpenCode service and Tailscale Serve status. They are separate services. Never assume an interrupted request finished.
Start with a workspace you can afford to lose
The first milestone is small: open a private HTTPS address on your iPad, approve one harmless edit, check the diff, and reconnect to the same files. Once that works, move a real branch into the workspace and add backups before relying on it.
If that would make your iPad more useful, choose a DigitalOcean server within your budget and start with the private-server setup. Keep the first project disposable and the first model budget small.