Skip to content

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).

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.

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 when var.domain is set): a stock, plugin-less caddy:2 container on :443 that does no ACME. It serves the cert files at /opt/zimmer/certs/{cert,key}.pem and reverse-proxies to the app. The app keeps publishing :80, so the MagicDNS http://… 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 by domain-cert-staging.yml): discovers the droplet’s tailnet IP, upserts a Cloudflare domain → 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 over tailscale ssh, and restarts Caddy (the Caddyfile sets admin 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.

Dockerfile.baseghcr.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/bundle with 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/air-cli@0.13.0 + adapters → /opt/air-cli
  • The Codex CLI @openai/codex@0.135.0 and Claude Code (via claude.ai/install.sh)

Dockerfileghcr.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.

WorkflowTriggerWhat it does
ci.ymlPR + push to mainrubocop · brakeman · Gemfile.lock freshness · tests (Postgres + Redis services) · GHCR-retention logic · docs site build
release-image.ymlpush to main (ignores **/*.md, docs/**)builds and pushes zimmer:{version, latest, sha-…}
build-base-image.ymlmanual + monthly cronrebuilds the base image
deploy-staging.ymlmanual onlysee below
teardown-staging.ymldaily cron 08:00 UTCdestroys the staging droplet (a powered-off droplet still bills)
ghcr-retention.ymlweekly cronprunes GHCR to ≤50 versions
domain-cert-staging.ymlweekly cron + manualissues/renews the Let’s Encrypt cert for var.domain via ACME DNS-01 and pushes it to the droplet (see Custom-domain HTTPS)

deploy-staging.yml does not do an in-place redeploy. It:

  1. Builds the base image (:staging) and app image (:staging-<sha>).
  2. Reaps the prior droplet and firewall through the DigitalOcean API, because the Terraform state is ephemeral (no backend block), so apply can’t converge on its own.
  3. Reaps the stale Tailscale node.
  4. terraform apply.
  5. Joins the tailnet last, only for the health check: it resolves the peer IP from tailscale status --json and curls http://<ip>/up, 40 times at 15-second intervals (a 10-minute budget).
Terminal window
cd infra/terraform
cp staging.tfvars.example staging.tfvars
export 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=false
terraform 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.

Provisioning and secrets