Setting up a remote environment for agentic coding

For the last few months my laptop has been the bottleneck in my own workflow. Not the CPU, not the RAM – the fact that it’s a laptop.

I run long AI coding sessions now. Claude Code churns away on a feature for twenty minutes, I go make coffee, and if I close the lid the session dies with it. So for a while I did the obvious dumb thing: I kept the Mac awake with Amphetamine , lid open, plugged in, sitting on a desk I then couldn’t walk away from. The machine doing the work had to be the machine in front of me, powered on, present. That’s backwards.

The Amphetamine menu bar app on macOS, its “Start New Session” menu expanded to the Hours submenu, offering to keep the Mac awake anywhere from 1 to 24 hours

So I moved the whole thing off my laptop. There’s now a server that runs the agents, the code, the databases, and the editor GUI. I reach it from my MacBook at my desk, from the couch, or from my phone on the train. Close the laptop, the work keeps running on the server. Open my phone twenty minutes later and I’m looking at the exact same session, right where it was.

If you’ve used Claude Code’s Remote Control to pick a session up from the phone app, it’s a bit like that, except it’s on by default, running all the time, and it’s my own server with none of the limits. Every project I have, not just the one I remembered to start a remote session for.

This post walks through how it’s built and why each piece is there. It’s also written so you can hand the whole thing to an LLM and have it build the same setup for you. More on that at the end, but keep it in mind as you read: everything here is a real command I ran on a real server, not a sketch.

What you get#

A server in the cloud isn’t new, I’ve had those for twenty years. What’s new is that the machine doing the work is no longer tied to the machine I’m sitting at.

  • I can close my laptop. The agent, the editor, the database, all of it lives on the server. My laptop is a window, not the engine. Shut the lid, get on a train, nothing stops.
  • I hand off between devices instantly. Start a task on the MacBook, check on it from my phone in the kitchen, back to the desk. Same session, same state, no syncing, no “let me push this real quick so I can pull it elsewhere”.
  • It survives a dropped connection. My train goes through a tunnel, SSH drops, the work on the server doesn’t care. I reconnect and reattach.
  • No more caffeine hacks. Amphetamine, Caffeine, caffeinate, the little while true; do; done tricks, … all gone. The laptop is allowed to sleep because it isn’t doing anything important.

It all rests on three pieces: a network layer (Tailscale), a server, and a coding GUI you reach from a browser (I use T3, but that part is swappable). Then a bunch of small tweaks on top that make it pleasant instead of fiddly.

Tailscale: the network that makes it private#

The first problem with “a server I reach from anywhere” is the “from anywhere” part. You do not want an editor with a shell on it exposed to the open internet. That’s how you end up in someone’s botnet.

Tailscale solves this cleanly. It’s a mesh VPN built on WireGuard , but you never touch a WireGuard config. You install a client on each of your devices, log in, and they all end up on one private network (a tailnet) where they can talk to each other directly, encrypted, no matter which network they’re physically on. My laptop, my phone, and the server all see each other as if they were on the same LAN, whether I’m at home, on cellular, or on hotel wifi.

There are proper native clients for everything: macOS, Windows, Linux, iOS, and Android. On the phone it’s an app-store install and a login, that’s the whole setup. Once a device is on your tailnet it can reach the server by name, and nothing outside the tailnet can.

The Tailscale macOS app showing my tailnet: a MacBook, the code-ma-ttias-be server and an iPhone all connected, with the server’s MagicDNS name and Tailscale IPs

That last part is what makes the whole thing safe. The server exposes exactly one port to the public internet: SSH, and even that I keep only as a break-glass door in case Tailscale itself is ever down. Everything else, the editor, the preview servers, the databases, is bound to localhost and published only onto the tailnet. The firewall denies inbound by default and allows the tailnet interface:

$ sudo ufw status verbose
Status: active
Default: deny (incoming), allow (outgoing), disabled (routed)

To                         Action      From
--                         ------      ----
9999/tcp                   ALLOW IN    Anywhere                   # SSH (break-glass, public)
Anywhere on tailscale0     ALLOW IN    Anywhere                   # Tailnet (all services)

Tailscale also does the SSH auth for me (tailscale up --ssh), so device-to-device SSH inside the tailnet needs no key juggling, and it can hand out real, valid HTTPS certificates for your machines. That’s how the editor gets a proper https:// URL with no cert warnings, reachable from Safari on my phone, without ever being public. tailscale serve puts a localhost port onto the tailnet over HTTPS and nothing more.

