DEV Community

Cover image for My coding-agent buddies live on a €5 VPS, and I watch them from the web
MojaLab
MojaLab

Posted on • Originally published at mojalab.com

My coding-agent buddies live on a €5 VPS, and I watch them from the web

The scene: I'm at home, Claude Code is running on my MacBook, forty minutes into a refactor and nowhere near done, and — whoops, it's late. I have to leave, and life does not respect long-running agents. On a laptop-bound setup that's a dead session: SSH drops, tmux is on the other machine, the agent is orphaned mid-task.

If instead I were working on my VPS, I'd close the laptop, walk out, and twenty minutes later pull out my phone, open mterm.mylab.example.com, pass Authelia (password + TOTP), and find myself in front of the same terminal — the agent still chewing through files, me scrolling its output with a thumb.

You can rent a cloud IDE and get something similar with three clicks and a subscription. It's a perfectly fine answer for most people. But if you've been around MojaLab for a while you know the rule of the house: if a problem can be solved with off-the-shelf SaaS or with a weekend, a terminal and a few open-source tools — we take the weekend. Not because the SaaS is bad — it isn't — but because we like to understand by doing.

So I built mojalab-vps-stack: a Docker Compose stack that turns any cheap VPS into a browser-first workbench for coding agents. Ten containers (one a one-shot initializer), one network, one Caddyfile. MIT-licensed, on GitHub.

One gate (Authelia) in front of every door; the workspace shared between terminal and file manager; the plumbing talks to Docker only through a read-only proxy.

A note on what this post is. The full step-by-step setup — DNS records, Authelia secrets, the guided installer, TOTP enrollment, troubleshooting — lives in the README on the repository. It's the canonical reference and it gets updated when the stack does. If you want the how-to, go there (or just run ./scripts/install.sh and answer the questions). This post is the why: the design decisions, the non-obvious failure modes, and what I learned running coding agents on this thing for real.

Why browser-first

The usual way to work on a remote dev box is SSH plus tmux, and it works — for you, from your laptop, with your keys. It stops working the moment the client isn't your laptop: a phone, a tablet, a borrowed machine, a locked-down corporate desktop. And "install Termius and copy your private key to your phone" is a sentence that should make you flinch.

