Manage config and secrets across environments
Continues from the last build: You promote one immutable artifact through every environment, but each environment's config is hand-edited at deploy time and a provider API key is still sitting in plaintext in the repo.
Last rung you nailed the build-once promotion: the exact same api and worker image flows from dev to staging to prod, tagged by git short SHA.
What you'll build
You will turn it from a pile of snowflake config into a disciplined, twelve-factor delivery system. Config becomes per-environment files with a shared schema and enforced parity, so dev, staging, and prod differ only in values, never in shape. Every secret leaves the repo: per-env secrets, including the DATABASE_URL and REDIS_URL that embed passwords, are SOPS-encrypted with age, committed safely as ciphertext, and decrypted by the pipeline at deploy time using a key the pipeline alone holds, then injected as environment variables into api and worker. You will rotate the SMS provider key end to end, and you will add a gitleaks CI gate plus a SOPS encryption check that fails any pull request carrying a plaintext credential, so the repo can never regress.
See how we teach, before you sign up
You don't just get code dumped on you. Every starter file and every solution is explained line-by-line, in plain English. Here's one real file from this project:
import os
REQUIRED = [
"DATABASE_URL",
"REDIS_URL",
"SMS_PROVIDER_API_KEY",
"SMTP_PASSWORD",
]
def load_config() -> dict:
missing = [k for k in REQUIRED if not os.environ.get(k)]
if missing:
raise RuntimeError(f"missing required env: {missing}")
return {
"database_url": os.environ["DATABASE_URL"],
"redis_url": os.environ["REDIS_URL"],
"sms_api_key": os.environ["SMS_PROVIDER_API_KEY"],
"smtp_password": os.environ["SMTP_PASSWORD"],
"worker_concurrency": int(os.environ.get("WORKER_CONCURRENCY", "4")),
}
Reading this file
REQUIRED = [The contract: these keys must be present as env in every environment, or the service refuses to start (fail fast).missing = [k for k in REQUIRED if not os.environ.get(k)]Fail-fast parity at runtime: a missing key crashes on boot instead of failing a notification at 3am.os.environ["SMS_PROVIDER_API_KEY"]The secret arrives only as env, injected by the pipeline after decryption. It is never read from a committed file.int(os.environ.get("WORKER_CONCURRENCY", "4"))Non-secret tunable with a default: this is config, varies per env, lives in the plaintext dev.env/prod.env files.
Twelve-factor loader. Reads strictly from environment variables, never from a file path baked into the image, so the same image behaves per env purely by injected env.
That's 1 of 9 explained code blocks in this single project.
The build, milestone by milestone
- 1
Make config twelve-factor and per-environment
4 guided stepsHand-editing manifests at deploy time is the root cause of snowflake environments. Twelve-factor config (strict env separation) means the artifact is identical everywhere and only the injected env changes, which is what makes promotion trustworthy.
- 2
Encrypt per-env secrets with SOPS and age
4 guided stepsSecrets in git are a permanent liability; even after deletion they live in history. SOPS lets you keep secrets in the repo as ciphertext (versioned, reviewable, per-env) while the plaintext only ever exists where the private key is, which here is the pipeline. A regex that misses _URL would leave DB passwords in cleartext inside a file named encrypted, which is the worst kind of false safety.
- 3
Decrypt at deploy time and inject env in the pipeline
4 guided stepsThis is the heart of delivery-time secret handling: the repo and image stay secret-free, and the pipeline is the only place that turns ciphertext into plaintext, right before injecting it. No human and no committed file ever holds the prod plaintext.
- 4
Enforce environment parity with a CI check
4 guided stepsParity is the discipline that makes promotion safe: if staging defines a key prod lacks, or carries a stray key prod does not, the same image behaves differently across environments. A mechanical check that flags both missing and extra keys turns an invisible class of incidents into a failed PR.
- 5
Block plaintext secrets in CI with gitleaks
4 guided stepsThe new-hire incident happened because nothing checked. A scanning gate makes the failure mode loud and pre-merge: a plaintext key fails the PR instead of shipping. The SOPS-encryption assertion, kept in its own committed script, stops the subtler mistake of committing a secrets file that was never encrypted.
- 6
Run a key rotation and write the runbook
4 guided stepsA secret you cannot rotate is a secret you do not control. The plaintext key was exposed in Slack threads, so it must be rotated, and the team needs a tested, written procedure so rotation is routine rather than a panic during an incident.
What's inside when you start
You'll walk away with
This is portfolio-grade. Build it free.
Sign up to unlock every milestone step-by-step, the code skeletons, full reference solutions, and checkable tasks, with your progress saved as you build.
Start building