Skip to content

Self-hosting Zimmer

This is the end-to-end walkthrough for running your own Zimmer: one DigitalOcean droplet, reachable only over your Tailscale tailnet, with Terraform provisioning the host and Kamal deploying the app onto it. The app has no public ingress (the firewall drops :80 at the edge) and there’s no one-click button, because a self-host has real pre-work first — accounts and credentials only you can create.

So the flow is two-part. First you do the prerequisites yourself. Then you run the rest of the setup — either following along by hand, or handing a self-contained prompt to a coding agent.

For the shape of what you’re deploying — the topology, the images, the Kamal deploy, the workflows — read Deploying; for the full variable and secret inventory, Provisioning and secrets. This page is the walkthrough that ties them together and stops short of duplicating them.

Regardless of whether you follow the rest by hand or hand it to an agent, do this pre-work yourself first. Each item is an account, a credential, or a secret that an agent can’t create for you — and shouldn’t hold on your behalf. Sort these out, collect the values, and the rest is mechanical.

The app’s secrets flow through Kamal (env.secret, sourced from your GitHub Actions secrets), so they stay out of the droplet’s user_data. The Tailscale auth key does not — cloud-init still bakes it in, and user_data is readable from the DigitalOcean metadata service by anything on the box, including every agent process a session spawns. Use an ephemeral, reusable auth key rather than a long-lived one, and prefer scoped, rotatable tokens throughout. See Where secrets end up that they shouldn’t.

A place to run the deploy: GitHub + the repo

Section titled “A place to run the deploy: GitHub + the repo”

You need a copy of this repository under your own GitHub account — fork or mirror tadasant/zimmer. That copy is where the deploy workflow runs, where the Actions secrets live, and what publishes the container image to your GHCR.

  • A GitHub account, and Git credentials on the machine you work from (gh auth login, or an SSH key registered with GitHub) so you can clone your fork and push to it.
  • A GHCR pull token — a GitHub token with read:packages — so Kamal can log the droplet into GHCR and pull the image (GHCR_PULL_TOKEN).

