Skip to content

Provisioning and secrets

Non-secret (set in staging.tfvars / production.tfvars):

VariableNotes
environmentvalidated staging | production
region / droplet_sizedefault nyc3 / s-2vcpu-4gb
image_refdefault 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_projectfalse 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_usernamedefault 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.

SecretUsed by
DIGITALOCEAN_ACCESS_TOKENdeploy + teardown (droplet reaping)
TAILSCALE_AUTH_KEYthe droplet’s cloud-init tailscale up
TS_CI_AUTHKEYCI’s own tailnet join, for the health check
TS_API_CLIENT_ID / TS_API_CLIENT_SECRETreaping the stale tailnet node
GHCR_PULL_TOKENdocker login ghcr.io on the droplet
STAGING_SECRET_BASERails SECRET_KEY_BASE for staging
OTEL_LOGS_EXPORTER_ENDPOINT / _BEARER_TOKEN, SENTRY_DSN_BACKENDoptional observability

Where secrets end up that they shouldn’t

Section titled “Where secrets end up that they shouldn’t”

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_KEYS empty),
  • every MCP OAuth callback URL points at localhost:3000 (APP_HOST defaults there),
  • anything reading Rails encrypted credentials (mcp_secrets, mcp_oauth_clients) fails.

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-production tag
  • Two databases: zimmer_production and zimmer_production_cable — both must pre-exist. The second is Action Cable’s, via solid_cable.

The app connects over the cluster’s private_host with sslmode=require.

The droplet joins the tailnet with --hostname zimmer (or zimmer-staging) and --ssh. MagicDNS then gives you http://zimmer.

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.

scripts/tailnet-reap-node.sh deletes the stale tailnet node so the MagicDNS name doesn’t drift to zimmer-staging-1, -2, …