Fall 2026brianmars.comBuilt at EGBT Technologies

The Devbox Build, Part 3: Containers and a Front Door

Docker goes on, then Caddy goes in front of it, and the tailnet starts to push back.

With the network sorted in part 2, I moved on to the runtime. Every service on this box is a container, so Docker is the only runtime the host needs.

I installed it from Docker's own repository, not Debian's package, so Compose and Buildx come with it. I followed the vendor's instructions for that part and changed nothing. The four lines after it are mine.

Four Changes to Docker

All four go in two small files. I moved Docker's images and volumes to the data disk under /srv, so a reinstall of the system disk leaves them alone. I pinned its DNS to public resolvers. I capped container logs so they can't fill the disk. And I made Docker wait for /srv to mount before it starts.

The DNS line needs a word of explanation. A container on the default bridge network inherits the host's resolver. On a tailnet, the host's resolver is a Tailscale address the container can't reach.

The mount ordering is quieter. Without it, Docker can start before the data disk is mounted and come up with no containers, and nothing logs an error. Only a reboot test catches it, which is why part 5 ends with one.

There's one more small trap. When I added myself to the docker group, the change only applied to new logins. A tmux session started earlier hands its old groups to every new pane until tmux restarts.

Reference card · Clip and keepDocker Engine on Debian

Docker Engine and the Compose plugin on a headless Debian 13 box, with storage on a separate data volume and DNS that works behind Tailscale.

  1. Storage moved to /srv/data/docker
  2. DNS pinned to 1.1.1.1 and 1.0.0.1
  3. Logs capped at 3 × 10 MB per container
  4. Docker waits for the /srv mount
Verified 17 Oct 2026 · Debian 13.6 (trixie) · 4 changes · 4 seamsRead the full card

One Front Door

Every web service on the box listens on 127.0.0.1 only. I put Caddy in front of all of them, and it gives each one a name and a real certificate: git.dev.tailnet.example, home.dev.tailnet.example, and so on.

The box isn't reachable from the internet, so Let's Encrypt can't check it the usual way. Caddy uses a DNS challenge instead, writing a record through Cloudflare's API to show I own the domain. The stock Caddy image has no module for that, so I build a small image that does.

The names are a wildcard record pointing at the box's tailnet address, set to DNS only in Cloudflare. Anyone can look them up. Only a device on my tailnet can connect to what they point at.

The Caddyfile has one block per service, all the same shape. An opening brace has to end its line, so a one-line tls { ... } fails to parse.

Reference card · Clip and keepCaddy with Cloudflare DNS-01

One HTTPS front door with real certificates, for private service names that only lead somewhere from inside the tailnet.

  1. A wildcard record for *.dev pointing at 100.x.y.z
  2. A token scoped to one zone
  3. Host networking
  4. The token in a locked .env
  5. One block per service
Verified 17 Oct 2026 · Caddy 2 (caddy:2, built with xcaddy) · 5 changes · 2 seamsRead the full card

Without a domain, Tailscale Serve does a simpler version of this with certificates on the MagicDNS name. You address services by port instead of by name.

No comments yet