nateparker.dev

← Writing

Deploying to the always-on box with one word

I already had a registry-based deploy: CI cross-built the image under emulation, and the Mac mini pulled latest. I replaced it with a push, a pinned commit, a native build on the mini, a health check, and a Caddy entry it writes itself.

6 min readhomelabdockerinfrastructure
A sealed parcel with a violet wax seal sliding down a long curved chute into the open hatch of a small brick workshop with a lit window.

Part seven of a series on wiring my machines together. Previously: every dev server gets a name.

Rule two of this whole project was that the desktop is where I write code, the Mac mini is where things run, and nothing gets deployed by hand. When I wrote down the goals, I listed three ways to get there and didn’t pick one:

  1. Pull. The mini watches a repo and redeploys when it changes.
  2. Push. A script on the desktop tells the mini to update, now.
  3. Registry. Something builds an image, pushes it to a registry, and the mini pulls it.

The thing is, one app was already on option three. Punch, the time tracker I wrote to replace Clockify, had a GitHub Actions workflow that built a Docker image whenever I pushed a version tag, published it to a private GitHub package, and the mini’s compose file ran ghcr.io/0x4rch/punch:latest. It’s the textbook setup. Living with it is how I found out what was wrong with it for one person and one box.

What was wrong with the registry

The architecture, again. The mini is an M4. GitHub’s runners are x86. So the release workflow built for both platforms, and the arm64 half ran under QEMU emulation: slow, and the kind of build where a native dependency can compile fine and misbehave at runtime. The only architecture that mattered was the one the build was emulating.

latest is a promise nobody keeps. The mini ran whatever :latest pointed to when it last pulled. Which commit was that? You find out by asking the registry, not the server.

Ceremony per release. Tag, wait for CI, then go and pull on the mini. Three steps, two of them waiting, for an app only I use.

A credential on the server for no reason. The package was private, so the mini needed a personal access token to read it, sitting in its Docker config forever.

So I took the push model and moved the build to where the code runs. The mini has an M4 and builds arm64 natively. It just needed telling what to build.

What deploy does

From the repo on the desktop:

$ deploy
==> pushing main (d635eadc)
==> checking out d635eadc
==> building punch
==> starting punch
==> deployed punch @ d635eadc
    http://punch.lan

There are two halves. On the desktop, deploy is a short script in my dotfiles: it pushes HEAD to GitHub, then SSHes to the mini and runs floo deploy <name> <repo-url> <sha>. On the mini, that script — which lives in the repo that describes the whole server — does the rest:

  1. Clones the repo into ~/apps/punch/src on first deploy, then fetches and checks out exactly that commit. Not a branch, not “latest” — the sha I just pushed.
  2. Runs git clean -ffdx, so nothing left over from a previous build can leak into this one. The checkout is owned by the deploy and nothing else; I never edit it.
  3. Builds the image natively with docker compose build.
  4. Starts it with up --wait, which doesn’t return until the container’s health check passes. A deploy that boots into a crash loop fails the command, instead of printing “deployed” and leaving me to find out later.
  5. Writes the app’s Caddy entry and reloads the proxy.

Passing the sha all the way through matters more than it looks. “Deploy main” means “deploy whatever main is by the time the mini looks”, which is a race I’d only ever lose at a bad moment. The sha means the thing that runs is the thing I pushed.

Uncommitted changes aren’t deployed, and deploy says so before it starts. Only commits go anywhere.

The repo describes itself

A repo becomes deployable by adding a compose.floo.yml. It’s a normal compose file, with two labels on the service that should be reachable:

name: punch

services:
  punch:
    build: .
    container_name: punch
    restart: unless-stopped
    environment:
      - "SECRET_KEY_BASE=${SECRET_KEY_BASE:?set it with floo env set punch SECRET_KEY_BASE=...}"
      - DATABASE_PATH=/data/punch.db
    ports:
      - "4000:4000"
    volumes:
      - punch_data:/data
    healthcheck:
      test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:4000/ || exit 1"]
    labels:
      floo.host: punch.lan
      floo.port: "4000"

The labels are the whole integration. On deploy, the mini reads them out of the resolved compose config and generates infra/caddy/apps/punch.caddy, so punch.lan starts working without me touching the proxy. It’s the same idea as part six from the other direction: the app says what it’s called, and the infrastructure listens.

Secrets stay on the box

The app’s secrets live in ~/apps/punch/.env on the mini, mode 600, and never in git:

floo env set punch SECRET_KEY_BASE=...   # writes the key, restarts the app
floo env punch                           # lists key names, never values

The compose file refers to them with ${KEY:?message}, so a deploy with a missing secret fails immediately with a message that says how to set it, instead of starting an app that dies on its first request. floo env punch printing names and not values is deliberate: it’s the command I run with someone looking over my shoulder, or with an agent in the session.

Two things that bit

YAML read my error message as a map. The first version of that SECRET_KEY_BASE line wasn’t quoted, and its fallback message said set with: floo env set .... That : was enough for YAML to decide the list item was a key-value pair. The fix is the quotes in the file above, and a message without a colon in it. It’s the kind of bug that makes you appreciate a deploy that fails loudly and immediately.

The data had to come with it. Punch was already running on the mini from the registry image, with a SQLite file in a Docker volume and a 3:30am backup job that runs docker exec punch to snapshot it. The new compose file mounts that same volume as external instead of creating a fresh one, and keeps the container name fixed so the backup job keeps working. Migrating the deploy method shouldn’t mean migrating the data, and here it didn’t: same volume, same backups, new way in.

What’s still rough

One app so far. Punch is the only thing deployed this way. The mini’s other long-running processes still start the old way, and moving them is a repo-by-repo job. The old release workflow is still in Punch’s repo, too; it just isn’t what the mini runs anymore.

No rollback command. Deploying an older commit works, because the sha is the whole interface, but there’s no floo rollback that remembers what was running before. For one personal app, git revert and deploy is fine. With more, I’d want it.

The mini’s disk is small. Building on a 256 GB machine means build layers accumulate. The deploy prunes dangling images after every run, which has kept it in check so far.

The rules held

Looking back at the five rules from part one: code lives in git, and the mini only ever sees commits. The desktop writes, the mini runs, and the line between them is a single word. Nothing on the mini is edited by hand — the checkout is the deploy’s, the Caddy entry is generated, the secrets have a command. The server’s state is described by its repo plus a .env per app, and that’s the whole list.

That was the last piece. Back in part one, getting from the couch to working code took a walk across the house and a fair amount of ceremony. Now it’s work to get in and deploy to ship, and both of them just work from wherever I’m sitting.