An experimental guide to transitioning from a tethered SSH/terminal workflow to a resilient, mobile-friendly environment for running AI agents autonomously—without relying on managed platforms or incurring high idle costs.
This design lets you run long-lived agent loops in the cloud and drive them primarily from a web browser on your laptop or phone (via a Zero-Trust tunnel), receive async push notifications when the agents need a human, and keep the whole stack alive across devices — with plain SSH reserved only for bootstrapping and administration.
- Compute: Hetzner Cloudcost-optimized ARM VM — the flavor I use isCAX33 (seecost-optimized ). Flavors change over time; if you don't see CAX33, pick the current cost-optimized ARM flavor in your size/price range.
- Orchestration:
zellijto keep the long-running daemons (the OpenCode server and the ngrok tunnel) alive after you disconnect. - Cost Management (Snapshot & Destroy): When you are done developing for the week, do not leave the VM running. Take a snapshot in the Hetzner console and delete the server. Snapshots cost a small per-GB monthly fee*(check the current rate on Hetzner's pricing/add-on page)* . Spinning up a new VM from the snapshot takes about 10 seconds and perfectly restores your environment.
- Create a Hetzner Cloud project .
- Add your SSH public key (
~/.ssh/id_ed25519.pub) underSecurity → SSH Keys in the project. - Create a server : image
Ubuntu 24.04, cost-optimized ARM flavor (e.g.CAX33), region nearest you, and select your SSH key. - First login as root:
ssh root@<YOUR_VM_IP>
- Create a non-root
devuser with sudo, and install your SSH key for it:
adduser dev
usermod -aG sudo dev
mkdir -p /home/dev/.ssh
cp ~/.ssh/authorized_keys /home/dev/.ssh/authorized_keys
chown -R dev:dev /home/dev/.ssh
chmod 700 /home/dev/.ssh && chmod 600 /home/dev/.ssh/authorized_keys
- Disable password authentication (keep key auth):
sudo sed -i -E 's/^#?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo systemctl reload sshd
From here on, log in as dev, not root.
In the Hetzner console, create a Cloud Firewall attached to this server with a single rule:
- Inbound: TCP
22only (source: your IP or0.0.0.0/0if you roam).
That's the whole point of this design: you never open inbound web ports. The web UI, code-server, and everything else reach you outbound through ngrok (which initiates the connection from the VM), so there is no public attack surface beyond SSH itself.
Add an SSH alias so you can hop on with ssh vm:
Host vm
HostName <YOUR_VM_IP>
User dev
IdentityFile ~/.ssh/id_ed25519
Plain SSH remains for bootstrapping and administration only. Your day-to-day interface is the browser (Section 4); SSH is the fallback for fixing things when the web path is down.
This is the primary everyday interface: a browser pointed at an ngrok URL, protected by Google OAuth, driving OpenCode's built-in web UI (and optionally code-server for a fuller IDE).
- On your laptop: open the ngrok URL in any browser. That's it — no SSH, no terminal emulator.
- On your phone: open the same ngrok URL in the mobile browser andAdd to Home Screen — OpenCode's web UI behaves like a Progressive Web App, giving you an app-like icon and full-screen feel. This replaces the old "Blink Shell over Mosh" mobile story entirely.
- The built-in OpenCode web UI (served on port
4096byopencode serve, Section 7) is the primary path — it's the smoothest mobile experience and needs no extra frontend. opencode-web(a third-party frontend) is anoptional alternative if you want a richer chat/diff UI (Section 8).- code-server is the fuller IDE-in-browser option: a real VS Code in a tab. On amobile browser it is usable but cramped — save code-server for laptop/desktop sessions (Section 13).
- Everything is reached over ngrok, so no inbound firewall ports are needed.
Install OpenCode with the official script, then verify:
curl -fsSL https://opencode.ai/install | bash
opencode --version
(Alternative package managers: npm install -g opencode-ai, bun install -g opencode-ai, or brew install anomalyco/tap/opencode.)
- Authenticate your LLM providers. This stores credentials in
~/.local/share/opencode/auth.json(never commit this):
opencode auth login
- Other config lives in
~/.config/opencode/opencode.json(global) or a project'sopencode.json— models, agents, permissions, and plugins all go here. - You can also supply keys via environment variables or a
.envfile, butnever commit secrets ; addauth.json,.env, and any key files to your dotfiles'.gitignore.
Run OpenCode as a headless background server inside Zellij so it survives disconnects, and serve the built-in web UI on port 4096.
Zellij basics: start with zellij; detach with Ctrl+o then d; re-attach with zellij attach; list sessions with zellij list-sessions.
zellij
opencode serve --port 4096
Zellij's role is now simple: it keeps opencode serve and the ngrok daemon alive after you close SSH. You don't use it as your everyday UI — the browser is that.
(opencode serve binds to localhost by default — all ngrok needs since it tunnels from the same VM. If you ever bind to 0.0.0.0, set a password via OPENCODE_SERVER_PASSWORD.)
OpenCode's built-in web UI (Section 7) is the recommended frontend. If you prefer a richer chat/diff experience, opencode-web is a separate, optional frontend that talks to the opencode serve API on localhost:4096:
git clone https://github.com/chris-tse/opencode-web
cd opencode-web
bun install # Bun recommended; Node 18+ also works
bun dev # dev server (default http://localhost:5173)
The app auto-detects the OpenCode API on localhost:4096.
This is optional. The built-in UI is simpler (fewer moving parts) and is what the rest of this guide assumes. If you do run opencode-web instead, tunnel its dev-server port (default 5173) rather than 4096 in Section 9.
Install ngrok from its apt repository — this resolves the right package for both amd64 and arm64 automatically:
curl -sSL https://ngrok-agent.s3.amazonaws.com/ngrok.asc \
| sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null
echo "deb https://ngrok-agent.s3.amazonaws.com bookworm main" \
| sudo tee /etc/apt/sources.list.d/ngrok.list
sudo apt update
sudo apt install ngrok
bookworm here is ngrok's static repo label, not your OS version — ngrok's repo only carries bookworm/ bullseye/ buster and the packages work across Debian/Ubuntu. Do not substitute $(lsb_release -cs) (Ubuntu 24.04 returns noble, which isn't in the repo).
Authenticate and launch, restricting access by email:
ngrok config add-authtoken <YOUR_NGROK_TOKEN>
ngrok http 4096 --oauth=google --oauth-allow-email="your.email@gmail.com"
ngrok specifics:
- Authtoken comes from the ngrok dashboard (
dashboard.ngrok.com). - Free-tier limits: up to3 concurrent agents / 3 online endpoints (verified against ngrok.com/pricing). The free tier also serves an interstitial page on HTTP/S endpoints and caps monthly requests/data transfer.
- Domains: the free tier gives you arandom
*.ngrok-free.appsubdomain; astatic*.ngrok.app(or your own domain) is a paid feature. - Rotation nuance: the free subdomain stays stable for as long as the tunnel process runs continuously (days/weeks), but itrotates every time the ngrok agent restarts — and the snapshot → destroy → rebuild workflow triggers that on every rebuild. So if you want a permanently bookmarkable URL / stable home-screen shortcut, you need a static (paid) domain.
- Org-wide access: swap
--oauth-allow-emailfor--oauth-allow-domain="yourcompany.com"to admit everyone in a Google Workspace domain. - (Optional: newer ngrok also supports OAuth via a
--traffic-policy-file policy.yamlfor finer-grained rules — but the simple flags above are all you need here.)
Once this is running inside Zellij, detach (Ctrl+o then d) and close your terminal. The OpenCode server, web UI, and ngrok tunnel keep running in the background.
To coordinate different specialized roles and avoid hitting rate limits or single points of failure, I built a small orchestrated agentic workflow called opencode-router.
This script dynamically routes tasks between specialized models: an Architect for system design, a Developer for implementation, and a Reviewer for code checks. By keeping this orchestration modular, you can easily plug in different API endpoints or local models based on the task complexity.
When running the orchestrated models simultaneously (e.g., the Developer implementing code while the Reviewer analyzes diffs on another task), you must isolate them so they do not overwrite each other's files in the same directory.
Use Git Worktrees to give each agent a dedicated branch and folder without cloning the repository twice:
git worktree add ../feature-branch -b feature-branch
cd ../feature-branch
opencode run "Refactor the authentication middleware"
Watching terminal logs for an agent to finish is a waste of time. Instead, configure a Telegram bot to ping your phone only when necessary — when an agent asks for permission, asks a question, or finishes a task.
The @goodnesshq/opencode-notification plugin listens for exactly those events and skips the rest of the chatter.
⚠️ The subagent gotcha. When you drive agents through opencode serve / opencode web (especially the orchestrator's architect/developer/reviewer subagents), those sessions run as subagents. The plugin's ignoreSubagents option defaults to true, which silently drops subagent notifications — so set "ignoreSubagents": false or you'll get nothing.
Declare the plugin in ~/.config/opencode/opencode.json (an npm package name, or a local plugin file as shown):
{
"plugin": ["~/.config/opencode/plugins/opencode-notifications.mjs"]
}
Then add its config in ~/.config/opencode/oc-notify.json (Telegram bot token from @BotFather, Chat ID from @userinfobot):
{
"enabled": true,
"title": "",
"ignoreSubagents": false,
"telegram": {
"token": "<YOUR_BOT_TOKEN>",
"chatId": "<YOUR_CHAT_ID>"
}
}
Quick test — Telegram vs. plugin: confirm Telegram itself works (token / Chat ID / network) independently of OpenCode:
curl -X POST "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/sendMessage" \
-d "chat_id=<YOUR_CHAT_ID>" \
-d "text=Test notification from Hetzner VM"
401 = bad/revoked token, 403 = bot blocked, timeout = egress/DNS issue. If this succeeds but notifications stay silent, the problem is in the plugin or OpenCode — check opencode serve logs and restart the server.
You can run code-server alongside your agents for a complete VS Code environment in the browser. It adds real CPU/RAM overhead on top of the agent processes, so if your CAX33 instance feels cramped while both run at once, bump to the next-larger flavor.
curl -fsSL https://code-server.dev/install.sh | sh
sudo systemctl enable --now code-server@dev
- Non-root requirement: code-server refuses to run as root, so run it as the
devuser (or$USERif you're logged in as a normal user). The systemd unitcode-server@<user>runs as that user. - Password caveat:
cat ~/.config/code-server/config.yaml | grep passwordonly works if you have** not** setPASSWORD/hashed-passwordin the config or environment. If you set your own, that's the password to use — the auto-generated one won't exist.
To optimize code-server so its file watchers do not consume all your VM's CPU and starve the AI agents, configure your workspace settings (~/.local/share/code-server/User/settings.json) to ignore heavy directories:
{
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/.git/subtree-cache/**": true,
"**/node_modules/*/**": true,
"**/target/**": true,
"**/dist/**": true
},
"search.exclude": {
"**/node_modules": true,
"**/target": true
}
}
You can expose code-server (running on port 8080 by default) using a second ngrok tunnel, applying the exact same Google OAuth configuration used in Section 9.
-
Keep your code on GitHub and push regularly — the VM is disposable by design (snapshot & destroy), so never treat its disk as the source of truth.
-
Version your dotfiles (including
~/.config/opencode/opencode.json,~/.ssh/config, and Zellij config) in a dotfiles repo. -
Snapshot cadence reminder: snapshot after meaningful setup milestones (after provisioning, after installing/configuring OpenCode + plugins) so a rebuild restores a known-good state.
-
Check running agent sessions:
zellij list-sessionsthenzellij attachto jump into the live terminal; detach again withCtrl+othend. -
OpenCode sessions/stats:
opencode session listto see sessions andopencode statsfor token/cost usage. -
ngrok: watch the tunnel's request log in the same Zellij pane, or the ngrok dashboard's traffic inspector.
-
ngrok free-tier limits: the free plan allows up to3 concurrent agents / 3 online endpoints , so the web UI (4096) plus a code-server tunnel (8080) fits within the cap — but remember the free tier also shows an interstitial page and caps monthly requests/data. For a permanent URL without the interstitial, upgrade to a paid plan.
-
Notifications not firing: First run the Telegram curl test in Section 12 to isolate Telegram-side vs plugin-side failure. Confirm the plugin is listed in your OpenCode config (not globally installed), the Telegram token/Chat ID are set correctly, and
ignoreSubagentsisfalseinoc-notify.json(otherwise subagent notifications are silently dropped). Check the OpenCode startup logs for plugin load errors. -
Web UI can't connect to 4096: the
opencode serveprocess isn't running or isn't bound where the UI expects. Verify withopencode serve --port 4096in a Zellij pane, and confirm ngrok is tunnelinglocalhost:4096on the same VM. -
Restrictive networks (airports, corporate Wi-Fi): if the ngrok domain is blocked, fall back to SSH (
ssh vm) — it's exactly why SSH stays as the admin/bootstrap path — or tether to your phone's hotspot.