{"slug": "personal-cloud-native-ai-agent-workspace", "title": "☁️ Personal Cloud-Native AI Agent Workspace", "summary": "A developer published an experimental guide for running long-lived AI agent loops on a cost-optimized Hetzner Cloud ARM VM (CAX33), using zellij to keep the OpenCode server and ngrok tunnel alive and driving the agents from a browser on laptop or phone via a Zero-Trust tunnel. The setup keeps only TCP port 22 open inbound, reaches the web UI outbound through ngrok with Google OAuth, and uses a snapshot-and-destroy workflow to avoid idle costs.", "body_md": "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.\n\nThis 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.\n\n- **Compute:** Hetzner Cloud**cost-optimized ARM** VM — the flavor I use is**CAX33** (see[cost-optimized](https://www.hetzner.com/cloud/cost-optimized/) ). Flavors change over time; if you don't see CAX33, pick the current cost-optimized ARM flavor in your size/price range.\n- **Orchestration:**`zellij` to keep the long-running daemons (the OpenCode server and the ngrok tunnel) alive after you disconnect.\n- **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.\n\n1. Create a Hetzner Cloud **project** .\n2. Add your **SSH public key** (`~/.ssh/id_ed25519.pub` ) under*Security → SSH Keys* in the project.\n3. Create a **server** : image`Ubuntu 24.04` , cost-optimized ARM flavor (e.g.`CAX33` ), region nearest you, and select your SSH key.\n4. First login as root:\n\n```\nssh root@<YOUR_VM_IP>\n```\n\n1. Create a non-root `dev` user with sudo, and install your SSH key for it:\n\n```\nadduser dev\nusermod -aG sudo dev\nmkdir -p /home/dev/.ssh\ncp ~/.ssh/authorized_keys /home/dev/.ssh/authorized_keys\nchown -R dev:dev /home/dev/.ssh\nchmod 700 /home/dev/.ssh && chmod 600 /home/dev/.ssh/authorized_keys\n```\n\n1. Disable password authentication (keep key auth):\n\n```\nsudo sed -i -E 's/^#?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config\nsudo systemctl reload sshd\n```\n\nFrom here on, log in as `dev`, not root.\n\nIn the Hetzner console, create a **Cloud Firewall** attached to this server with a single rule:\n\n- **Inbound:** TCP`22` only (source: your IP or`0.0.0.0/0` if you roam).\n\nThat'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.\n\nAdd an SSH alias so you can hop on with `ssh vm`:\n\n```\n# ~/.ssh/config\nHost vm\n    HostName <YOUR_VM_IP>\n    User dev\n    IdentityFile ~/.ssh/id_ed25519\n```\n\n**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.\n\nThis 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).\n\n- **On your laptop:** open the ngrok URL in any browser. That's it — no SSH, no terminal emulator.\n- **On your phone:** open the same ngrok URL in the mobile browser and**Add 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.\n- The **built-in OpenCode web UI** (served on port`4096` by`opencode serve` , Section 7) is the primary path — it's the smoothest mobile experience and needs no extra frontend.\n- **[`opencode-web`](https://github.com/chris-tse/opencode-web)** (a third-party frontend) is an**optional** alternative if you want a richer chat/diff UI (Section 8).\n- **code-server** is the fuller IDE-in-browser option: a real VS Code in a tab. On a**mobile browser it is usable but cramped** — save code-server for laptop/desktop sessions (Section 13).\n- Everything is reached over ngrok, so no inbound firewall ports are needed.\n\nInstall OpenCode with the official script, then verify:\n\n```\ncurl -fsSL https://opencode.ai/install | bash\nopencode --version\n```\n\n*(Alternative package managers: `npm install -g opencode-ai`, `bun install -g opencode-ai`, or `brew install anomalyco/tap/opencode`.)*\n\n- Authenticate your LLM providers. This stores credentials in `~/.local/share/opencode/auth.json` (never commit this):\n\n```\nopencode auth login\n```\n\n- Other config lives in `~/.config/opencode/opencode.json` (global) or a project's`opencode.json` — models, agents, permissions, and plugins all go here.\n- You can also supply keys via environment variables or a `.env` file, but**never commit secrets** ; add`auth.json` ,`.env` , and any key files to your dotfiles'`.gitignore` .\n\nRun OpenCode as a headless background server inside Zellij so it survives disconnects, and serve the built-in web UI on port `4096`.\n\n**Zellij basics:** start with `zellij`; **detach** with `Ctrl+o` then `d`; **re-attach** with `zellij attach`; **list sessions** with `zellij list-sessions`.\n\n```\n# 1. Start (or attach to) a persistent Zellij session\nzellij\n\n# 2. Start the OpenCode headless server — serves the API and web UI on 4096\nopencode serve --port 4096\n```\n\n**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.\n\n*(`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`.)*\n\nOpenCode's **built-in** web UI (Section 7) is the recommended frontend. If you prefer a richer chat/diff experience, [`opencode-web`](https://github.com/chris-tse/opencode-web) is a separate, **optional** frontend that talks to the `opencode serve` API on `localhost:4096`:\n\n```\ngit clone https://github.com/chris-tse/opencode-web\ncd opencode-web\nbun install        # Bun recommended; Node 18+ also works\nbun dev            # dev server (default http://localhost:5173)\n```\n\nThe app auto-detects the OpenCode API on `localhost:4096`.\n\n**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.\n\nInstall ngrok from its apt repository — this resolves the right package for **both amd64 and arm64** automatically:\n\n```\ncurl -sSL https://ngrok-agent.s3.amazonaws.com/ngrok.asc \\\n  | sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null\necho \"deb https://ngrok-agent.s3.amazonaws.com bookworm main\" \\\n  | sudo tee /etc/apt/sources.list.d/ngrok.list\nsudo apt update\nsudo apt install ngrok\n```\n\n`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).\n\nAuthenticate and launch, restricting access by email:\n\n```\nngrok config add-authtoken <YOUR_NGROK_TOKEN>\n\n# tunnel the built-in OpenCode web UI (port 4096), Google OAuth at the edge\nngrok http 4096 --oauth=google --oauth-allow-email=\"your.email@gmail.com\"\n```\n\n**ngrok specifics:**\n\n- **Authtoken** comes from the ngrok dashboard (`dashboard.ngrok.com` ).\n- **Free-tier limits:** up to**3 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.\n- **Domains:** the free tier gives you a**random `*.ngrok-free.app`** subdomain; a**static `*.ngrok.app`** (or your own domain) is a paid feature.\n- **Rotation nuance:** the free subdomain stays stable for as long as the tunnel process runs continuously (days/weeks), but it**rotates 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.\n- **Org-wide access:** swap`--oauth-allow-email` for`--oauth-allow-domain=\"yourcompany.com\"` to admit everyone in a Google Workspace domain.\n- *(Optional: newer ngrok also supports OAuth via a `--traffic-policy-file policy.yaml` for finer-grained rules — but the simple flags above are all you need here.)*\n\n*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.*\n\nTo coordinate different specialized roles and avoid hitting rate limits or single points of failure, I built a small orchestrated agentic workflow called **opencode-router**.\n\nThis 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.\n\nWhen 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.\n\nUse **Git Worktrees** to give each agent a dedicated branch and folder without cloning the repository twice:\n\n```\n# Create a new, isolated working directory tied to a new branch.\n# The ../ sibling path is intentional — each worktree is a separate checkout,\n# and every agent gets its own branch + worktree so they never collide.\ngit worktree add ../feature-branch -b feature-branch\n\n# Move into the new directory\ncd ../feature-branch\n\n# Start a parallel agent session here\nopencode run \"Refactor the authentication middleware\"\n```\n\nWatching 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.\n\nThe [`@goodnesshq/opencode-notification`](https://www.npmjs.com/package/@goodnesshq/opencode-notification) plugin listens for exactly those events and skips the rest of the chatter.\n\n⚠️ **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.\n\nDeclare the plugin in `~/.config/opencode/opencode.json` (an npm package name, or a local plugin file as shown):\n\n```\n{\n  \"plugin\": [\"~/.config/opencode/plugins/opencode-notifications.mjs\"]\n}\n```\n\nThen add its config in `~/.config/opencode/oc-notify.json` (Telegram bot token from `@BotFather`, Chat ID from `@userinfobot`):\n\n```\n{\n  \"enabled\": true,\n  \"title\": \"\",\n  \"ignoreSubagents\": false,\n  \"telegram\": {\n    \"token\": \"<YOUR_BOT_TOKEN>\",\n    \"chatId\": \"<YOUR_CHAT_ID>\"\n  }\n}\n```\n\n**Quick test — Telegram vs. plugin:** confirm Telegram itself works (token / Chat ID / network) independently of OpenCode:\n\n```\ncurl -X POST \"https://api.telegram.org/bot<YOUR_BOT_TOKEN>/sendMessage\" \\\n     -d \"chat_id=<YOUR_CHAT_ID>\" \\\n     -d \"text=Test notification from Hetzner VM\"\n```\n\n`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.\n\nYou 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.\n\n```\n# Install code-server\ncurl -fsSL https://code-server.dev/install.sh | sh\n\n# Enable and start the service (must run as a NON-root user)\nsudo systemctl enable --now code-server@dev\n```\n\n- **Non-root requirement:** code-server refuses to run as root, so run it as the`dev` user (or`$USER` if you're logged in as a normal user). The systemd unit`code-server@<user>` runs as that user.\n- **Password caveat:**`cat ~/.config/code-server/config.yaml | grep password` only works if you have** not** set`PASSWORD` /`hashed-password` in the config or environment. If you set your own, that's the password to use — the auto-generated one won't exist.\n\nTo 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:\n\n```\n{\n  \"files.watcherExclude\": {\n    \"**/.git/objects/**\": true,\n    \"**/.git/subtree-cache/**\": true,\n    \"**/node_modules/*/**\": true,\n    \"**/target/**\": true,\n    \"**/dist/**\": true\n  },\n  \"search.exclude\": {\n    \"**/node_modules\": true,\n    \"**/target\": true\n  }\n}\n```\n\nYou 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.\n\n- **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.\n- **Version your dotfiles** (including`~/.config/opencode/opencode.json` ,`~/.ssh/config` , and Zellij config) in a dotfiles repo.\n- **Snapshot cadence reminder:** snapshot after meaningful setup milestones (after provisioning, after installing/configuring OpenCode + plugins) so a rebuild restores a known-good state.\n\n- **Check running agent sessions:**`zellij list-sessions` then`zellij attach` to jump into the live terminal; detach again with`Ctrl+o` then`d` .\n- **OpenCode sessions/stats:**`opencode session list` to see sessions and`opencode stats` for token/cost usage.\n- **ngrok:** watch the tunnel's request log in the same Zellij pane, or the ngrok dashboard's traffic inspector.\n\n- **ngrok free-tier limits:** the free plan allows up to**3 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.\n- **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`ignoreSubagents` is`false` in`oc-notify.json` (otherwise subagent notifications are silently dropped). Check the OpenCode startup logs for plugin load errors.\n- **Web UI can't connect to 4096:** the`opencode serve` process isn't running or isn't bound where the UI expects. Verify with`opencode serve --port 4096` in a Zellij pane, and confirm ngrok is tunneling`localhost:4096` on the same VM.\n- **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.", "url": "https://wpnews.pro/news/personal-cloud-native-ai-agent-workspace", "canonical_source": "https://gist.github.com/hamednourhani/e49492915ac50f50108e04f2cc722a80", "published_at": "2026-09-05 09:29:00+00:00", "updated_at": "2026-09-24 08:30:07.668717+00:00", "lang": "en", "topics": ["ai-agents", "ai-infrastructure", "developer-tools", "ai-tools"], "entities": ["Hetzner Cloud", "OpenCode", "ngrok", "zellij", "code-server", "Ubuntu", "Google", "opencode-web"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/personal-cloud-native-ai-agent-workspace", "markdown": "https://wpnews.pro/news/personal-cloud-native-ai-agent-workspace.md", "text": "https://wpnews.pro/news/personal-cloud-native-ai-agent-workspace.txt", "jsonld": "https://wpnews.pro/news/personal-cloud-native-ai-agent-workspace.jsonld"}}