If you only copy one thing from this post: put the server on a tailnet and keep it off the public internet.

The server: I started on a VPS and outgrew it#

For the first month this ran on a DigitalOcean droplet: Ubuntu 26.04 LTS, 2 vCPUs, about 8 GB of RAM, 28 GB of disk, plus a 100 GB block volume I added a few days later. That was the right way to start. It’s cheap, it’s up in sixty seconds, and it proved the idea worked before I spent real money on it.

Then I started actually using it.

Two vCPUs cope fine with one agent. They don’t cope with four. Every T3 thread is its own worktree with its own preview server, so when two of them are running a test suite while a third builds assets, two vCPUs are gone. Here’s sar on my last morning on that machine, peak one-minute load per day:

$ for f in /var/log/sysstat/sa[0-9]*; do sar -q -f "$f" | awk -v d="$(basename $f)" \
    'NR>3 && $4 ~ /^[0-9.]+$/{if($4>m)m=$4} END{if(m)printf "  %s  %.2f\n", d, m}'; done
  sa22   4.26
  sa23   3.14
  sa24   4.63
  sa25  15.04
  sa26  19.28

Load 19 on two cores means everything is queued behind everything else. And 3 GB of the 8 had been pushed out to swap, so the box was reading its own memory back off a disk.

I didn’t move because the VPS got expensive. I moved because fixing it on the same provider would have been. A CPU-Optimized droplet with 32 vCPUs and 64 GB of RAM is $672 a month. The box I ended up with is €137.

It’s from the Hetzner server auction : an i9-13900 (24 cores, 32 threads), 64 GB of DDR5 ECC, and two 1.92 TB datacenter NVMe drives, in Falkenstein. €137/month excluding VAT, no setup fee. The auction is second-hand hardware they’re clearing out, so you’re picking from whatever’s in stock that day rather than a menu, and the price ratchets down on a timer until someone buys it.

A word on picking one, because “more cores is better” is only half right. Most of what an agent makes you wait on is single-threaded: phpstan, composer, a hugo build, one pest process. There was a 32-core EPYC in the same auction for twice the money whose single-thread score is lower than the droplet I was leaving. It would have felt slower for one agent while being great for twelve. The i9 happened to be both the fastest single core and the most cores in that day’s German stock.

The same benchmark on both boxes:

Old (2 vCPU droplet)New (i9-13900)
PHP, 3M md5, one thread0.58s0.36s
PHP, 3M md5, all cores0.88s (×2)0.74s (×32)
xz -3 on 64 MB, one thread29.2s9.6s
xz -3 on 64 MB, all cores17.7s3.4s
openssl sha256456 MB/s2,587 MB/s
openssl aes-256-gcm10,654 MB/s7,717 MB/s
dd 1 GB write966 MB/s2.5 GB/s

Note the AES line, because it’s the one thing that got worse. The old Xeon has AVX-512 and this i9 doesn’t (Intel fused it off on consumer Raptor Lake), so OpenSSL drops from the AVX-512 VAES path to the 256-bit one. The trade runs the other way on SHA-256, where the i9 has SHA-NI and the Xeon didn’t, hence the 5.7x. Nothing I run is bottlenecked on AES throughput, so I’ll take it. But a faster CPU isn’t automatically faster at everything.

On top of the base OS it’s just a normal dev machine: git, tmux, PHP, Node, a database or two, … and Docker for anything that’s easier in a container.

htop on the original VPS: two CPUs, 2.8 GB of 7.75 GB RAM in use, and a process list showing ClickHouse, T3’s node process, tailscaled, and several parallel claude agents running as the dev user

(That screenshot is the old droplet, back when two cores were still coping.)

I also turn off automatic updates on this server, which feels wrong until you think about it. Unattended-upgrades can decide to reboot at 3am, and a reboot kills every running tmux session and every in-flight agent. That’s the exact thing this setup exists to prevent. So I patch by hand, when I choose to, and let the sessions live:

$ sudo apt-get purge unattended-upgrades
$ sudo systemctl mask apt-daily.timer apt-daily-upgrade.timer

Long-running work goes in tmux, one session per project. Detach with Ctrl-b d, reattach from any device with tmux attach. That’s the fallback layer that survives everything, including the fancier GUI on top of it dying. If the browser editor ever breaks, ssh in and tmux attach and I’ve lost nothing.

One btrfs pool, mirrored across both drives#

I got this wrong when I first built this server.

