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.
Prerequisites you set up yourself
Section titled “Prerequisites you set up yourself”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-timegh auth logindevice flow inside the container after the first deploy (step 10).Dockerfile.basebakes a credential helper (credential.https://github.com.helper = !/usr/bin/gh auth git-credential), so plaingitinside a session authenticates throughgh— there is noGH_TOKENenv var fallback.
The host: a DigitalOcean account
Section titled “The host: a DigitalOcean account”- A DigitalOcean account and an API token with write access (
do_token/ theDIGITALOCEAN_ACCESS_TOKENActions 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.hcluseszimmer-tfstateinnyc3), plus a Spaces access-key pair. The keys are passed toterraform initasAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY(theSPACES_ACCESS_KEY_ID/SPACES_SECRET_ACCESS_KEYActions secrets). Remote state is what letsapplyconverge 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>_productionand<name>_production_cable) must be created by hand. The spec is ininfra/terraform/data-stores/README.md. Staging skips this and runs a throwaway Postgres accessory.
The deploy channel: a Kamal SSH key
Section titled “The deploy channel: a Kamal SSH key”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 forrooton the droplet; the private half isKAMAL_SSH_KEY, held only in CI. Alifecycle.preconditionfails the plan if the public key is empty, since Kamal could not otherwise reach the box.
The network: a Tailscale account
Section titled “The network: a Tailscale account”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:cifor 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 minttag:cikeys. - 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 tozimmer-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 theclaude_homevolume; Codex’s do not — nothing mounts~/.codex, so acodex loginis 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). Thedomain-cert-*workflow uses it to issue the Let’s Encrypt cert via ACME DNS-01 and to upsert thedomain → tailnet IPA record. The token lives only in Actions; the droplet never holds it.
The secrets you just generate
Section titled “The secrets you just generate”- 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 fromSECRET_KEY_BASE— the Postgres data volume is durable andPOSTGRES_PASSWORDonly takes effect on the firstinitdb, so a separate, stable password keeps rotatingSECRET_KEY_BASEsafe. 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 encryptedmcp_secrets— the values MCP servers reference as${VAR}, plusSLACK_BOT_TOKENandENG_ALERTS_SLACK_CHANNEL_ID. Your fork inherits ourconfig/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, thengh 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 nomcp_secrets.
Stand it up
Section titled “Stand it up”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.
-
Get the code. Fork or mirror
tadasant/zimmerto your GitHub account and clone it. -
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 toghcr.io/tadasant/…, which yourGITHUB_TOKENhas no rights to. Change these and commit:File What to change .github/workflows/deploy-staging.ymlthe ghcr.io/tadasant/zimmerandghcr.io/tadasant/zimmer-baseimage tags → your GHCR namespaceconfig/deploy.ymlimage: tadasant/zimmer→image: <you>/zimmer(Kamal prepends the registry)config/deploy.staging.ymlAPP_HOSTandZIMMER_STAGING_BASE_URL→ your host. Terraform’sdomainonly 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) -
Create the Terraform state bucket. Make a DigitalOcean Spaces bucket for state (the shipped
infra/terraform/backend.staging.hclexpectszimmer-tfstateinnyc3) and mint a Spaces access-key pair. -
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, andCLOUDFLARE_API_TOKEN(see the caution in step 8 — the cert job runs on every deploy, so this is effectively required). -
Define
tag:ciin your Tailscale ACL so CI’s health-check node can join. -
Write your tfvars. Edit
infra/terraform/staging.tfvars.exampleand commit it — setregion,droplet_size,domain(or""for MagicDNS only), andmanaged_db_cluster_name. Terraform only provisions the host here; the app image, env, and data stores are Kamal’s. -
Deploy. Run the
deploy-stagingworkflow on your fork (gh workflow run deploy-staging.yml, or the Actions tab). It builds the images,terraform applys the host through remote state, thenkamal deploy -d stagingboots 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. -
Custom domain. Set
domainin tfvars, addCLOUDFLARE_API_TOKEN, and pointDOMAIN/CF_ZONE_ID/ACME_EMAILindomain-cert-staging.ymlat your own zone. Thedeploy-stagingworkflow chains acertjob after the deploy, so the cert is issued and thedomain → tailnet IPA record upserted automatically; the weekly schedule renews thereafter. -
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 theworkercontainer is up — that’s where agent sessions execute, so a session stuck inwaitingmeans the worker didn’t come up. Rollback is one command:kamal rollback <version> -d staging(the host keeps the last 5 images). -
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) bashgh auth login # GitHub — the sole source of git authclaude /login # and/or: codex loginFor
ghandclaudethis is once, not per deploy:gh’s token lands in~/.config/ghand Claude’s in~/.claude, both durable named volumes (gh_config,claude_home) shared bywebandworkerand re-attached across Kamal container swaps. Confirm withgh auth statusandclaude whoami; the UI surfaces the same state (CliStatusServicerefreshes it every two minutes). -
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 inconfig/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 rolesvolume:- /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.jsonBind-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.
What the agent needs access to
Section titled “What the agent needs access to”Give it these, and nothing more. Everything else is a value you paste in when it asks.
| Tool | What it does with it | Minimum permissions |
|---|---|---|
GitHub — an MCP server, or just a logged-in gh CLI. Required. | Rewrites the hardcoded tadasant values in your fork, sets the Actions secrets, commits the tfvars, dispatches deploy-staging, reads the run logs. | Repository-scoped: Metadata read · Secrets read+write · Actions read+write · Contents read+write · Workflows read+write (it has to edit .github/workflows/ to repoint the image tags). No org or admin access. |
| DigitalOcean — optional. | Confirms the Spaces state bucket and the droplet exist. | Read-only (Spaces + droplets). Skip it entirely if you created the bucket yourself. |
| Tailscale — optional. | Confirms the droplet joined the tailnet and reads its IP. | Devices: read. ACL edits stay yours — no MCP server does that step for you. |
| Browser (Playwright) — optional. | Loads the UI to eyeball it. | None, but the agent’s machine has to be on your tailnet to reach it. |
Copy this prompt and paste it into Claude Code (or any coding agent) working in your fork. It assumes every prerequisite above is done and that you’ll supply the secret values when it asks — it will not invent them.
You are setting up a self-hosted Zimmer deployment for me. Every prerequisite is already done: I have GitHub/GHCR, DigitalOcean (with a Spaces bucket for Terraform state), a Kamal SSH keypair, Tailscale, and (optionally) Cloudflare accounts, and I hold all the credential values. Your job is the mechanical setup on my fork of the repo. Do not invent, guess, or fabricate any secret value — when you need one, stop and ask me for it, and never commit a secret to git.
Access you need: GitHub only — an MCP server or a logged-in 'gh' CLI, scoped to this repository with Metadata: read, Secrets: read+write, Actions: read+write, Contents: read+write, and Workflows: read+write (you must edit .github/workflows/ to repoint the image tags). You do NOT need (and must not ask me for) DigitalOcean, Tailscale, or Kamal credentials of your own: every secret you collect goes straight into GitHub Actions and the deploy runs in CI. A read-only DigitalOcean or Tailscale MCP server is optional, only to confirm the Spaces bucket / droplet / tailnet node exists. If you are missing the GitHub access above, stop and tell me before doing anything else.
Work in this repository (my fork of tadasant/zimmer). Read docs/src/content/docs/operate/deploying.md and provisioning.md first so you understand the topology (Terraform provisions the persistent host; Kamal deploys the app) and the exact variable/secret names.
Then do the following, pausing to confirm with me at each step that produces external side effects:
1. FIRST, repoint the fork at me — a fresh fork does NOT deploy until you do. The repo is Zimmer's own deployment and hardcodes 'tadasant' in several places; the image build would push to ghcr.io/tadasant/... which my GITHUB_TOKEN cannot write to, and it would 403. Ask me for my GitHub username/org and my host, then edit and commit:
- .github/workflows/deploy-staging.yml: the ghcr.io/tadasant/zimmer and ghcr.io/tadasant/zimmer-base image tags -> my GHCR namespace.
- config/deploy.yml: 'image: tadasant/zimmer' -> 'image: <me>/zimmer'.
- config/deploy.staging.yml: APP_HOST and ZIMMER_STAGING_BASE_URL -> my host. (Terraform's 'domain' only turns on the TLS terminator; it does NOT feed Rails. Leaving these points every generated URL and OAuth callback at someone else's host.)
- infra/terraform/backend.staging.hcl: bucket/endpoint -> my Spaces bucket.
- .github/workflows/domain-cert-staging.yml: DOMAIN, CF_ZONE_ID, ACME_EMAIL -> mine (only if I'm using a custom domain).
2. Set the GitHub Actions secrets on this repo with 'gh secret set'. Ask me for each value one at a time. At minimum: DIGITALOCEAN_ACCESS_TOKEN, SPACES_ACCESS_KEY_ID, SPACES_SECRET_ACCESS_KEY, KAMAL_SSH_KEY (the private half of the Kamal deploy key), KAMAL_SSH_PUBKEY (the public half), TAILSCALE_AUTH_KEY, TS_CI_AUTHKEY (a pre-minted tag:ci auth key, NOT an OAuth client), TS_API_CLIENT_ID, TS_API_CLIENT_SECRET, GHCR_PULL_TOKEN, STAGING_SECRET_BASE, STAGING_DB_PASSWORD (the Postgres accessory password, separate from SECRET_KEY_BASE), STAGING_API_KEYS, CLOUDFLARE_API_TOKEN. Note CLOUDFLARE_API_TOKEN is effectively required: deploy-staging.yml chains a 'cert' job with no condition and domain-cert-staging.yml exits 1 when the token is empty, so without it the run goes red even when the app deployed fine. If I don't want a custom domain, offer to delete the 'cert' job from the workflow instead.
3. Confirm my Terraform state bucket exists on DigitalOcean Spaces and that infra/terraform/backend.staging.hcl points at it (bucket name, region, endpoint). If mine differs from the committed zimmer-tfstate / nyc3 values, update backend.staging.hcl.
4. Remind me to define tag:ci in my Tailscale ACL policy (admin console) — you can't do that for me — and wait for me to confirm it's done.
5. Edit infra/terraform/staging.tfvars.example and COMMIT it — do not create a local staging.tfvars. *.tfvars is gitignored and the deploy workflow runs its own 'cp staging.tfvars.example staging.tfvars' on the runner, so only the committed .example reaches CI. Set the non-secret host config: region, droplet_size, domain (ask me; empty string means MagicDNS-only http access), and managed_db_cluster_name (empty for the throwaway staging Postgres). Do NOT put any secret in this file — secrets flow through Actions secrets and Kamal's secrets, never a committed file.
6. Deploy by triggering the deploy-staging workflow: 'gh workflow run deploy-staging.yml', then watch it with 'gh run watch'. It builds the images, runs terraform apply against remote state to provision/reconcile the host, then kamal deploy -d staging with a health-gated cutover. If it fails, read the logs, diagnose, and tell me what's wrong before retrying.
7. If a custom domain is configured, confirm the cert was issued — the deploy-staging workflow chains a domain-cert job after the deploy, so it runs automatically. If it didn't, run domain-cert-staging via workflow_dispatch.
8. Verify the deployment. Confirm the workflow's tailnet health check passed and that it reports the worker container running (agent sessions run in the worker; without it, sessions enqueue and never execute). Report the URL I reach it at (the MagicDNS name like http://zimmer-staging, or my custom HTTPS domain), and remind me that a bad deploy can be rolled back with 'kamal rollback <version> -d staging'.
9. Hand the Git login back to me. The instance needs its own GitHub auth before any session can clone a repo, push a branch, or open a PR — and it is an interactive OAuth device flow inside the container, so you cannot do it (you have no shell on the droplet, and there is no GH_TOKEN env var to set). Tell me to run, once:
bin/kamal app exec -i --reuse -d staging bash
gh auth login # GitHub: the sole source of git auth
claude /login # and/or: codex login
Explain that the gh and claude tokens persist on the gh_config and claude_home volumes, so those are one-time and not per-deploy — but there is NO ~/.codex volume, so a codex login is wiped by the next deploy unless I add one. Until I do the gh login, every session fails at the clone step even though /up is green. Do not call the setup complete until I confirm 'gh auth status' succeeds inside the container.
10. Tell me what's left to make it mine. The instance will be running Zimmer's own small in-image catalog, not my agent roots / MCP servers / skills. Point me at docs/src/content/docs/air/artifacts.md ("Pointing an instance at your own catalog"): I deliver my catalog to a persistent host dir, bind-mount it into BOTH the web and worker roles, and set AIR_CONFIG to the mounted air.production.json. Warn me that only production.rb guards AIR_CONFIG with a File.exist? fallback — staging.rb reads it straight through, and deploy.staging.yml sets RAILS_ENV=staging — so on this staging-shaped deploy I must deliver the catalog files BEFORE setting AIR_CONFIG, or the app resolves zero roots. Offer to wire it up as a follow-up, but do not attempt it now.
Report back the deploy status, the URL, and a short summary of exactly what you did. Do not close anything out until I've confirmed I can reach the UI.The prompt tells the agent to ask you before anything with an external side effect, and never to commit a secret.
Know this before you rely on it
Section titled “Know this before you rely on it”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, anduser_datais readable from the metadata service by anything on the box, agent processes included. Use an ephemeral, reusable key. - Nothing is encrypted at rest, and
/supervisoris 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 applyshuts the internet out immediately. The key-only sshd and its tailnet:2222listener ride cloud-init, though, so a droplet that already exists keeps the Ubuntu image’sPermitRootLogin yes/PasswordAuthentication yesposture until you deploy withrecreate_droplet: true. Before you do that, read the one fallback door a rebuilt droplet has. - Change
admin_ssh_pubkeysbefore your first deploy.deploy-staging.ymldoescp staging.tfvars.example staging.tfvarsverbatim, so whatever key sits in the committed example is what gets authorized forrooton your droplet — and the checked-in value is the upstream author’s operator key, not yours. Editstaging.tfvars.exampleitself (staging.tfvarsis gitignored and would be overwritten by thatcp). Set it to your own key, or[]. user_datais 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 changingvar.domainneeds a deliberateterraform taintof the droplet.
Where to go next
Section titled “Where to go next”- Your first session — drive the running app.
- Configuration reference — the env vars that shape behavior.
- Auth architecture — how credentials flow, and the
/supervisorpanel to lock down.