Back to path
IntermediateBeacon · Project 7 of 12 ~7h· 6 milestones

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.

Twelve-factor config separation across environmentsSOPS encryption with age for secrets-in-gitDelivery-time secret decryption and env injection in CIEnvironment parity enforcement with schema checksSecret rotation procedures and runbooksPlaintext-secret prevention with gitleaks in CI

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:

libs/config.pypython
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. 1

    Make config twelve-factor and per-environment

    4 guided steps

    Hand-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. 2

    Encrypt per-env secrets with SOPS and age

    4 guided steps

    Secrets 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. 3

    Decrypt at deploy time and inject env in the pipeline

    4 guided steps

    This 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. 4

    Enforce environment parity with a CI check

    4 guided steps

    Parity 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. 5

    Block plaintext secrets in CI with gitleaks

    4 guided steps

    The 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. 6

    Run a key rotation and write the runbook

    4 guided steps

    A 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

3 starter files, ready to clone
6 guided milestones
6 full reference solutions
9 code blocks explained line-by-line
6 "is it working?" checks
4 interview questions it prepares you for

You'll walk away with

A twelve-factor config layout: schema.env contract plus per-env dev/staging/prod plaintext config files, with libs/config.py reading strictly from environment variables and failing fast
SOPS-encrypted secrets.<env>.enc.yaml for every environment plus .sops.yaml whose encrypted_regex covers _URL, so DATABASE_URL and REDIS_URL passwords are ciphertext, with all plaintext provider keys removed from the working tree
A deploy workflow driven by the workflow_dispatch input deploy_env that loads the age key from the CI secret store, decrypts only the target environment's secrets at deploy time, and injects masked env into api and worker
A secret-scan workflow (gitleaks plus the committed scripts/check-sops-encrypted.sh assertion) and scripts/check-parity.sh that fails on missing OR extra keys, both required on every pull request
A tested RUNBOOK-rotation.md documenting the staged SMS provider key rotation using gh workflow run deploy.yml -f deploy_env=<env> and the separate age-key re-key procedure

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