Cloud images hand you ext4 as the disk by default. ext4 can’t share blocks between files, so every worktree with its own node_modules costs the full copy. Ten of them took that 28 GB disk down to 687 MB free in about a day, and I ended up deduplicating the worktrees with jdupes to get the space back. Hardlinks work. They’re still a workaround for a filesystem that can’t share blocks.

On the droplet I fixed that by bolting on a 100 GB block volume formatted btrfs and moving /home/dev onto it. It worked, but it left the box with a 28 GB root that had nothing to do with the 100 GB where the actual work lived, and that root filled up and took the server down twice. Splitting a disk is a capacity-planning problem you get to have forever.

With two NVMe drives I don’t have to split anything. The whole machine is one btrfs pool now, mirrored across both drives, with subvolumes instead of partitions:

$ cat /proc/mdstat | grep -E '^md|blocks'
md2 : active raid1 nvme1n1p4[1] nvme0n1p4[0]
      1864754496 blocks super 1.2 [2/2] [UU]
md1 : active raid1 nvme1n1p3[1] nvme0n1p3[0]
      8379392 blocks super 1.2 [2/2] [UU]
md0 : active raid1 nvme1n1p2[1] nvme0n1p2[0]
      1046528 blocks super 1.2 [2/2] [UU]

$ findmnt -no SOURCE,FSTYPE,OPTIONS /
/dev/md2[/@] btrfs rw,noatime,compress=zstd:3,ssd,discard=async,space_cache=v2,subvolid=256,subvol=/@

Be warned though, if you’re doing this on Hetzner: installimage cannot build a native btrfs RAID1. Its own docs say so, in as many words: “no support btrfs multi-device volumes”. So the mirror is mdadm and btrfs sits on top of it. You still get reflinks, compression, subvolumes and snapshots, which is everything this setup actually uses. What you give up is btrfs repairing a bad block from the good copy, because mdadm can’t tell it which half is right. btrfs will still detect the corruption. It just can’t fix it for you.

A couple of things that cost me a cycle there. The EFI partition can’t live on mdadm (firmware can’t read it), so there’s a separate 1 GB ESP on each drive. And installimage mounts your subvolumes before it unpacks the image, so a mountpoint nested deeper than one level fails with ENOENT: my @var-lib-docker subvolume blew up the install at step 7 of 17 because /var/lib didn’t exist yet. @home and @tmp were fine. Create the deep ones afterwards.

Copy-on-write is the reason to bother with btrfs at all. cp --reflink clones a file by sharing its blocks instead of copying them, so duplicating a gigabyte costs nothing until something writes:

$ sudo btrfs filesystem df / | grep ^Data
Data, single: total=23.01GiB, used=18.39GiB
$ cp --reflink=always -a node_modules /home/dev/scratch/    # 520 MB, 32,985 files
$ sudo btrfs filesystem df / | grep ^Data
Data, single: total=23.01GiB, used=18.39GiB

That’s ohdear.app’s node_modules, half a gigabyte across nearly 33,000 files, cloned in 0.42 seconds, and the used figure doesn’t move at all. Ten checkouts of the same node_modules are that problem at scale, and this is worktree-up’s whole trick: if the worktree’s lockfile is byte-identical to the primary’s, it reflinks the dependency directory instead of installing. A fresh Laravel worktree with vendor and node_modules both cloned is ready in 1.5 seconds instead of a couple of minutes.

I mount it with compress=zstd:3 too, so 11 GB of home sits in 7.4 GB on disk:

$ sudo compsize /home/dev
Processed 335505 files, 139334 regular extents (140869 refs), 232539 inline.
Type       Perc     Disk Usage   Uncompressed Referenced
TOTAL       62%      7.4G          11G          11G
none       100%      5.7G         5.7G         5.7G
zstd        28%      1.7G         6.1G         6.0G

The 100% line is everything already compressed (images, archives, git packs), which btrfs correctly declines to compress twice. The zstd line is the text: source, node_modules, SQL dumps.

The jdupes cron still runs, and on btrfs it now uses jdupes -B, which dedupes matching files into shared extents rather than hardlinking them, so writing to one copy can’t reach the others.

The full disk layout, /tmp included#

