# Setting up a remote environment for agentic coding on a VPS

> Source: <https://ma.ttias.be/remote-coding-environment-vps/>
> Published: 2026-07-23 00:00:00+00:00

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](https://apps.apple.com/us/app/amphetamine/id937984704)
, 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.

So I moved the whole thing off my laptop. There’s now a small VPS 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](https://code.claude.com/docs/en/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 box 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 box, not a sketch.

## What you get[#](#what-you-get)

The point isn’t “a server in the cloud”. I’ve had those for twenty years. The point 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 box (a VPS), 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[#](#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](https://tailscale.com)
solves this cleanly. It’s a mesh VPN built on [WireGuard](https://www.wireguard.com/)
, 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 VPS 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 VPS by name, and nothing outside the tailnet can.

That last part is what makes the whole thing safe. The VPS 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:

``` bash
$ 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 take one idea from this post, take this one: put the box on a tailnet and keep it off the public internet. The rest is detail.

## The server: a standard Ubuntu 26.04 LTS VPS[#](#the-server-a-standard-ubuntu-2604-lts-vps)

The server itself is deliberately unremarkable. It’s a DigitalOcean droplet running Ubuntu 26.04 LTS, 2 vCPUs, about 8 GB of RAM, 28 GB of disk. Any provider works. Hetzner, Vultr, whoever. You want enough RAM to run a few agents plus a database or two at once, so I’d aim for 8 GB rather than 4, and add a bit of swap.

On top of the base OS it’s just a normal dev machine: git, tmux, the language runtimes I use (PHP, Node, a database), and Docker for anything that’s easier in a container.

I also turn *off* automatic updates on this box, 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:

``` bash
$ 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.

## The coding GUI: T3, but bring your own[#](#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](https://github.com/pingdotgg/t3code)
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.

**This piece is swappable, and I’d expect to swap it.** 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. That’s fine. Nothing else in this setup depends on the specific GUI. The network is Tailscale, the box is a plain Linux server, 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.

So don’t take T3 as the recommendation here, take the *shape* as the recommendation. A few others solve the same problem already: [Emdash](https://emdash.ai/)
and [Orca](https://github.com/stablyai/orca)
are open-source apps that run agents in parallel git worktrees, with remote-over-SSH execution so the runtime can sit on another box. [CloudCLI](https://github.com/siteboon/claudecodeui)
and [Paseo](https://paseo.sh/)
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. The point is that the layer is a commodity now, and swapping it doesn’t touch anything underneath. The rest of the post is the durable part.

## No code editor[#](#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, and I didn’t plan it. I assumed the browser GUI was me swapping my editor for a worse one I could reach from my phone. It 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[#](#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[#](#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 box. 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[#](#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 box. The whole script is about a dozen lines:

``` bash
#!/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"
wt-init                                    # databases, .env, dependencies
t3 project add "$DEST" --title "${2:-$NAME}"
```

The real work is in `wt-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 `wt-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 `wt-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.

### Every agent gets its own worktree[#](#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](https://git-scm.com/docs/git-worktree)
: 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 `wt-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 `wt-up`

once per project, 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.

What’s isolated and what isn’t is worth being precise about. 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: `wt-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 `wt-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 (`wt-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.

### One project that manages the box itself[#](#one-project-that-manages-the-box-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 box: 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 dev box.

Because that admin repo is tracked in git, every change to how the box 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 box’s config is code.

### The details[#](#the-details)

A pile of little decisions to save you some time:

**Anything that listens binds to** and is published to the tailnet with`127.0.0.1`

`tailscale serve`

. Nothing gets a public port. Docker is the sneaky exception here, because its iptables rules bypass the firewall, 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.

None of these are clever on their own. Together they’re the difference between a setup I trust and one I babysit.

## A whole server, not just a session[#](#a-whole-server-not-just-a-session)

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

It runs work on a schedule. The box 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"
```

Dependency bumps, a `composer audit`

, 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 box. 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. My laptop shows me the result; it doesn’t generate it.

## A workspace for Home Assistant[#](#a-workspace-for-home-assistant)

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

I run [Home Assistant](https://www.home-assistant.io/)
at home, and it has an [MCP](https://modelcontextprotocol.io/)
server, the protocol agents use to talk to outside tools. So I gave the box 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 box can’t reach my home network, of course. It’s a VPS in a data centre, not a device on my LAN. The connection goes out through [Nabu Casa](https://www.nabucasa.com/)
, Home Assistant’s cloud, which gives my instance a stable external URL. The MCP server on the box 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.

This is where the OpenClaw comparison stops being a comparison. An always-on box that manages my home from a chat interface, reachable from my phone, is 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 box has become the always-on assistant I’d otherwise have installed OpenClaw to get.

## Monitoring your dev box[#](#monitoring-your-dev-box)

This box 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](https://ohdear.app)
, which I co-founded, so take this as the biased recommendation it is. Uptime is the obvious half: is the box answering. The half that matters more for a box like this is [port scanning](https://ohdear.app/features/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 inside the box it all looks fine, while from the outside there’s now a port reachable that I never meant to expose.

Port scanning checks the box 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 my box that’s one port, 9999, the SSH break-glass door, and nothing else:

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.

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 box is one `docker run`

away from: “ah, I thought that was firewalled.”

That’s a weak spot of hiding everything behind a private network. It’s great until something sidesteps it, and the only reliable way to catch that is to look from the outside. So I keep something looking.

## Hand this post to your own agent[#](#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 box, 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](https://twitter.com/mattiasgeniar)
. That part of the stack is still wide open, and I’m curious what everyone else lands on. In this day of AI development, it almost feels like new alternatives pop up weekly 🤯
