Deploying
Zimmer deploys to a single DigitalOcean droplet running Docker Compose, reachable only over
Tailscale. There is no Kubernetes, no load balancer, and no HA. TLS is optional and off by default —
setting var.domain adds a tailnet-only HTTPS front door (see below).
The topology
Section titled “The topology”By default there is no TLS. The app container serves plain HTTP on :80, and production.rb sets
assume_ssl and force_ssl, which works only because assume_ssl makes Rails pretend the request
arrived over TLS. The actual encryption is WireGuard, via Tailscale. A future public ingress would
break this subtly and badly.
Setting var.domain adds a real HTTPS front door (see below), still tailnet-only, which makes
assume_ssl true in reality. Supervision is dockerd + restart: always.
Custom-domain HTTPS over the tailnet
Section titled “Custom-domain HTTPS over the tailnet”Plain HTTP with assume_ssl is a known sharp edge: because Rails computes https:// origins that
never match the browser’s http://, every CSRF-protected form POST 422s and every ActionCable upgrade
is rejected. Setting var.domain (e.g. zimmer.tadasant.com) fixes this class at the source by putting
a genuine cert on a custom name — while staying reachable only over the tailnet.
The trick is that TLS behind a tailnet is awkward: the firewall opens no public 80/443, so ACME HTTP-01/TLS-ALPN-01 can’t work — only DNS-01 can. On-box renewal would mean parking a Cloudflare token on the droplet, so the work is split so the box holds no DNS credential:
- On the droplet (
cloud-init.yaml.tftpl, only whenvar.domainis set): a stock, plugin-lesscaddy:2container on:443that does no ACME. It serves the cert files at/opt/zimmer/certs/{cert,key}.pemand reverse-proxies to the app. The app keeps publishing:80, so the MagicDNShttp://…path is unchanged and a Caddy misconfig can’t take the box down. A self-signed placeholder is written at boot so Caddy can start before the real cert arrives. - In CI (
scripts/domain-cert.sh, run bydomain-cert-staging.yml): discovers the droplet’s tailnet IP, upserts a Cloudflaredomain → tailnet IP (100.x)A record, issues/renews the Let’s Encrypt cert via ACME DNS-01 through Cloudflare, pushes only the cert onto the box overtailscale ssh, and restarts Caddy (the Caddyfile setsadmin off, so there’s no live-reload endpoint — a restart re-reads the bind-mounted files). The Cloudflare token lives only in GitHub Actions.
The A record points at the tailnet IP, so tailnet peers resolve and reach it while everyone else gets an unroutable address — same tailnet-only exposure as the MagicDNS name, now with a real cert.
The two gaps that will bite you
Section titled “The two gaps that will bite you”The Docker images
Section titled “The Docker images”Dockerfile.base → ghcr.io/tadasant/zimmer-base — the heavy one, rebuilt monthly (cron
0 6 1 * *) or on demand. From ruby:3.4.6-slim, it bakes in:
- Gems, pre-bundled to
/usr/local/bundlewith bootsnap precompiled - Node.js 22, the Docker CLI,
gh, the 1Password CLI,uv/uvx - Playwright + Chromium and Puppeteer + Chrome (for browser-automation MCP servers)
- The npm and Python MCP packages listed in
mcp.json(bin/preinstall-mcp-packages) - The AIR CLI
@pulsemcp/[email protected]+ adapters →/opt/air-cli - The Codex CLI
@openai/[email protected]and Claude Code (viaclaude.ai/install.sh)
Dockerfile → ghcr.io/tadasant/zimmer — the app image. Copies the app onto the base, re-runs
bundle install (which catches Gemfile drift against the base), precompiles assets, drops to
USER 1000:1000, and runs bin/thrust bin/rails server.
The workflows
Section titled “The workflows”| Workflow | Trigger | What it does |
|---|---|---|
ci.yml | PR + push to main | rubocop · brakeman · Gemfile.lock freshness · tests (Postgres + Redis services) · GHCR-retention logic · docs site build |
release-image.yml | push to main (ignores **/*.md, docs/**) | builds and pushes zimmer:{version, latest, sha-…} |
build-base-image.yml | manual + monthly cron | rebuilds the base image |
deploy-staging.yml | manual only | see below |
teardown-staging.yml | daily cron 08:00 UTC | destroys the staging droplet (a powered-off droplet still bills) |
ghcr-retention.yml | weekly cron | prunes GHCR to ≤50 versions |
domain-cert-staging.yml | weekly cron + manual | issues/renews the Let’s Encrypt cert for var.domain via ACME DNS-01 and pushes it to the droplet (see Custom-domain HTTPS) |
Staging deploys are destroy-and-recreate
Section titled “Staging deploys are destroy-and-recreate”deploy-staging.yml does not do an in-place redeploy. It:
- Builds the base image (
:staging) and app image (:staging-<sha>). - Reaps the prior droplet and firewall through the DigitalOcean API, because the Terraform state
is ephemeral (no backend block), so
applycan’t converge on its own. - Reaps the stale Tailscale node.
terraform apply.- Joins the tailnet last, only for the health check: it resolves the peer IP from
tailscale status --jsonand curlshttp://<ip>/up, 40 times at 15-second intervals (a 10-minute budget).
Terraform, briefly
Section titled “Terraform, briefly”cd infra/terraformcp staging.tfvars.example staging.tfvarsexport TF_VAR_do_token=… TF_VAR_tailscale_auth_key=… TF_VAR_ghcr_token=… \ TF_VAR_secret_key_base=$(openssl rand -hex 64)terraform init -input=falseterraform apply -input=false -auto-approve -var-file=staging.tfvars \ -var="image_ref=ghcr.io/tadasant/zimmer:<tag>"Creates: the droplet, the firewall, and optionally a DO project (manage_project defaults to false,
because a DO project name is account-unique and would collide under ephemeral state). It does not
create a DNS record — when var.domain is set, the domain-cert workflow owns the A record (pointing at
the tailnet IP), which keeps the Cloudflare credential out of Terraform.
Production references a pre-existing Managed Postgres cluster as a read-only data source. It never
creates it. The cluster, and both its databases (zimmer_production and zimmer_production_cable),
must exist first.