There’s a second default I got wrong, and it ships with every cloud image: /tmp is a tmpfs, so it lives in RAM. Ubuntu sizes it at half your memory, 3.9 GB here. A tmpfs page can’t be reclaimed until the file is deleted, so every gigabyte of build scratch stays charged to memory until something cleans it up. I caught /tmp sitting at 1.9 GB used while Shmem was only 122 MB, which means about 1.75 GB of it had been pushed out into swap. At 81% full the box had eaten its whole memory headroom: fork started failing with ENOMEM and even echo returned nothing.

So /tmp is its own subvolume in the pool now, and the ceiling went from 3.9 GB of memory to the whole 1.8 TB. Be warned though, a tmpfs gets wiped at every boot and a real filesystem doesn’t, so you want a D /tmp 1777 root root 10d line in /etc/tmpfiles.d/ to clear it at boot and age out whatever survives. nosuid,nodev because /tmp is world-writable, and noatime so reading a file doesn’t cause a write.

The whole fstab, /boot and swap included, because there’s nothing else to it:

UUID=B358-5484     /boot/efi       vfat  umask=0077 0 1
UUID=1bbb62f6-...  /boot           ext4  defaults 0 0
UUID=c0f199f5-...  none            swap  sw 0 0
UUID=fcfad565-...  /               btrfs defaults,noatime,compress=zstd:3,subvol=@ 0 0
UUID=fcfad565-...  /home           btrfs defaults,noatime,compress=zstd:3,subvol=@home 0 0
UUID=fcfad565-...  /tmp            btrfs defaults,noatime,nosuid,nodev,compress=zstd:3,subvol=@tmp 0 0
UUID=fcfad565-...  /.snapshots     btrfs defaults,noatime,compress=zstd:3,subvol=@snapshots 0 0
UUID=fcfad565-...  /var/lib/docker btrfs defaults,noatime,compress=zstd:3,subvol=@var-lib-docker 0 0

Five of those lines are the same filesystem. That’s the point of subvolumes: no line has a size, so nothing can fill up while something else sits half empty.

The coding GUI: T3, but bring your own#

This layer is the least settled of the three, and that’s fine.

I want a proper editor I can drive from a browser, including my phone’s browser, so I’m not squinting at tmux on a 6-inch screen when I just want to review a diff and approve a change. The server runs the editor as a service: I open a URL and there’s my project, my files, my git state, my agents.

Right now I use T3 Code for this. It’s a web GUI that runs on the server (not on my Mac), speaks to Claude Code and Codex, does git worktrees, shows diffs, and opens pull requests. It has first-class Tailscale support, so t3 serve --tailscale-serve publishes it on tailnet-only HTTPS and I open that URL from anything. It’s early software, the project says so itself, and I’ve hit rough edges. But it does the job today.

My one current issue with T3 is that I can’t queue up multiple messages in the editor: a new message can only be sent once the agent has finished processing the previous one. That’s a matter of time, I imagine, before that’s fixed.

The T3 Code web GUI in a browser: a projects sidebar with ma.ttias.be, Box admin, Oh Dear and Home Assistant, an agent thread showing a git diff, and the Claude Opus model picker at the bottom

Update, August 2026: there’s a native iOS app now, T3 Code - Remote Claude & more , and it’s replaced the browser-on-my-phone half of this for me. It doesn’t replace the server, though. The app pairs with the same T3 backend running on the server, the one serving the web GUI in the screenshot above, so everything below still applies. I just get a proper app icon instead of a Safari tab.

This piece is swappable, and chances are, I’ll end up trying a few different web GUIs over the coming weeks. The whole space of “browser-based agent coding UIs” is moving fast, and a year from now the good option might be something else entirely. Nothing else in this setup depends on the specific GUI. The network is Tailscale, the server is a plain Ubuntu box, the work lives in git and tmux. If I rip out T3 tomorrow and drop in a different editor, the foundation doesn’t move. Pick whatever’s good now, and don’t marry it.

T3 itself isn’t the recommendation here, the shape is. A few others solve the same problem already: Emdash and Orca are open-source apps that run agents in parallel git worktrees, with remote-over-SSH execution so the runtime can sit on another machine. CloudCLI and Paseo go the web route like T3: a self-hosted server you reach from a browser or your phone. I haven’t run all of them in anger, so this isn’t a leaderboard.

No code editor#

I haven’t opened a real code editor in months. No VS Code, no PhpStorm, no vim beyond a quick :wq on a config file. And I don’t miss it.

That’s the biggest change in how I work. I assumed the browser GUI was me swapping my editor for a worse one I could reach from my phone. Turns out I don’t use it as an editor at all. I open T3 to read a diff, approve a change, start the next task, and watch an agent work. I almost never type code into a file by hand.

