Originally published at hafiz.dev
I wanted to work on my projects from wherever I happen to be. At the desk on the Mac, on the train from my phone, on a borrowed laptop if it came to that. Not just check on things, but write code, review what an AI agent did, and deploy it. A laptop-only setup can't do that, and it has a second problem that's easy to ignore until it bites. The laptop is a single point of failure. Earlier this month a quick check showed finished work on a few projects that existed on exactly one machine, with no copy anywhere else.
So I built the alternative. One cheap VPS is the workstation, and every device I own is just a window onto it. I'd written about coding PHP from a phone back in May, but that was one project on one box. This is the version that holds up for a dozen projects, with an AI agent doing most of the typing.
Everything below was built between 25 and 28 August 2026, in three days of evenings. It's a blueprint, not a tour. Follow it and you end up with the same setup. The part nobody publishes, the table of everything that broke along the way, is near the end, and it's the section I'd bookmark.
The idea: one box, many windows
One cheap VPS is the workstation. The MacBook and the iPhone are just windows onto it.
tmux keeps every session alive on the server, one session per project. Claude Code runs inside those sessions and does the actual work. Every project gets a private staging URL so I can look at what it did from any browser. Close the laptop, lock the phone, lose the train's Wi-Fi. Nothing happens to the work, because nothing was running on the device.
The setups you see on Twitter (levelsio's is the famous one) mostly stop at "tmux on a server, Termius on the phone". That part takes an hour. What took three days was making it hold up for many projects, with private HTTPS staging, an agent with shell access, and a way to rebuild the whole thing from a repo. That's what this post is about.
The stack, and why each piece
| Piece | Choice | Why this one |
|---|---|---|
| Box | netcup VPS Lite 1 G12s, €5/mo incl. VAT | 2 vCore, 4 GB RAM, 80 GB SSD. 4 GB is the binding constraint, not disk. 2 GB does not fit Claude Code plus a build plus staging sites |
| OS | Debian 13 | The provider's default. Ships PHP 8.4, so PHP 8.3 comes from Sury's repo |
| Network in | Tailscale only | Port 22 is firewalled to the tailnet's 100.64.0.0/10 range. There is no public SSH port at all |
| Sessions | tmux, one session per project | Each keeps its own windows, scrollback and running Claude Code. Switching projects disturbs nothing |
| Web | Caddy on loopback + php-fpm | Caddy listens on 127.0.0.1:80 only. One config file per project, generated by a script |
| Staging | Cloudflare Tunnel + Cloudflare Access | Outbound tunnel, so no inbound port. Access puts an email one-time PIN in front of every staging URL |
| Agent | Claude Code on the box | The sessions show up in the Claude desktop and iOS apps too, which solves screenshots (more below) |
| Phone | Termius | One host entry per project, each with a startup snippet that lands in the right tmux session |
Two of those deserve a sentence more.
Why Tailscale instead of a hardened public port. I did the public-port version on another box and wrote it up in How I Hardened My VPS in One Afternoon. This time I skipped straight to closing the port. In the few hours between provisioning and the firewall rule going in, sshd logged 1,904 failed login attempts on a box that didn't exist the day before. In the 18 hours after the rule: zero. No fail2ban, because there's nothing for it to react to. The way back in if Tailscale ever breaks is the provider's web console, which doesn't depend on the network path.
Why a tunnel and not just Tailscale for staging. Security is roughly a third of the reason. The rest is that some of my projects have OAuth callbacks, Stripe webhooks and payment flows, and none of those will talk to http://. The tunnel gives real certificates on real hostnames with no open port and no Let's Encrypt dance. It also works from a phone browser without the Tailscale app, and a staging link can be sent to a client.
The blueprint
The rule that governs the whole build is simple. Anything typed on the box goes into a git repo first, then onto the box. Four provisioning scripts, a handful of commands in bin/, the systemd units, the panel source. The box must always be rebuildable from that repo, because a VPS at this price is not something to get attached to.
Provisioning: four scripts and four manual steps
Run in order, each idempotent, so re-running is safe.
./scripts/00-ssh-bootstrap.sh <ip> devbox # local: dedicated key, pin host key, harden sshd
ssh devbox 'bash -s' < scripts/01-base.sh # packages, 2G swap, ufw, unattended upgrades
# MANUAL.md steps 1 and 2: Tailscale join, Cloudflare tunnel login
ssh devbox 'bash -s' < scripts/02-stack.sh # PHP 8.3, Node 22, Composer, Playwright path
ssh devbox 'bash -s' < scripts/03-caddy.sh # Caddy bound to loopback
ssh devbox 'bash -s' < scripts/04-tunnel.sh # cloudflared as a service
The SSH bootstrap generates a key just for this box, pins the host key before the first real connection, verifies key auth works, and only then disables password login:
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
MaxAuthTries 3
The base script is where the firewall rule lives. Tailscale and cloudflared are both outbound, so this is the entire inbound policy:
ufw default deny incoming
ufw default allow outgoing
ufw allow from 100.64.0.0/10 to any port 22 proto tcp comment "SSH via Tailscale"
ufw --force enable
It also adds git config --global --add safe.directory "*", and you'll see why in the gotchas table.
The stack script sets php-fpm to pm = ondemand with a 30 second idle timeout. That single line is what makes "eight staging sites up at once" cheap. An idle pool spawns no workers, so a registered-but-unused project costs nothing until someone requests a page.
Four things genuinely cannot be scripted, and the repo says so instead of pretending. Tailscale needs a browser login on the same tailnet as your other devices. The Cloudflare tunnel needs cloudflared tunnel login. Claude Code needs its account authentication. And .env files are copied per project, by hand, never from git.
The three commands you'll actually use
Everything day to day goes through three small bash scripts installed to /usr/local/bin.
p # list projects, staging URLs, which sessions are running
p prompt-optimizer # jump into that project's tmux session, already cd'd
dev up prompt-optimizer # serve at prompt-optimizer-staging.hafiz.dev, create DNS if new
dev down prompt-optimizer # stop serving, free its php-fpm workers
dev list # what is up, plus memory and worker count
dev logs prompt-optimizer # follow that project's requests
project-setup <name> <git-url> # clone and bootstrap a new project
p is the one that changes how the box feels. It creates the session if it doesn't exist, detached, then either attaches to it or, if you're already inside tmux, switches your client over. No detaching, no cd, no remembering session names. The one wrinkle worth knowing: tmux switch-client needs the real client, so the script reads #{client_tty} and passes it with -c. Without that, running it from a Termius snippet silently does nothing.
dev up writes one Caddy vhost per project. This is the whole file:
http://prompt-optimizer-staging.hafiz.dev {
root * /var/www/prompt-optimizer/public
encode gzip
php_fastcgi unix//run/php/php8.3-fpm.sock {
env HTTPS on
}
file_server
}
Note env HTTPS on. TLS terminates at Cloudflare and the tunnel hands Caddy plain HTTP, so without that line Laravel thinks the request is insecure and generates http:// asset URLs. Every XHR on the page then fails with mixed-content errors. That one cost me an evening.
dev up also creates the DNS record on first run, through cloudflared tunnel route dns, and then leaves it alone. dev down removes the vhost but keeps the DNS, because Cloudflare rate-limits record churn and there's no reason to delete it.
Staging behind Cloudflare Access
Here's the full path of a request to a staging site. The point of the diagram is what's missing: there is no arrow into the VPS from the internet.
cloudflared makes an outbound connection to Cloudflare and keeps it open. Requests for *-staging.hafiz.dev arrive through that connection and get handed to Caddy on the loopback interface. Caddy talks to php-fpm over a Unix socket. Nothing on the box has a port open to the world.
In front of all of it sits one Access application in Zero Trust: subdomain *-staging, domain hafiz.dev, policy "Allow", selector "Emails", one address. Every staging URL then asks for a one-time code by email before serving a byte. I verified it from outside rather than assuming: an unauthenticated request returns a 302 to the sign-in page with zero application content in the body, while the same site serves normally when curled on the box. The free Zero Trust plan is more than enough for one person.
The hostname shape is not a style choice. I wanted prompt-optimizer.dev.hafiz.dev. It fails the TLS handshake with sslv3 alert handshake failure, even with DNS and the tunnel healthy, because Cloudflare's free Universal SSL covers one subdomain level only. Two levels deep needs Advanced Certificate Manager, a paid add-on. And *-dev.hafiz.dev looks like a wildcard but isn't one. DNS wildcards only work as a leading *., so Cloudflare treats that asterisk literally and it never matches anything. So it's <project>-staging.hafiz.dev, one CNAME per project, created by dev up. With fifteen projects that's fine.
Migrating a project onto the box
project-setup <name> <git-url> does the boring part and refuses to do the dangerous part:
- Clone into
/var/www/<name>, or fast-forward pull if it's already there - Create the
storage/framework/*skeleton, because not every repo tracks it -
composer install, with--no-scriptsif there's no.envyet (more on this below) -
npm installandnpm run build - If
.envexists:key:generateif needed,migrate --force,storage:link -
chown -R www-data:www-data, group-writable, setgid on directories
Then the manual steps, deliberately manual:
- Copy the
.env, and decide per token. A token that can write to production only goes on the box with a reason. Most of them stay blank, so if the box is ever compromised the blast radius stays small. - Copy the SQLite database.
-
rsyncstorage/app/publicfrom the production box, not from the laptop. The laptop copy is missing every image production generated since you last pulled. On one project the local copy was 32 MB and production was 71 MB. -
chmod g+rwX storage/appafter that rsync, because-apreserves production's restrictive directory permissions and the Caddy user can't traverse into a700directory. -
dev up <name>.
A project is verified when three things are true: curl on the box returns 200, the public staging URL returns 302 to Access, and a page loads content from the real database.
Eight projects went through this in three days. The ones I parked (four of them, no active work) are a fifteen-minute job each when one wakes up, which is the point of writing the procedure down.
Working from the phone
This is the part I built all of it for. The pattern comes from levelsio: instead of one SSH host and a tmux menu, Termius gets one host entry per project. Same address, same key, but the label is the project name and the startup snippet is p <name>. A fresh SSH connection isn't inside tmux yet, so p attaches directly and every project becomes a tap-to-open tab.
The plain devbox host stays for box-wide work and attaches a shared work session. Only projects that have earned it get their own entry.
Four snippets cover everything else:
| Snippet | Script |
|---|---|
attach work |
`[ -n "$TMUX" ] \ |
{% raw %}claude
|
claude --continue |
detach |
tmux detach |
dev list |
dev list |
And the tmux config on the box is short. mouse on so finger-scrolling works, focus-events on, a 20,000 line history, and the session picker bound to s.
Switching projects while Claude Code is running. p <name> only works at a shell prompt. With Claude Code in the foreground it owns the input line, so typing p hafiz-dev just sends "p hafiz-dev" to Claude as a message. Only two things reach tmux past a running application: the prefix key, which tmux intercepts at the terminal layer, and mouse events. So the switch is Ctrl+B then S, which opens the session picker. On the phone, ctrl is a key in the Termius toolbar. Tap it, press b, press s. Verified working through Claude Code, over 4G.
I tried binding a status-bar tap to the picker so switching would be one touch. Termius on iOS treats the touch as a text-selection gesture and shows its own Copy/Paste menu instead of forwarding a mouse event. Binding removed. The prefix works.
Screenshots into Claude. Raw paste into Claude Code over SSH does nothing, because the image is on the phone's clipboard and the CLI is on the server. The fix is to not use the terminal for that. Sessions running on the box show up in the Claude iOS and desktop apps, grouped by project. Open the session there, attach the image natively, and it travels through Claude's own infrastructure. For the rare case where the file needs to physically exist on the box (a fixture, an asset), the iPhone share sheet to Tailscale drops it into an inbox directory via Taildrop.
Three equivalent ways to leave. Close Termius. Lock the phone. Or Ctrl+B D if you want to be tidy. tmux notices the connection drop, detaches, and everything keeps running. Termius shows a persistent "One connection" notification while it holds the SSH session open for fast reconnects. Harmless. The only thing that loses work is typing exit inside a session, because that's the one action that destroys it.
The proof this works came on day two: a footer change written on the phone over 4G, reviewed, corrected, committed and deployed to a live site. Since then the two-machine problem has mostly dissolved, because there is one checkout. From the Mac, dp prompt-optimizer attaches the same session the phone uses. Nothing to reconcile.
The control panel
By day three there were eight staging sites and a growing number of Claude Code instances, and "what's running right now" needed an answer that didn't involve SSH. So the box serves one more Access-protected page, on the same staging pattern as everything else. It shows memory, disk, load, each project with its staging state, every tmux session with a claude/idle badge, every Claude process with its working directory and RSS, and which repos are out of step with their remotes. Then buttons: start and stop per staging site, and "start CC" on any idle session.
Putting buttons on a web page that runs shell commands on a box with production deploy keys is exactly the kind of thing that goes wrong. The design has three paths, and each one needs less privilege than the one before it.
Status is read-only. A root systemd timer runs a Python script every 30 seconds that collects everything and writes status.json with a 0644 mode. The page fetches that file every 15 seconds and renders it. The web layer executes nothing for status. If the file goes stale, the page says so.
Actions go through the narrowest sudo I could write. The start/stop buttons POST to a small PHP file. It refuses anything without a custom X-Panel header (browsers won't send custom headers cross-origin without a CORS preflight, which is never allowed), validates the project name against ^[a-z0-9][a-z0-9-]{0,31}$, checks the directory exists under /var/www, refuses the name panel so it can't saw off its own branch, and only then runs sudo dev up|down <name>. The sudoers rule grants www-data exactly that: one binary, two verbs, a name matching a character class, plus the status refresh. Nothing else. php-fpm runs under systemd's ProtectSystem=full, with a drop-in that makes exactly one directory writable, the one Caddy vhosts live in.
Starting Claude Code needs no sudo at all. This is the path I'm happiest with. The button writes a project name into /var/spool/panel/cc-start and returns. A systemd path unit, running as root, watches for that file. When it appears, a consumer script re-validates the name from scratch, checks the tmux session exists, checks that the foreground process in that session is a bare shell (never a running Claude, never an editor), and types claude --continue || claude into it. The web layer left a note. Root decided what to do with it.
Two things the consumer had to learn. Inside a systemd unit, tmux's -t "=name" target form fails with "can't find pane" while plain -t name and list-panes -a work, so it uses those. And claude --continue on a large old conversation shows a resume picker that self-cancels when no client is attached, exiting 0, so the || claude never fires. The script waits six seconds, checks whether Claude is actually running, and starts a fresh conversation if not. The old one stays resumable from a real terminal.
Stopping Claude Code is deliberately not a button. That stays a human act, from a terminal or the Claude app.
If you run an agent with shell access anywhere near production keys, this earlier post on keeping it from destroying your app covers the project-level guardrails. The panel is the box-level version of the same instinct: every new action gets a header check, strict validation, and the least privilege that can possibly do the job, and the spool-file pattern beats a new sudoers line every time.
Everything that broke
This is the table I wish someone had published before I started. Every row cost real time.
| Symptom | Cause | Fix |
|---|---|---|
| 500 on every page of a fresh project | Checkout was root-owned and php-fpm runs as www-data. Laravel writes to vendor/ during package discovery, not just storage/
|
project-setup chowns the whole tree to www-data, group-writable, setgid dirs |
dubious ownership on every git command |
Checkouts owned by www-data, git runs as root |
git config --global --add safe.directory "*" in the base script |
deploy.sh can't reach production |
The box's SSH key wasn't on the production servers. This is per box, and I have three | Add the devbox public key to each production box's authorized_keys
|
Staging URL fails TLS with sslv3 alert handshake failure
|
Free Universal SSL covers one subdomain level. foo.dev.hafiz.dev is two deep |
Hostnames are <project>-staging.hafiz.dev
|
*-dev.hafiz.dev wildcard matches nothing |
DNS wildcards only work as a leading *.. The asterisk mid-label is literal |
One CNAME per project, created by dev up
|
Ctrl+B D seems dead on the phone |
Mistimed keystrokes, not interception. The prefix does reach tmux | Slow down |
project-setup dies in composer on a fresh clone |
package:discover boots the app, and a service provider that needs a secret throws with no .env (a Stripe service in one project) |
composer install --no-scripts until .env exists, then re-run |
| "Please provide a valid cache path" on a fresh clone | The repo didn't track storage/framework/*, so the view compiler had nowhere to write |
project-setup creates the storage skeleton before composer runs |
| New staging URL dead in the browser for about 30 minutes | The resolver in the path (the carrier DNS behind an iPhone hotspot) cached NXDOMAIN from a lookup made before dev up created the record. Negative TTL was 1800 seconds, and flushing the Mac can't clear an upstream cache |
Set the interface DNS to 1.1.1.1, or wait it out |
| Mixed-content errors on every staging XHR | TLS ends at Cloudflare, the tunnel delivers plain HTTP, so Laravel generated http:// URLs |
env HTTPS on in every Caddy vhost |
site.webmanifest CORS errors on staging |
Browsers fetch manifests without cookies, so the request can't carry the Access session and gets redirected to the login page | Cosmetic. Inherent to Access-protected staging, ignore it |
| Uploaded images 403 on staging | Two causes stacked: storage:link never ran, and storage/app/public is data git doesn't carry |
project-setup runs storage:link. Rsync the directory from production, not the laptop |
| Images still 403 with correct file permissions |
storage/app itself was 700, so the caddy user (group www-data) couldn't traverse into it. rsync -a preserves production's restrictive directory modes |
chmod g+rwX storage/app after any rsync from production |
| Playwright can't launch Chromium in one project | Browser builds are version-pinned. The project's playwright-core wanted build 1208, the box had 1234 |
node node_modules/playwright-core/cli.js install chromium from that project |
tmux send-keys -t "=name" fails inside a systemd unit |
"can't find pane" for the = exact-match form when no client is attached |
Use plain -t name, and list-panes -a with a filter |
claude --continue from the panel does nothing |
On a large conversation it shows a resume picker that self-cancels with exit 0 when no client is attached | Re-check after six seconds and start fresh if Claude isn't running |
The Termius status-bar tap that never worked belongs in the same spirit but didn't cost enough to earn a row.
What it costs and what it feels like
€5 a month, billed six months at a time, so €30 up front. That's the whole bill. For comparison, my main production server is a small box running several sites, and I'd never run a build or an agent there, which is the same reasoning behind separating environments that applies to any small setup.
Memory is the number that matters on a 4 GB box, so here's what it actually uses. Baseline with eight staging sites up and nothing else running: about 840 MB. That includes the OS, Caddy, cloudflared, Tailscale, php-fpm pools that spawn no workers while idle, and the panel's timers. Each Claude Code instance adds roughly 450 MB, and that is the only thing that scales with how much you're doing. Three projects with Claude open is comfortable. Six would not be. The 2 GB swap file exists for composer install and npm run build, each of which can spike past 500 MB, and OOM mid-task from a phone is the failure I most wanted to avoid.
Disk was never the constraint. The active projects total under 6 GB, and most of that is vendor/ and node_modules/ that rebuild from lockfiles.
Latency from Turin to Vienna, where the box landed, is around 20 ms. Typing over SSH feels local. My Helsinki box, at about 40 ms, feels like typing through syrup by comparison, and that difference is a good part of why this got a new box rather than sharing an existing one.
The rebuild story is the one I care about most. Everything that defines the box is in one repo, and the box's own checkout of that repo is where the panel gets deployed from. If netcup vanished tomorrow, the recovery is order a box anywhere, run four scripts, do the four manual steps, run project-setup per project, copy secrets. About an hour, plus rsync time for databases. I moved a live SaaS between servers with two minutes of downtime earlier this month using the same "write it down as you go" habit, and it's the habit, not the scripts, that makes a box disposable.
What it feels like is harder to put in a table. The honest version is that reviewing works on a phone and debugging doesn't. Reading a diff, approving a plan, running a deploy, fixing a typo: all fine from a train. Stepping through a failing test on a phone keyboard is miserable, and that's exactly the moment you'll want a real screen. So the Mac still does the heavy work. It just does it as another window onto the same session, which means closing the lid mid-task is free, and the audit script hasn't found stranded commits since.
FAQ
Why not just use Tailscale for the staging sites too?
Because some of my projects have OAuth callbacks and Stripe webhooks, and those refuse to talk to http:// or to a private address. The tunnel gives valid certificates on real hostnames with no open port, works from any browser without a Tailscale client, and lets me send a staging link to someone else. Tailscale carries SSH, the tunnel carries HTTPS. They're complements, and for a project with no third-party callbacks Tailscale alone would do.
Does Claude Code keep running when the phone disconnects?
Yes, and that's the whole point of tmux. Claude Code runs inside a tmux session on the server. When the SSH connection drops, tmux detaches the client and the session keeps running. Reconnect from any device and it's still there, mid-task. The only way to lose work is to type exit inside the session.
How do you switch projects while Claude Code is in the foreground?
Ctrl+B then S opens tmux's session picker. The prefix key reaches tmux before any application sees it, so it works even while Claude Code owns the input line. Typing a command into the terminal at that point doesn't work, because the characters go to Claude as a message. On the phone, ctrl is a key in the Termius toolbar.
Isn't putting production deploy keys on a box with a web-controlled agent dangerous?
It's a trade, and it's made deliberately. The box can deploy to production because that's what makes it a workstation. What limits the damage is that .env tokens are copied per project with a reason for each, most of the ones that can write to live sites stay blank, and the panel's actions are validated three times over with the narrowest sudo rule that works. The start-Claude path needs no sudo at all. The remaining risk is an agent with shell access, which is the same risk on the laptop.
What happens if Tailscale breaks and there's no public SSH port?
The provider's web console. It's a VNC session into the box that doesn't depend on the network path at all, which is why I didn't keep a public port open as a fallback. Closing the port is strictly stronger than banning attackers who reach it.
The mental model in one line
Conversations live on the server. You carry glass.
Every design decision above follows from that. Sessions persist because they never ran on the device. Staging is private because the only way in is a tunnel that dials out. The panel can be trusted because the web layer only ever reads a file or leaves a note. And the box is disposable because everything that made it is in a repo, next to a table of what went wrong the first time.


Top comments (0)