The browser is the one client that exists everywhere. So the design goal was: every useful thing reachable from a URL, every URL behind one real authentication gate. Not a VPN (nothing to install), not per-app passwords (one gate, one session), not security-by-obscurity on a high port (scanners don't care).

The gate is Authelia: single sign-on with TOTP second factor, enforced by Caddy's forward_auth on every subdomain. Behind it:

  • term.*Zellij's native web client: full multiplexer, panes, tabs, sessions that survive disconnects
  • mterm.* — a mobile-friendly terminal (wetty) that SSHes into the same Zellij container and can join the same session
  • files.* — Filestash, a web file manager over the shared workspace
  • stats.* — Glances, so you can watch the agent eat CPU in real time
  • home.* — a landing page with links, because muscle memory needs a home

Plus the plumbing you don't see: Watchtower (monitor-only — it tells me about updates via Telegram, it never surprise-updates anything), a Telegram bot for /stats and proactive alerts, and a read-only Docker socket proxy so neither of those can ever exec into a container even if compromised.

The workbench: a container the agents can trash

The heart of the stack is the Zellij container, and it's shaped by one observation: coding agents are messy tenants. They install packages, compile things, run test suites, spawn processes. You want to give them room to do that — and walls they can't take down.

A preemptive confession about Zellij: at first it's a little annoying. The shortcuts aren't tmux's, the keybinding bar looks like noise, and your first ten minutes go into closing panes you opened by accident. Then it clicks — panes, tabs, the session manager, the detach that just works — and you grow fond of it. Give it an evening before you judge it.

The room: Debian trixie with Node 22, Python 3.13, build-essential, git, gh, uv, ripgrep, and — this one matters more than it looks — a real /usr/local/bin/fd symlink, not a shell alias. Aliases only exist in interactive shells; when Claude Code runs fd through sh -c, an alias is invisible and the command just fails. If you've ever wondered why an agent claims a tool "isn't installed" when you can clearly run it yourself: check whether it's an alias.

The walls: the container gets a memory limit, a CPU limit and a pids limit (an agent-written test suite fork-bombing your VPS into a reboot is funnier in retrospect than at 11 PM). The limits are set in .env, and the installer suggests values based on the host — total RAM minus about 3 GB for the OS and the rest of the stack. On an 8 GB VPS, give the workbench 5 GB; on the €5, 4 GB box from the title, the installer lands on about 1 GB — enough for the agents themselves, but keep an eye on the heavy builds. Do not lowball this one, and here's the war story: the stack originally shipped with a 512 MB limit on this container, which sounds reasonable until you learn how the OOM killer chooses its victim — it kills the biggest process in the cgroup, which after a while is Zellij itself. Result: one hungry npm install and every session, including the agent you were watching from the beach, dies. Memory limits on a workbench are not a tidiness feature. Size them like you mean it.

State is split with intent. Project files live under /srv, which both the terminal and the file manager see — upload a tarball from the browser, untar it in the shell. But the credentials~/.claude, ~/.config, npm globals, API keys — live in a separate bind mount that Filestash cannot see. Your web file manager should never be one misclick away from serving your Anthropic session token.

Updating the agents without the rebuild dance

Agent CLIs release constantly, and the naive approach — bake them into the Docker image — means every update is a rebuild, and every rebuild kills your sessions. The fix is boring and effective: everything installs into ~/.local, which is a persistent bind mount that comes first in PATH. The image ships an update-agents command:

$ update-agents
→ claude    (npm @anthropic-ai/claude-code)
→ codex     (npm @openai/codex)
→ opencode  (npm opencode-ai)
→ kimi      (official installer)
→ agy       (self-updater)
→ kiro      (self-updater)
Enter fullscreen mode Exit fullscreen mode

One command, no rebuild, no downtime, survives image rebuilds. Two details earned their place the hard way. First, npm's infamous ENOTEMPTY rename bug (stale files in the old package dir, common right after a Node major bump): the script now clears the package directory — which holds no user state — and retries once, automatically. Second, Antigravity's agy is deliberately not installed from npm: there is no official package, and what you find under the obvious names on the registry is squatted placeholders. That's not paranoia, that's the supply chain in 2026. The script only drives agy's own self-updater after you've installed it once from Google's channel.

There's also git-ssh-key, born from doing the same chore on two VPSes in one afternoon: it generates a per-forge ed25519 key in persistent storage, prints the public key with the exact settings URL to paste it into (GitHub, GitLab, or your self-hosted forge), and wires ~/.ssh/config so it keeps working after rebuilds. One command, then git clone git@github.com:... just works.

Prerequisites and windows into the lab: DNS, Resend, Filestash and Glances

Before anything works, you need two things the installer can't do for you.

A domain. The stack is browser-first, and the browser wants names, not ports. You need a domain (even a few-euros-a-year one) and a handful of A records pointing at the VPS's IP: auth, term, mterm, files, stats, home. Or a single wildcard *.lab.yourdomain.tld, which is the route I recommend — one record, zero maintenance when you add a service. Caddy handles the certificates itself, but only if the names resolve: if you run the installer before DNS has propagated, the certs fail and it all looks broken when it's really just early.

A way to send email. This is the prerequisite nobody expects: to let you register your TOTP, Authelia emails you an identity-verification link. No SMTP, no 2FA, no lab. I use Resend: the free tier is more than enough for a single-user lab, the SMTP credentials go into .env and you never think about it again. (For completeness: Authelia also has a filesystem notifier that writes the link to a file instead of sending it — it works, but it forces you into a shell on the host at exactly the moment you're setting up access without a shell on the host. Resend is faster.)

Then there are the two windows into the lab, each worth a few lines.

Glances (stats.*) is the easy one: zero configuration, open the page and watch the agent eat CPU in real time. It's the fastest way to answer "is it working, or has it hung?" without touching the terminal.

Filestash (files.*) is enormously useful but not ready out of the box: on first launch you configure it — admin password, storage backend pointed at the local /srv mount. Five minutes, once, but skip it and you land on a setup page and think something exploded. The README has the exact steps.

And here's a trick that earned its spot in this post: the Zellij token lives happily in a file under /srv. The native web client on term.* has its own authentication, separate and independent from Authelia: a token it generates on first boot and asks for every so often — typically when the session expires or you switch machines. And a token, by its nature, isn't something you know by heart. The practical answer: save it in a file like /srv/notes/zellij-token.txt. Filestash sees it, so from any browser you log in through Authelia, open the file, copy, paste, and you're back in. (The mobile route on mterm.* will never ask you for it — there, access to Zellij goes another way, which I get to in the security section.)

Yes, it's a deliberate trade-off: that token is, effectively, a second line of defense behind Authelia, and putting it in a file reachable behind that same Authelia weakens the second line. For my threat model — single user, mandatory TOTP — I happily accept it in exchange for never having to dig into the VPS to fish it out. We'll come back to this when the security model is on the table in full; if your threat model is different, just don't create the file.

The phone part, and the IME rabbit hole

The mobile terminal is where most of the interesting engineering hides, because mobile browsers actively fight you.

Honestly, I'm not satisfied with the result yet — call it 60%. I use it, but it still needs work. Either way, here's how it works:

Wetty gives you xterm.js over WebSocket, which is 80% of the job. The remaining 20% is why the stack builds a custom Caddy with the replace-response module: Caddy injects a small vanilla-JS keyboard overlay into wetty's HTML on the fly — wetty itself stays untouched, so future wetty releases just work. The overlay adds what soft keyboards don't have: arrows (hold to auto-repeat), Esc, Tab, F-keys, one-tap Zellij actions (new pane, session manager, detach), one-tap :wq, and sticky Ctrl/Alt — tap to arm for one keystroke, long-press to lock.

That sticky Ctrl taught me more about mobile browsers than I wanted to know. The first implementation intercepted beforeinput on xterm's hidden textarea, transformed the next letter into its control byte, and called preventDefault(). Worked beautifully — on desktop. On a phone it did nothing: you'd arm Ctrl, type o, and a literal o appeared. The reason is that mobile keyboards don't send you a keydown with a letter in it; they compose text through the IME, the keydown arrives as Unidentified keyCode 229, and the composition's beforeinput is frequently not cancelable — your preventDefault() is silently ignored. The fix: intercept at the document level in the capture phase, which runs before xterm's own listeners ever see the event, and use stopImmediatePropagation() — which, unlike preventDefault(), always works. Swallow the event, transform the character, inject the control byte yourself, clear the composition debris. If you're building anything that needs to intercept typed characters on mobile web: element-level preventDefault is a desktop-only illusion.

Same category of lesson, CSS edition: the overlay bar wraps onto a variable number of rows depending on the key set and the screen width, so any hardcoded "reserve 92px for the bar" guess is wrong on some phone — and the terminal's last line hides under the buttons, which on a terminal means hiding the prompt, the only line you actually care about. The bar now measures its own rendered height and publishes it as a CSS variable the terminal sizes against. And on iOS, where the soft keyboard overlays the page instead of resizing it, the bar rides up on visualViewport so Esc and the arrows stay visible exactly when you're typing.

The security model, honestly

Everything behind the gate trusts the gate. That's the design, and it's the right trade-off for a single-user workbench — and the wrong one for anything multi-tenant, so let's be precise about what holds the line:

  • Two gates before a shell on term.*: Authelia (password + TOTP) and then Zellij's own token. A remote shell on your VPS should not be one phishing away. The mobile route (mterm.*) skips the second gate by design — wetty authenticates into the Zellij container with an internal SSH key that never leaves the Docker network — so there, Authelia alone holds the line. (And if you adopt the token-in-a-file-on-Filestash trick, this becomes the posture everywhere: one gate, the real one, with TOTP. There's even an argument for it: two subdomains with two different postures are harder to reason about than one coherent system where Authelia holds the line, full stop. Know it and choose.)
  • Inside the container, the lab user has passwordless sudo — deliberately documented rather than hidden. Past both gates, an attacker owns that container. The blast radius is the workbench, not the host: no Docker socket inside, resource limits on the way out.
  • Watchtower, Glances and the Telegram bot reach Docker through a read-only socket proxy with all write endpoints denied (the bot only reads container logs, to alert on Authelia logins). A compromised metrics dashboard cannot exec into your terminal.
  • Watchtower never auto-updates. It reports; you read release notes and pull deliberately. Surprise 3 AM updates on the box your agents live on is a genre of fun I've retired from. (Corollary: locally-built images are invisible to Watchtower — bumping Zellij itself is a one-line .env change and a rebuild, on your schedule.)

What it does not defend against: a compromised password and TOTP seed, kernel-level container escapes, or an attacker who already has shell on the host. One kernel, one trust boundary. Know what you're running.

What this is not

This is a personal homelab published as-is, not a product. It's single-user by design. It doesn't back anything up — pair it with something like CryptoSync pointed at /srv and the lab state directory. It assumes you'll read a Caddyfile before trusting it, and the repo's disclaimer is not boilerplate. The installer deliberately refuses to touch your firewall: UFW and Docker's iptables interact in ways that have locked better people than me out of better servers than mine — use your provider's firewall and read the README's hardening notes.

And if what you actually want is "a dev box with zero setup", GitHub Codespaces exists and is genuinely good. This is for people who want the box to be theirs.

Why the journey is worth it

I could have stopped at "SSH works fine". I didn't, because the point was never just remote access. Somewhere between watching the OOM killer take down a session mid-refactor, tracing why a phone keyboard swallows Ctrl, and teaching an installer to tell bytes from megabytes, the stack stopped being a pile of YAML and became something I can reason about — every port, every mount, every trust boundary placed on purpose. Now, when an agent works for forty minutes on my code, I know exactly what room it's working in, what it can touch, and how to look over its shoulder from anywhere with a browser.

That's the MojaLab habit: take the long way once, so the short way feels different ever after.

The repo is at github.com/doradame/mojalab-vps-stack, MIT-licensed. Clone it on a fresh VPS, run ./scripts/install.sh, answer the questions, and fifteen minutes of DNS propagation later your agents have a home. Read the configs before you trust them — including mine.

If you build something on top of it, or break something interesting, I'd love to hear about it.

Made in MojaLab. Several coding agents were mildly inconvenienced by memory limits during the making of this stack.

Top comments (0)