The loop now is: describe what I want, let the agent write it, read the diff, run the tests, say what’s wrong, go again. The editor used to be where I did that middle step. It’s mostly gone, and what’s left is the two ends, deciding what to build and judging whether it’s right. Those are the parts I want to be doing anyway.

So what I tuned the setup for isn’t editing, it’s that loop: how fast I get from an idea to a diff I can judge, from anywhere, without a machine I have to keep awake. A snappy editor does nothing for that. A server that runs the suite in a couple of seconds and shows me the result on my phone does.

I’m not claiming hand-coding is dead. I still read every line before it merges, and I still drop into the code when an agent is flailing. But the editor as the place I spend my day is over.

The custom sauce#

Everything above is the skeleton. These are the tweaks that make it feel like mine rather than a generic tutorial, and they’re the bits I’d miss if they were gone.

The agents update themselves every morning#

Claude Code, Codex, and T3 all ship new versions constantly. I don’t want to npm update three tools by hand every morning, and I don’t want stale versions either. A systemd timer updates all three at 05:00 my time, before I’m awake, so whatever I open is already current.

It runs on UTC (as servers should), but I pin the schedule to my actual timezone, so daylight saving never shifts it:

[Timer]
# Box runs UTC; the explicit timezone keeps this at 05:00 local year-round.
OnCalendar=*-*-* 05:00:00 Europe/Brussels
Persistent=true

If that looks like it clashes with turning off automatic OS updates earlier, the difference is what can reboot the server. An OS upgrade can, and it drags every session down with it. These don’t touch the OS: Claude and Codex just swap a binary the next run picks up, and T3 only restarts itself. That restart still drops a live T3 connection, which is exactly why it runs at 05:00 and not while I’m working.

T3 gets the careful treatment because it’s alpha software backing the UI I work in: after each update the timer health-checks it, and if the new version doesn’t come back up, it rolls back to the previous one automatically. I tested both paths on the way in, a real upgrade and a forced failure, because an auto-updater you haven’t watched roll back is just a hope.

Onboarding a workspace in one command#

Adding a project shouldn’t be a checklist I half-remember. newproject clones the repo, provisions its databases, writes its .env, and registers it in T3, which can’t add a project from its own UI on a remote server. The whole script is about a dozen lines:

#!/usr/bin/env bash
set -euo pipefail
URL="${1:?usage: newproject <git-url> [title]}"
NAME="$(basename "$URL" .git)"
DEST="$HOME/projects/$NAME"

git clone "$URL" "$DEST"
cd "$DEST"
worktree-init                                    # databases, .env, dependencies
t3 project add "$DEST" --title "${2:-$NAME}"

The real work is in worktree-init. Every project gets its own MySQL and ClickHouse database named proj_<name>, with the credentials written into .env. It reaches MySQL through a ~/.my.cnf file instead of sudo mysql, so the same script runs unattended from cron without hanging on a password prompt:

NAME="$(basename "$PWD")"
DBNAME="proj_$(printf '%s' "$NAME" | tr 'A-Z-' 'a-z_' | tr -cd 'a-z0-9_')"
DBPASS="$(sed -n 's/^DB_PASSWORD=//p' .env | head -1)"     # reuse if already set
[ -n "$DBPASS" ] || DBPASS="$(openssl rand -hex 16)"