Git shows up a second time, and it’s the one people miss: the agents Zimmer runs are themselves Git users. Every session clones a repo, commits on a branch, and opens a PR, so the running instance needs its own GitHub login.

  • A GitHub account for the instance — your own is fine — with access to the repositories your agent roots point at, and rights to push branches and open pull requests there.
  • There is no token to pre-mint for this. Zimmer authenticates Git through gh: you run a one-time gh auth login device flow inside the container after the first deploy (step 10). Dockerfile.base bakes a credential helper (credential.https://github.com.helper = !/usr/bin/gh auth git-credential), so plain git inside a session authenticates through gh — there is no GH_TOKEN env var fallback.
  • A DigitalOcean account and an API token with write access (do_token / the DIGITALOCEAN_ACCESS_TOKEN Actions secret). Terraform uses it to create the droplet, firewall, and reserved IP.
  • A DigitalOcean Spaces bucket for Terraform’s remote state (the shipped backend.staging.hcl uses zimmer-tfstate in nyc3), plus a Spaces access-key pair. The keys are passed to terraform init as AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (the SPACES_ACCESS_KEY_ID / SPACES_SECRET_ACCESS_KEY Actions secrets). Remote state is what lets apply converge on a persistent droplet instead of recreating it.
  • Production only: a Managed Postgres cluster, created out of band. Terraform reads it as a data source and never creates it, so it must already exist. The cluster’s primary user (doadmin) comes with it, but both databases (<name>_production and <name>_production_cable) must be created by hand. The spec is in infra/terraform/data-stores/README.md. Staging skips this and runs a throwaway Postgres accessory.

Kamal reaches the droplet over SSH to swap containers. It is not your login key — mint a dedicated keypair for it.

  • An SSH keypair for Kamal. The public half is deploy_ssh_pubkey (KAMAL_SSH_PUBKEY), which cloud-init authorizes for root on the droplet; the private half is KAMAL_SSH_KEY, held only in CI. A lifecycle.precondition fails the plan if the public key is empty, since Kamal could not otherwise reach the box.

Zimmer is reachable only over your tailnet. The droplet joins it at boot and the UI is served over that VPN, never a public port.

  • A Tailscale account and tailnet.
  • A pre-authorized, ephemeral auth key for the droplet to join (tailscale_auth_key / TAILSCALE_AUTH_KEY).
  • A separate pre-minted auth key tagged tag:ci for CI’s own tailnet join, so it can resolve the droplet’s IP and health-check it (TS_CI_AUTHKEY). This must be a real auth key, not an OAuth client — a Tailscale OAuth client cannot mint tag:ci keys.
  • A Tailscale API client (TS_API_CLIENT_ID / TS_API_CLIENT_SECRET) so the deploy can reap the stale tailnet node and keep the MagicDNS name from drifting to zimmer-staging-1, -2, …
  • Tailnet ACL policy that defines tag:ci. Edit it in the Tailscale admin console.

The agent’s brain: an Anthropic and/or OpenAI account

Section titled “The agent’s brain: an Anthropic and/or OpenAI account”

Zimmer runs coding agents, so it needs at least one agent harness logged in. Without this, sessions spawn but no agent runs.

  • An Anthropic account for Claude Code, and/or an OpenAI account for Codex. Like the GitHub login above, this is an OAuth flow you run inside the container after the first deploy (claude /login / codex login, step 10). Claude’s credentials persist on the claude_home volume; Codex’s do not — nothing mounts ~/.codex, so a codex login is wiped by the next deploy unless you add a volume for it. See agent harness credentials.

Effectively required: a Cloudflare zone for HTTPS

Section titled “Effectively required: a Cloudflare zone for HTTPS”

Nominally optional — you can reach Zimmer at its plain tailnet MagicDNS name (http://zimmer, or http://zimmer-staging). In practice the shipped deploy-staging workflow chains its cert job unconditionally and that job hard-fails without a Cloudflare token, so skipping this means every deploy run goes red unless you delete the cert job from your fork (see step 8). Plain HTTP over the tailnet has a known sharp edge — assume_ssl makes Rails compute https:// origins that never match the browser’s http://, so CSRF-protected POSTs 422 and ActionCable upgrades get rejected. A custom domain fixes that class at the source. See Custom-domain HTTPS over the tailnet.

  • A Cloudflare account holding your domain’s DNS zone.
  • A Cloudflare API token scoped to Zone:DNS:Edit + Zone:Zone:Read on that one zone (CLOUDFLARE_API_TOKEN). The domain-cert-* workflow uses it to issue the Let’s Encrypt cert via ACME DNS-01 and to upsert the domain → tailnet IP A record. The token lives only in Actions; the droplet never holds it.
  • A Rails SECRET_KEY_BASE: openssl rand -hex 64 (STAGING_SECRET_BASE). A secret only you hold.
  • A database password for the Postgres accessory: another openssl rand -hex 32 (STAGING_DB_PASSWORD). It is deliberately not derived from SECRET_KEY_BASE — the Postgres data volume is durable and POSTGRES_PASSWORD only takes effect on the first initdb, so a separate, stable password keeps rotating SECRET_KEY_BASE safe.
  • API_KEYS (STAGING_API_KEYS): comma-separated bearer keys for the REST API. Empty leaves the API off (every request 401s).
  • (Optional) A Rails credentials key (STAGING_RAILS_MASTER_KEY), only if you want encrypted mcp_secrets — the values MCP servers reference as ${VAR}, plus SLACK_BOT_TOKEN and ENG_ALERTS_SLACK_CHANNEL_ID. Your fork inherits our config/credentials/staging.yml.enc, and you do not have the key to it, so delete it and make your own: rm config/credentials/staging.yml.enc && EDITOR=vim bin/rails credentials:edit -e staging, then gh secret set STAGING_RAILS_MASTER_KEY < config/credentials/staging.key. Skip all of it and the deploy still works — you just get a ::warning:: and no mcp_secrets.

With the prerequisites in hand, the rest is mechanical. Do it yourself, or hand the prompt in the Hand it to an agent tab to Claude Code (or any coding agent) and let it drive.

  1. Get the code. Fork or mirror tadasant/zimmer to your GitHub account and clone it.

  2. Point the fork at yourself. The repo is Zimmer’s own deployment, so a handful of values are hardcoded to tadasant. A fresh fork will not deploy until you rewrite them — the image build pushes to ghcr.io/tadasant/…, which your GITHUB_TOKEN has no rights to. Change these and commit:

    FileWhat to change
    .github/workflows/deploy-staging.ymlthe ghcr.io/tadasant/zimmer and ghcr.io/tadasant/zimmer-base image tags → your GHCR namespace
    config/deploy.ymlimage: tadasant/zimmerimage: <you>/zimmer (Kamal prepends the registry)
    config/deploy.staging.ymlAPP_HOST and ZIMMER_STAGING_BASE_URL → your host. Terraform’s domain only turns on the TLS terminator; it does not feed Rails. Leave these and every generated URL and OAuth callback points at someone else’s host.
    infra/terraform/backend.staging.hclbucket / endpoint → your Spaces bucket
    .github/workflows/domain-cert-staging.ymlDOMAIN, CF_ZONE_ID, ACME_EMAIL → yours (only if you’re using a custom domain)
  3. Create the Terraform state bucket. Make a DigitalOcean Spaces bucket for state (the shipped infra/terraform/backend.staging.hcl expects zimmer-tfstate in nyc3) and mint a Spaces access-key pair.

  4. Set the GitHub Actions secrets on your fork (gh secret set NAME). The full list and what each is used for is in Provisioning → GitHub Actions secrets. At minimum: DIGITALOCEAN_ACCESS_TOKEN, SPACES_ACCESS_KEY_ID, SPACES_SECRET_ACCESS_KEY, KAMAL_SSH_KEY, KAMAL_SSH_PUBKEY, TAILSCALE_AUTH_KEY, TS_CI_AUTHKEY, TS_API_CLIENT_ID, TS_API_CLIENT_SECRET, GHCR_PULL_TOKEN, STAGING_SECRET_BASE, STAGING_DB_PASSWORD, STAGING_API_KEYS, and CLOUDFLARE_API_TOKEN (see the caution in step 8 — the cert job runs on every deploy, so this is effectively required).

  5. Define tag:ci in your Tailscale ACL so CI’s health-check node can join.

  6. Write your tfvars. Edit infra/terraform/staging.tfvars.example and commit it — set region, droplet_size, domain (or "" for MagicDNS only), and managed_db_cluster_name. Terraform only provisions the host here; the app image, env, and data stores are Kamal’s.

  7. Deploy. Run the deploy-staging workflow on your fork (gh workflow run deploy-staging.yml, or the Actions tab). It builds the images, terraform applys the host through remote state, then kamal deploy -d staging boots the new containers alongside the old ones, health-checks /up, and only then flips traffic. The first run against an empty state bucket provisions the droplet; later runs swap containers in place. The full sequence is in Deploying → Staging deploys are Kamal container swaps.

  8. Custom domain. Set domain in tfvars, add CLOUDFLARE_API_TOKEN, and point DOMAIN / CF_ZONE_ID / ACME_EMAIL in domain-cert-staging.yml at your own zone. The deploy-staging workflow chains a cert job after the deploy, so the cert is issued and the domain → tailnet IP A record upserted automatically; the weekly schedule renews thereafter.

  9. Verify. Join the same tailnet from your machine, curl the health endpoint (http://zimmer-staging/up), and open the UI at the MagicDNS name (http://zimmer-staging, or your custom domain over HTTPS). The deploy also asserts the worker container is up — that’s where agent sessions execute, so a session stuck in waiting means the worker didn’t come up. Rollback is one command: kamal rollback <version> -d staging (the host keeps the last 5 images).

  10. Log the instance in — Git first. The app is up, but it can’t do anything useful yet: an agent that can’t authenticate to GitHub can’t clone, can’t push a branch, and can’t open a PR. These are interactive OAuth device flows, so they can’t be baked into a secret — you run them once, by hand, inside the container:

    Terminal window
    bin/kamal app exec -i --reuse -d staging bash # or: ssh root@<droplet>, then
    # docker exec -it $(docker ps -q --filter name=zimmer-worker | head -1) bash
    gh auth login # GitHub — the sole source of git auth
    claude /login # and/or: codex login

    For gh and claude this is once, not per deploy: gh’s token lands in ~/.config/gh and Claude’s in ~/.claude, both durable named volumes (gh_config, claude_home) shared by web and worker and re-attached across Kamal container swaps. Confirm with gh auth status and claude whoami; the UI surfaces the same state (CliStatusService refreshes it every two minutes).

  11. Make it yours: point it at your own catalog. You now have a working Zimmer — running Zimmer’s catalog. The image ships a small, self-contained one, and a stock deploy (staging included) serves exactly that. It is deliberately minimal; it is not meant to be your catalog. Your agent roots, your MCP servers, and your skills live in a catalog you own.

    Deliver it to the box and point the app at it with AIR_CONFIG. The wiring exists today in config/deploy.production.yml; a staging-shaped deploy ships neither the mounts nor the env var, so you add them:

    # config/deploy.<env>.yml — on BOTH the web and worker roles
    volume:
    - /opt/zimmer/catalog:/rails/catalog:ro # your air.production.json + artifacts/
    - /opt/zimmer/credentials:/rails/config/credentials:ro # your production.yml.enc (mcp_secrets)
    env:
    clear:
    AIR_CONFIG: /rails/catalog/air.production.json

    Bind-mounting host paths is what makes the catalog survive a Kamal deploy — a new container re-attaches the same mounts — and mounting on both roles is what makes agent sessions (which run in the worker) resolve the same roots and secrets the web UI shows. Sync the files to those paths from your own repo with a workflow, and re-run it when the catalog changes, not on every deploy.

    → The full mechanism, with a worked example, is in Pointing an instance at your own catalog.

The staging model works, but it makes trade-offs a single-operator box can live with and a wider deployment can’t. Read Known limitations in full; the ones most likely to matter here:

  • The Tailscale auth key sits in the droplet’s user_data. App secrets (SECRET_KEY_BASE, the DB password, the GHCR token) now reach the container through Kamal from GitHub Actions and stay out of cloud-init — but the tailnet auth key and the deploy public key don’t, and user_data is readable from the metadata service by anything on the box, agent processes included. Use an ephemeral, reusable key.
  • Nothing is encrypted at rest, and /supervisor is unauthenticated. OAuth tokens and secrets are plaintext columns, and the admin panel that renders them has its auth stubbed out. The tailnet perimeter is the only wall — don’t expose port 80. See Auth overview.
  • SSH is tailnet-only, but the hardening only lands on a rebuild. The firewall opens no public TCP port, so a plain terraform apply shuts the internet out immediately. The key-only sshd and its tailnet :2222 listener ride cloud-init, though, so a droplet that already exists keeps the Ubuntu image’s PermitRootLogin yes / PasswordAuthentication yes posture until you deploy with recreate_droplet: true. Before you do that, read the one fallback door a rebuilt droplet has.
  • Change admin_ssh_pubkeys before your first deploy. deploy-staging.yml does cp staging.tfvars.example staging.tfvars verbatim, so whatever key sits in the committed example is what gets authorized for root on your droplet — and the checked-in value is the upstream author’s operator key, not yours. Edit staging.tfvars.example itself (staging.tfvars is gitignored and would be overwritten by that cp). Set it to your own key, or [].
  • user_data is frozen after the first boot. ignore_changes = [user_data] is what stops an app change from replacing the droplet — but it also means the Kamal deploy key and the Caddyfile only reach the box on a rebuild. Rotating the deploy key or changing var.domain needs a deliberate terraform taint of the droplet.