Populate Doppler¶
Runbook for populating the oep Doppler project with app secrets after infrastructure provisioning.
Implements UNI-54 (BD-1.5).
3-way ownership¶
All secrets in Doppler follow a 3-class ownership model. Each class has a different mechanism and different "source of truth":
| Class | Examples | Owner | Mechanism | Where set |
|---|---|---|---|---|
| Class 1: TF-generated | DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_DATABASE, REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, AWS_BUCKET, AWS_PUBLIC_BUCKET |
Terraform | doppler_secret resources in doppler_tenant_secrets.tf. TF pushes from module.tenant[*] outputs. Auto-syncs on DO rotation events. TF wins on drift. |
per-env Doppler configs (oep-stg, mansety-prd, us-prd) |
| Class 2: TF-consumer creds | DIGITALOCEAN_TOKEN, CLOUDFLARE_API_TOKEN, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_ENDPOINT, AWS_DEFAULT_REGION, SENTRY_AUTH_TOKEN, ARGOCD_GITHUB_APP_* |
Operator | doppler secrets set KEY='value' --project oep --config base. Source: 1Password / rotate-at-source. |
Doppler base |
| Class 3: App-runtime | APP_KEY, APP_NAME, APP_URL, REVERB_APP_*, SENTRY_LARAVEL_DSN, TYPESENSE_*, FIREBASE_CREDENTIALS, TWILIO_*, VAPID_*, etc. |
Operator | doppler secrets upload <env>.env bulk import. Future edits via Doppler UI. |
per-env (tenant-specific) OR base (truly shared) |
Class 1¶
Class-1 values already live in TF state (DO Managed MySQL/Redis/Spaces outputs). Adding
doppler_secret resources reading from those outputs adds zero new state surface.
Do NOT edit Class-1 keys in the Doppler UI - they will be reverted on the next terraform apply.
For emergency rotation: rotate at the DO side (DO console or doctl) then run terraform apply.
Class 2¶
Set via CLI from operator laptop. Source values from 1Password vault or rotate in the upstream service (DO console, Cloudflare dashboard, Sentry, etc.) then update Doppler:
export DOPPLER_TOKEN="$(op item get 'Doppler Admin Token' --field credential)"
doppler secrets set DIGITALOCEAN_TOKEN='dop_v1_...' --project oep --config base
doppler secrets set CLOUDFLARE_API_TOKEN='...' --project oep --config base
# etc.
Class 3¶
Bulk-import from local .env files (see Bulk upload below).
Future per-key edits: use the Doppler dashboard UI or doppler secrets set KEY='value' --config <env>.
Doppler "Action Required" on the base config¶
After the first terraform apply, Doppler shows 10 "Action Required" secrets on the base config:
DB_HOST,DB_PORT,DB_USERNAME,DB_PASSWORD,DB_DATABASE,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,AWS_BUCKET,AWS_PUBLIC_BUCKET
This is expected. These are Class-1 keys that Terraform pushes to the per-env configs (oep-stg, mansety-prd, us-prd) only — not to base. Doppler's "sync gap" detector flags any key that exists in a child config but not in the parent.
They must NOT be added to base: each tenant has a different value, so no shared base value makes sense.
Action: click Dismiss All once in the Doppler dashboard (oep project → base config → "Action Required" section). This is a one-time UI action — the Doppler Terraform provider does not expose sync-gap dismissal.
Config Inheritance¶
The oep Doppler project uses Config Inheritance:
baseconfig is markedinheritable = true(managed indoppler.tf).- Each env config (
oep-stg,mansety-prd,us-prd) declaresinherits = ["oep.base"]. - Secrets in
baseare automatically visible in all env configs without reference lines. - A per-env config can override a base key by setting the same key name locally (local wins).
Override precedence: local config > first inherited > second inherited > ...
This means:
- Class-2 keys (in
base) are auto-inherited by all tenant env configs. - Class-3 keys promoted to
base(shared app constants identical across tenants) are also auto-inherited. - Per-tenant Class-3 keys are set in the individual env config.
When you download secrets for a deployment (doppler secrets download --config oep-stg),
the flat merged result includes base-inherited + local keys.
Class 1: TF push¶
Class-1 keys are pushed automatically on every terraform apply via doppler_secret resources
in oep-infra/doppler_tenant_secrets.tf.
To run after initial infrastructure provisioning:
cd unipuka-infra-do/oep-infra
export DOPPLER_TOKEN="$(op item get 'Doppler Admin Token' --field credential)"
export TF_VAR_doppler_admin_token="$DOPPLER_TOKEN"
doppler run --project oep --config base -- terraform apply
Verify per-tenant keys in Doppler UI: DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD,
DB_DATABASE, REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, AWS_BUCKET, AWS_PUBLIC_BUCKET
should show "set by API" in each of the 3 env configs.
Class 2: Operator set in base¶
Run once after initial provisioning (values sourced from terraform.tfvars or 1Password):
export DOPPLER_TOKEN="$(op item get 'Doppler Admin Token' --field credential)"
doppler secrets set DIGITALOCEAN_TOKEN='...' --project oep --config base
doppler secrets set CLOUDFLARE_API_TOKEN='...' --project oep --config base
# SPACES_* = canonical source of truth. Rotate these; AWS_* reference them automatically.
doppler secrets set SPACES_ACCESS_KEY_ID='DO...' --project oep --config base
doppler secrets set SPACES_SECRET_ACCESS_KEY='...' --project oep --config base
# AWS_* = Doppler references for S3 backend + Laravel S3 driver compatibility.
doppler secrets set AWS_ACCESS_KEY_ID='${SPACES_ACCESS_KEY_ID}' --project oep --config base
doppler secrets set AWS_SECRET_ACCESS_KEY='${SPACES_SECRET_ACCESS_KEY}' --project oep --config base
doppler secrets set AWS_ENDPOINT='https://ams3.digitaloceanspaces.com' --project oep --config base
doppler secrets set AWS_DEFAULT_REGION='ams3' --project oep --config base
doppler secrets set SENTRY_AUTH_TOKEN='sntrys_...' --project oep --config base
doppler secrets set ARGOCD_GITHUB_APP_ID='...' --project oep --config base
doppler secrets set ARGOCD_GITHUB_APP_INSTALLATION_ID='...' --project oep --config base
# Multi-line PEM: pipe via stdin
cat /path/to/argocd-github-app.pem | doppler secrets set ARGOCD_GITHUB_APP_PRIVATE_KEY \
--project oep --config base
Class 3: Bulk upload¶
Prerequisite - prepare .env files¶
Obtain the per-tenant .env source files:
soa/mansety.env- from operator / 1Password vault (mansety-prdAWS SSM dump)soa/us.env- from operator / 1Password vault (us-prdAWS SSM dump)soa/oep-stg.env- rendered fromunipuka-infra/oep-staging/ssm_parameters.tflocal.envsMap(no live AWS oep-staging; values may need adjustment post-import)
Strip Class-1 + Class-2 keys from each file before upload (these are managed by TF or in base):
CLASS12_PATTERN='^(DB_HOST|DB_PORT|DB_USERNAME|DB_PASSWORD|DB_DATABASE|REDIS_HOST|REDIS_PORT|REDIS_PASSWORD|AWS_BUCKET|AWS_PUBLIC_BUCKET|SPACES_ACCESS_KEY_ID|SPACES_SECRET_ACCESS_KEY|AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_DEFAULT_REGION|AWS_ENDPOINT|DIGITALOCEAN_TOKEN|CLOUDFLARE_API_TOKEN|SENTRY_AUTH_TOKEN|ARGOCD_GITHUB_APP_ID|ARGOCD_GITHUB_APP_INSTALLATION_ID|ARGOCD_GITHUB_APP_PRIVATE_KEY)='
# Create stripped working copies in /tmp
grep -Ev "$CLASS12_PATTERN" soa/oep-stg.env | grep -v '^#' | grep -v '^$' > /tmp/oep-stg.do.env
grep -Ev "$CLASS12_PATTERN" soa/mansety.env | grep -v '^#' | grep -v '^$' > /tmp/mansety.do.env
grep -Ev "$CLASS12_PATTERN" soa/us.env | grep -v '^#' | grep -v '^$' > /tmp/us.do.env
# Verify: should return 0 for each
for f in /tmp/oep-stg.do.env /tmp/mansety.do.env /tmp/us.do.env; do
leaked=$(grep -cE "$CLASS12_PATTERN" "$f" || true)
[ "$leaked" = "0" ] && echo "$f: clean" || echo "$f: LEAKED $leaked keys"
done
Snapshot (rollback insurance)¶
Always snapshot before uploading to avoid data loss:
export DOPPLER_TOKEN="$(op item get 'Doppler Admin Token' --field credential)"
for cfg in base oep-stg mansety-prd us-prd; do
doppler secrets download --project oep --config "$cfg" --format=json --no-file \
> "/tmp/doppler-backup-${cfg}-$(date +%s).json"
done
ls -1 /tmp/doppler-backup-*.json # should show 4 files
Upload¶
# Upload base Class-3 shared keys (if any not already in base from Class-2 seeding)
# doppler secrets upload --project oep --config base /tmp/base.env
# Upload per-tenant Class-3 keys
doppler secrets upload --project oep --config oep-stg /tmp/oep-stg.do.env
doppler secrets upload --project oep --config mansety-prd /tmp/mansety.do.env
doppler secrets upload --project oep --config us-prd /tmp/us.do.env
No reference-script step needed. Config Inheritance handles the base merge transparently.
Rollback¶
Restore a config from a JSON snapshot taken before the upload:
export DOPPLER_TOKEN="$(op item get 'Doppler Admin Token' --field credential)"
# Identify the snapshot
SNAPSHOT="/tmp/doppler-backup-oep-stg-1234567890.json"
# Upload JSON snapshot directly — preserves newlines and special chars (e.g. PEM keys).
doppler secrets upload --project oep --config oep-stg "$SNAPSHOT"
Where Class-3 source .env files come from¶
| Tenant | Source |
|---|---|
mansety-prd |
Operator laptop - dump from AWS SSM Parameter Store (/soa/production/) or 1Password |
us-prd |
Operator laptop - dump from AWS SSM Parameter Store (/soa/production/) or 1Password |
oep-stg |
Rendered from unipuka-infra/oep-staging/ssm_parameters.tf local.envsMap. File: soa/oep-stg.env (gitignored). No live AWS oep-staging; adjust dynamic values (REVERB_APP_SECRET, FIREBASE_CREDENTIALS, etc.) before upload. |