Skip to content

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:

  • base config is marked inheritable = true (managed in doppler.tf).
  • Each env config (oep-stg, mansety-prd, us-prd) declares inherits = ["oep.base"].
  • Secrets in base are 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-prd AWS SSM dump)
  • soa/us.env - from operator / 1Password vault (us-prd AWS SSM dump)
  • soa/oep-stg.env - rendered from unipuka-infra/oep-staging/ssm_parameters.tf local.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.