mysql <<SQL
CREATE DATABASE IF NOT EXISTS \`$DBNAME\` CHARACTER SET utf8mb4;
CREATE USER IF NOT EXISTS '$DBNAME'@'localhost' IDENTIFIED BY '$DBPASS';
ALTER USER '$DBNAME'@'localhost' IDENTIFIED BY '$DBPASS';
GRANT ALL PRIVILEGES ON \`$DBNAME\`.* TO '$DBNAME'@'localhost';
SQL

That ALTER USER right after CREATE USER IF NOT EXISTS looks redundant, and it’s there because of a bug. IF NOT EXISTS keeps the old password when the user already exists, so the first time I deleted a project’s .env and re-ran, the freshly generated password and the database’s actual password drifted apart and the app couldn’t connect. Setting it every run keeps them in sync.

Serving is the part T3 triggers. When I start a thread, T3 makes a git worktree and runs a single setup script. Mine is worktree-up: it installs dependencies and puts the app on the tailnet. The fiddly bits are all Laravel:

# APP_URL portless: domain-scoped routes match Request::getHost(), which drops the
# port, so "host:8563" would never match. ASSET_URL WITH the port: Vite builds
# asset links from it, not the request, so portless sends every stylesheet to :443
# (that's T3 Code, not the app).
envset APP_URL   "https://$HOST"
envset ASSET_URL "https://$HOST:$PORT"

export PHP_CLI_SERVER_WORKERS=4    # php -S is single-request and self-deadlocks otherwise
nohup php artisan serve --host=127.0.0.1 --port="$PORT" >"$LOG" 2>&1 &
tailscale serve --bg --https="$PORT" "127.0.0.1:$PORT"

Every comment in there is a debugging session I only want to have once. The $PORT comes from worktree-port, which hashes the worktree name for a starting point and then linear-probes for a free one, so every worktree keeps a stable URL and two of them never fight over the same port. A naive hash % range collides about one time in five by the time you’ve got a couple of dozen worktrees, and the one that loses just fails to bind without saying anything.

Previewing worktrees#

A reader asked the practical version of this:

How do you view the output when you’re switching devices? Do you have specific domain names for all your projects so that you can use the browser?

No domains. Nothing in DNS, no reverse-proxy vhost, no per-project config anywhere. Every preview is a port on the one hostname the server already has, and that hostname comes from Tailscale: code-ma-ttias-be.tail854658.ts.net. The app binds to 127.0.0.1 on a port nobody outside can reach, and one line publishes it:

tailscale serve --bg --https="$PORT" "127.0.0.1:$PORT"

Which gets me this, one entry per running preview:

$ tailscale serve status
https://code-ma-ttias-be.tail854658.ts.net:8443 (tailnet only)
|-- / proxy http://127.0.0.1:8088

https://code-ma-ttias-be.tail854658.ts.net:8809 (tailnet only)
|-- / proxy http://127.0.0.1:8809

Same host every time, different port per worktree. worktree-port pins that port for the life of the worktree, so a branch’s preview lives at the same address today and next Tuesday.

The certificate is the part that makes this usable on a phone. Tailscale gets a real Let’s Encrypt cert for the MagicDNS name and renews it, so this is ordinary trusted HTTPS, not a self-signed thing I have to click past on iOS:

$ echo | openssl s_client -connect code-ma-ttias-be.tail854658.ts.net:8443 \
    -servername code-ma-ttias-be.tail854658.ts.net 2>/dev/null \
    | openssl x509 -noout -issuer -subject
issuer=C=US, O=Let's Encrypt, CN=YE2
subject=CN=code-ma-ttias-be.tail854658.ts.net

Switching devices is then just… opening the URL. MagicDNS resolves that name identically on my MacBook, my phone, and the iPad, so a link I open at my desk is the same string on the couch. No VPN toggle to flip, no login. And it stays tailnet-only: from the public internet that name points at a 100.x address nobody can route to.

Could I give each project a subdomain instead? Sure, and then every new project needs a DNS record, a wildcard certificate, and a vhost, all for a preview that exists for the three days the branch does. A port costs me nothing and Tailscale already owns the cert.

The catch with ports is that any dev server generating absolute URLs has to be told which one it’s on, or it points every asset at :443, where T3 lives and the app doesn’t. Laravel needs the port in ASSET_URL, as above. Hugo needs --baseURL "https://$HOST/" with the scheme, so live reload picks wss and its default appendPort tacks the :8809 back on. Two frameworks, same debugging session.

Every agent gets its own worktree#

If you run more than one agent at once, they can’t share a working directory. Two agents editing the same files on the same branch trip over each other instantly. The fix is git worktrees : each agent gets its own checkout of the repo, on its own branch, in its own directory, all backed by the same .git.

T3 does this part for me. Start a new thread and it runs git worktree add under the hood, so I can have four agents working on four features at once, each isolated on the filesystem. That much is automatic.

The reader who nudged me toward writing this had a fair point, though. In a tool like Conductor you press + and get a new isolated workspace with its environment already set up. T3 creates the worktree, but the environment inside it is on you. A fresh worktree is a bare checkout: no .env (it’s gitignored, so it doesn’t come along), no installed dependencies, no running app. That’s what worktree-up is for. It’s the glue Conductor bundles and T3 makes you supply: copy the .env in, composer install, start the preview. The catch is that it’s a one-time cost. You write worktree-up once per project (or, more realistically, let Claude build this once for you), flag it “run on worktree create,” and from then on every new worktree is a single click for me too. More setup up front, in exchange for controlling exactly what a worktree gets.

The isolation isn’t total, and the gap is easy to trip over. Each worktree gets its own files, its own branch, its own copied .env, and its own preview server on its own port. What they share is the database: worktree-init makes one MySQL and ClickHouse database per project, and every worktree of that project points at it. Two agents on two branches see the same tables, and a migration one of them runs lands for both. That’s usually what I want for a quick feature branch, but if you need real per-branch data isolation you’d key the database name on the branch in worktree-init rather than the project.

One more gap T3 leaves: there’s no “worktree removed” event, so nothing tears down the preview server when I delete a worktree. I handle that with a one-minute reaper (worktree-gc) that drops any preview whose worktree directory has vanished. Not elegant, but a merged branch doesn’t leave a dead php process and a bound port behind. That’s the whole thing, about twenty lines:

Update: the reaper frees the port, but it never deletes the worktree directory, and each one carries its own node_modules. That filled the disk a day after I published this. I wrote up how I deduplicated the worktrees with jdupes , and why copy-on-write would have been the better fix if the server weren’t on ext4.

# worktree-gc, on a one-minute systemd timer. T3 fires a script on worktree
# *create* but has no teardown event, so a merged or deleted worktree would
# otherwise leave a server and a bound port behind.
set -euo pipefail

REG="$HOME/.worktree-serve"
[ -d "$REG" ] || exit 0
shopt -s nullglob

for p in "$REG"/*.pid; do
    NAME="$(basename "$p" .pid)"
    PID="$(cat "$p" 2>/dev/null || true)"
    DIR="$(cat "$REG/$NAME.dir" 2>/dev/null || true)"

    DEAD=0
    if [ -z "$PID" ] || ! kill -0 "$PID" 2>/dev/null; then DEAD=1; fi
    if [ -n "$DIR" ] && [ ! -d "$DIR" ]; then DEAD=1; fi
    [ "$DEAD" = 1 ] || continue

    PORT="$(cat "$REG/$NAME.port" 2>/dev/null || true)"
    [ -n "$PID" ]  && kill "$PID" 2>/dev/null || true
    [ -n "$PORT" ] && tailscale serve --https="$PORT" off 2>/dev/null || true
    # (also tears down the worktree's docker compose stack, if it has one)

    rm -f "$REG/$NAME.url" "$REG/$NAME.port" "$REG/$NAME.pid" "$REG/$NAME.dir"
    echo "reaped $NAME"
done

One project that manages the server itself#

One of the “projects” in my editor is the server’s own admin repo. So from the same UI where I write code, I have a workspace whose job is administering the server: spin up a new project, install a package, tweak a config, check what’s running. The server can drive itself through the same agent interface as everything else. Think OpenClaw or Hermes, but for your own dev server.

The Box admin T3 thread, where the agent explains its own runtime on the dev box: the t3code systemd service, the tailnet-only URL it serves on, local env mode, and memory usage

Because that admin repo is tracked in git, every change to how the server works, the scripts above included, is reviewable in a diff and deployed by copying files out of the repo instead of hand-editing live files and hoping I remember what I changed. The server’s config is code.

The details#

A pile of little decisions to save you some time:

  • Anything that listens binds to 127.0.0.1 and is published to the tailnet with tailscale serve. Nothing gets a public port. Be warned though: Docker’s iptables rules bypass the firewall entirely, so container ports need an explicit 127.0.0.1: prefix or they end up exposed.
  • Databases are per-project and localhost-only, so I can throw a real MySQL and ClickHouse at any project without ever exposing them.

A whole server, including cron jobs#

The Remote Control comparison from the top only goes so far. This is a full Linux server that never sleeps, and that buys a couple of things a laptop-bound setup can’t.

It runs work on a schedule. The server is awake at 6am whether I am or not, so I let it earn its keep while I sleep. Some of that is ordinary cron: the nightly database dumps, the 05:00 tool updates from earlier. Better still, put an agent on a timer. Claude Code runs headless with claude -p, so a cron line or a systemd timer can hand it a chore:

# 6am cron, while I'm asleep: bump deps and open a PR if anything moved
0 6 * * *  cd ~/projects/app && claude -p "bump composer + npm deps, run composer audit, open a PR if changed"

# Monday 7am: draft the week's changelog from the merged PRs
0 7 * * 1  cd ~/projects/app && claude -p "summarise last week's merged PRs into a changelog entry, open a PR"

Dependency bumps, a composer audit, a summary of last week’s changes for the changelog, pre-provisioning a workspace so it’s warm before I sit down, a weekly sweep for dead code. None of it needs me at the keyboard, and none of it needs my laptop powered on. I wake up to a branch, read it over coffee, and merge or bin it.

It also runs my CI so my laptop doesn’t have to. Before I push and burn real CI minutes, I run the suite on the server. It can peg all its cores at 100% for a couple of minutes building assets and running tests, while my laptop stays cool and free for something else. The agent already lives next to the code, so it runs the suite and reads its own failures without shipping anything back and forth.

One note on all this scheduled work: the more you lean on it, the more a silently-broken job costs you, and a cron line that stops firing doesn’t announce itself. If you’re starting to rely heavily on this kind of automation on your dev server, consider monitoring those cronjobs with Oh Dear too (my own product, so I’m biased, but this is exactly what it’s built for).

A workspace for Home Assistant#

Not every workspace on the server is code. One of them is my house.

I run Home Assistant at home, and it has an MCP server, the protocol agents use to talk to outside tools. So I gave the server a Home Assistant project whose MCP connection points at my house. Now, from the same T3 UI where I review a pull request, I can ask what the temperature is in the office, flip a switch, or check whether I left the garage open. From my phone, from anywhere.

The server can’t reach my home network, of course. It’s a server in a data centre, not a device on my LAN. The connection goes out through Nabu Casa , Home Assistant’s cloud, which gives my instance a stable external URL. The MCP server on it holds a long-lived token and talks to that URL. From the agent’s side it’s just another tool: it can see all 2,196 entities my Home Assistant exposes, and act on them.

An always-on server that manages my home from a chat interface, reachable from my phone – that’s most of what those self-hosted assistants are for. I didn’t set out to replace one, but between the dev workspaces, the scheduled agents, and now the house, this server has become the always-on assistant I’d otherwise have installed OpenClaw to get.

Monitoring your dev server#

This server went from a toy to something I lean on. My code lives here, my databases, the link to my house, the agents that run overnight. If it falls over, or starts leaking something I didn’t mean to expose, I want to know before I trip over it myself.

I monitor it with Oh Dear , which I co-founded, so take this as the biased recommendation it is. Uptime is the obvious half: is the server answering. The more interesting half for a server like this is port scanning .

The security model here is “one public port, everything else on the tailnet.” That holds only as long as my firewall rules stay correct, and firewall rules rot. Docker punches straight through ufw. A docker run -p 8080:8080 I forgot to pin to 127.0.0.1. A package that helpfully opens a port when I install it. From the server itself it all looks fine, while from the outside there’s now a port reachable that I never meant to expose.

Port scanning checks the server the way an attacker would, from the outside. Oh Dear scans all 65,535 TCP ports on the public IP once a day by default, more often if you want, and you tell it which ones are meant to be open. On the first scan it shows you what it found and asks you to set a baseline. On mine that’s one port, 9999, the SSH break-glass door, and nothing else:

Oh Dear’s port scanner baseline screen: one open port found, 9999 running OpenSSH, ticked as expected, with a Set Baseline button

From then on, every scan is checked against that baseline. As long as 9999 is the only thing answering, it stays green. The moment an unexpected port responds, I get a notification.

Oh Dear’s port scan result reading All ports are as expected, with port 9999 the only open port, matching the saved baseline

So the day I fat-finger a Docker flag and expose a database, I hear about it instead of reading about it in an incident report later. It’s the backstop for the exact mistake a tailnet-only server is one docker run away from: “ah, I thought that was firewalled.”

Hand this post to your own agent#

I said at the top this doubles as a setup guide, and I mean it literally. This post is written to be a prompt.

If you have Claude Code, Codex, or a similar coding agent, you can hand it this entire article and say “build me this on a fresh Ubuntu VPS, here’s the IP”. The agent has enough here to provision the server, install Tailscale, lock down the firewall, set up the browser GUI, and wire in the auto-updates. That’s how I’d bootstrap a second one now: give an agent the write-up and a root login, and check its work as it goes. Verify each step rather than trusting it blindly, the same way I’d review a colleague’s pull request, but the bulk of the typing is done for you.

I set out to stop keeping my laptop awake, and I ended up with an environment I can rebuild just by describing it.

If you build your own version, or you’ve got a sharper take on the GUI layer, let me know – I’d love to see what you come up with. New GUIs for this pop up weekly, chances are there’s a better one out by the time you read this.