Provisioning and secrets
Terraform variables
Section titled “Terraform variables”Non-secret (set in staging.tfvars / production.tfvars):
| Variable | Notes |
|---|---|
environment | validated staging | production |
region / droplet_size | default nyc3 / s-2vcpu-4gb |
image_ref | default ghcr.io/tadasant/zimmer:latest |
domain | "" by default. Set it to turn on custom-domain HTTPS over the tailnet. Terraform no longer creates a DNS record — the domain-cert workflow owns the A record. |
manage_project | false by default — a DO project name is account-unique and collides under ephemeral state |
ssh_key_fingerprints | |
ghcr_username | |
managed_db_cluster_name | "" for staging (uses the compose db); set for production |
managed_db_username | default doadmin |
Secrets (as TF_VAR_*, all marked sensitive):
do_token · tailscale_auth_key · ghcr_token · secret_key_base · managed_db_password ·
optional otel_logs_endpoint, otel_logs_token, sentry_dsn.
A lifecycle.precondition on the droplet fails the plan if use_managed_db is set but
managed_db_password is empty.
GitHub Actions secrets
Section titled “GitHub Actions secrets”| Secret | Used by |
|---|---|
DIGITALOCEAN_ACCESS_TOKEN | deploy + teardown (droplet reaping) |
TAILSCALE_AUTH_KEY | the droplet’s cloud-init tailscale up |
TS_CI_AUTHKEY | CI’s own tailnet join, for the health check |
TS_API_CLIENT_ID / TS_API_CLIENT_SECRET | reaping the stale tailnet node |
GHCR_PULL_TOKEN | docker login ghcr.io on the droplet |
STAGING_SECRET_BASE | Rails SECRET_KEY_BASE for staging |
OTEL_LOGS_EXPORTER_ENDPOINT / _BEARER_TOKEN, SENTRY_DSN_BACKEND | optional observability |
Where secrets end up that they shouldn’t
Section titled “Where secrets end up that they shouldn’t”The three env vars the deploy forgets
Section titled “The three env vars the deploy forgets”RAILS_MASTER_KEY, API_KEYS, and APP_HOST are all consumed by the app and none appear in
cloud-init.yaml.tftpl. On a stock droplet:
- the REST API 401s on everything (
API_KEYSempty), - every MCP OAuth callback URL points at
localhost:3000(APP_HOSTdefaults there), - anything reading Rails encrypted credentials (
mcp_secrets,mcp_oauth_clients) fails.
Managed Postgres (production)
Section titled “Managed Postgres (production)”Not created by Terraform — referenced as a read-only data source. Per
infra/terraform/data-stores/README.md, it must already exist:
- Cluster
zimmer-production-pg, PG16,db-s-1vcpu-1gb, 1 node,nyc3 - User
doadmin - A tag-scoped firewall allowing the
zimmer-productiontag - Two databases:
zimmer_productionandzimmer_production_cable— both must pre-exist. The second is Action Cable’s, viasolid_cable.
The app connects over the cluster’s private_host with sslmode=require.
Tailscale ACLs
Section titled “Tailscale ACLs”The droplet joins the tailnet with --hostname zimmer (or zimmer-staging) and --ssh. MagicDNS then
gives you http://zimmer.
Ephemeral Terraform state
Section titled “Ephemeral Terraform state”There is no backend block. State lives on the CI runner and evaporates.
The deploy compensates by hand-reaping the droplet and firewall through the DigitalOcean API before
every apply. A project is account-unique and would 409 on a re-run, which is why manage_project
defaults to false. (Terraform no longer manages a DNS record at all — the domain-cert workflow owns
the A record now, out of band.)
Terraform can never converge, and terraform destroy can never work properly. This is a known,
accepted trade-off for a single-operator staging environment, documented in the README. It would not
survive a second operator.
Hostname stability
Section titled “Hostname stability”scripts/tailnet-reap-node.sh deletes the stale tailnet node so the MagicDNS name doesn’t drift to
zimmer-staging-1, -2, …