DevOps Lesson 7 of 56

Secrets & Configuration Management, In Depth: 12-Factor Config, Secret Stores & Rotation

Every breach post-mortem you will ever read has the same paragraph buried in it: a credential that should never have been reachable was reachable. A database password in a config file checked into Git. An AWS access key pasted into a CI variable five years ago and never touched since. A .env file emailed between two developers and now sitting in three inboxes. Secrets and configuration management is not a glamorous topic — there is no shiny dashboard, no demo that makes a room gasp — but it is the single discipline that separates teams who get breached from teams who do not. Get it wrong and the most sophisticated firewall, the tightest network policy and the best-paid security team are all bypassed by one leaked string.

This lesson is the secure-by-default foundation. It is deliberately the basics done properly, before the course’s advanced lesson on Vault dynamic secrets takes you further. By the end you will be able to draw the precise line between configuration (which is fine to commit) and secrets (which are never), apply the 12-factor “config in the environment” rule and know exactly where each kind of config should live, recognise and avoid the cardinal sin — secrets in Git — including how leaks happen, how to detect them with scanners like gitleaks and trufflehog, and the one thing you must do first after a leak (rotate, not delete). You will be able to compare the major secret stores — HashiCorp Vault, AWS Secrets Manager and SSM Parameter Store, Azure Key Vault, GCP Secret Manager, and the GitOps-native options SOPS and Sealed Secrets — and pick the right one. You will know the four injection patterns that get a secret into a running workload and their trade-offs, how to mask secrets in CI logs, and finally how rotation, short-lived dynamic credentials and OIDC together let you delete static cloud keys altogether. Throughout, one principle recurs: the only secret that can never leak is the one that does not exist — and the whole modern playbook is about getting as close to that ideal as you can.

A note on the examples below. Because this is teaching material, every credential, key and token you see is a deliberately obvious fake — <REDACTED>, EXAMPLE_KEY, dummy-not-a-real-secret. Never copy a realistic-looking value from a tutorial into anything; treat every example secret as poison.

Learning objectives

After working through this lesson you will be able to:

Prerequisites

You need only a working mental model of how software is built and deployed: that an application reads some settings at start-up, that it runs in different environments (your laptop, staging, production), that code lives in a Git repository, and that a CI/CD pipeline builds and ships it. No prior security specialism is assumed and every term is defined as it appears. This lesson sits in the Fundamentals strand of the DevOps Zero-to-Hero ladder, immediately after Observability Fundamentals (observability-fundamentals-logs-metrics-traces-slo-devops) and before Testing in CI (testing-in-ci-test-pyramid-coverage-quality-gates). It is the conceptual on-ramp to the course’s advanced credential lessons — Dynamic Secrets with Vault (vault-dynamic-secrets-cicd-short-lived-credentials) and OIDC keyless deploys (github-actions-oidc-keyless-deploys-multi-cloud) — which assume you already know everything below. If you have read the GitHub Actions fundamentals lesson you will recognise the secrets-and-variables model that reappears here in its general form.

Core concepts: configuration versus secrets

The whole discipline starts with one distinction, and most mistakes are a failure to make it. Configuration is everything about your application’s behaviour that varies between deployments but is not sensitive — a log level, a feature flag, a page size, the public URL of a downstream API. Secrets are the subset of configuration that grants access or proves identity and would cause harm if disclosed — a database password, an API key, a private signing key, a TLS certificate’s private key, an OAuth client secret.

The clean test is a single question: “If this value leaked to a stranger on the internet, would anything bad happen?” If the answer is “no, it is just a number or a public address”, it is configuration. If the answer is “they could read our data, spend our money, or impersonate us”, it is a secret. A second, sharper test for the grey cases: “Can I print this in a build log without worrying?” Configuration can; a secret cannot, which is exactly why CI systems mask secrets and never mask config.

Aspect Configuration Secret
Examples Log level, feature flags, page size, public API URL, region, timeouts DB password, API key, private key, TLS key, OAuth client secret, token
Sensitivity Non-sensitive — disclosure is harmless Sensitive — disclosure causes harm (data, money, identity)
The leak test “Could print it in a log” “Must never appear in a log”
Where it may live Repo (per-env files), env vars, config service Secret store only — never the repo
Versioning in Git Fine, even encouraged Forbidden in plaintext (encrypted-at-rest patterns excepted, see SOPS)
Rotation Rarely needed Routine and after any suspected exposure
Access control Usually open to the team Least-privilege, audited, time-bound
In CI logs Visible Masked / redacted

Two corollaries fall out of this table and they govern everything that follows. First, configuration and secrets are managed by different machinery: config can sit in the repository in per-environment files because it is harmless; secrets must live in a dedicated, access-controlled, audited store and be fetched at deploy or run time. Second, the boundary occasionally blurs — a connection string contains both a harmless host (config) and a password (secret). The correct move is to split it: keep the host in config and the password in the secret store, then compose the connection string at run time. Never let one secret field drag an entire structured blob into the secret store or, worse, an entire secret into the repo.

The 12-factor rule: store config in the environment

The most influential single piece of guidance here is Factor III of the Twelve-Factor App methodology: “Store config in the environment.” The reasoning is precise and worth understanding rather than memorising. The Twelve-Factor authors define config as everything that varies between deploys (staging vs production vs a developer’s laptop) and observe that such config must be strictly separated from code, because code is identical across all deploys while config is what differs. Their litmus test is memorable: could you open-source the codebase this minute without exposing a single credential? If yes, your config is properly externalised; if no, secrets are baked into the code and you have failed Factor III.

Why environment variables specifically, rather than a config file checked into the repo? Three reasons. They are language- and OS-agnostic — every runtime can read an environment variable, so the same mechanism works for a Node service, a Go binary and a shell script. They are unlikely to be committed accidentally, because they live in the process environment, not in a file sitting in your working tree begging to be git add-ed. And they cleanly support the core requirement: the same built artifact runs in every environment, configured only by its surroundings. You build the container image once; staging and production differ only in the environment handed to that identical image.

It is worth stating the nuances and the honest criticisms, because senior engineers know that 12-factor is a guideline, not scripture:

So the rule, updated for 2026, is: store configuration in the environment; store secrets in a secret store and inject a reference. Both honour the deeper principle — config (and secrets) must never be hard-coded into the image — which is the part that truly matters.

Where configuration lives: the options and their trade-offs

There is no single place config belongs; there is a layering of places, each suited to different kinds of values. Understanding the menu lets you put each value where it costs least and risks least.

Location What it suits Pros Cons / gotchas
Environment variables Small, flat, per-deploy values; the 12-factor default Universal, simple, no file to commit, swappable per environment Flat/stringly-typed; visible to the process tree; not encrypted; awkward for large/structured config
Config files in the repo (per-env: config.dev.yaml, config.prod.yaml) Non-sensitive, structured, version-controlled config Reviewable, diffable, structured, history; lives with the code Never put secrets here; risk of editing the wrong env file; needs an overlay/merge strategy
Config files not in the repo (mounted at deploy) Larger or env-specific config you do not want in Git Keeps env-specific detail out of the repo Must be provisioned separately; drift risk; where did the canonical copy go?
Config service / app-config store (AWS AppConfig, Azure App Configuration, Spring Cloud Config, Consul KV, etcd) Dynamic config, feature flags, runtime changes without redeploy Central, audited, can change at runtime, supports flags & gradual rollout Extra dependency; a runtime call to fetch config; needs its own access control; can hide config from the repo
Secret store (Vault, cloud manager, etc.) Secrets only Encrypted, access-controlled, audited, rotatable Never use it as a general config dump; latency and a dependency at fetch time
Command-line flags / args One-off overrides, scripts Explicit, no file Appear in process lists (ps) and shell history — never pass secrets as args

Layered on top of where is how you vary config per environment — the overlay pattern. The standard approach is a base configuration plus per-environment overrides that are merged at deploy time: a base.yaml with everything common, then prod.yaml / staging.yaml containing only the deltas. Kubernetes formalises this with Kustomize (a base/ and per-environment overlays/), and Helm does it with a base values.yaml plus values-prod.yaml. The discipline is the same everywhere: common config lives once; only the differences are per-environment; and secrets are never part of any of these files — they are referenced and injected separately. A frequent and dangerous mistake is to copy the whole config file per environment and edit each copy, which guarantees drift and, eventually, someone pasting a production secret into the file “just for now”.

The cardinal sin: secrets in Git

If you remember one thing from this lesson, remember this section. Committing a secret to a Git repository is the single most common and most damaging mistake in the whole discipline. It deserves the label cardinal sin because of a property of Git that beginners consistently underestimate: Git never forgets.

Why it is so much worse than it looks

When you commit password = dummy-not-a-real-secret and later “fix” it by editing the file and committing again, the secret is still in the repository — it lives in the history, reachable by git log -p, git show <old-commit>, or simply by checking out the earlier commit. Deleting the file in a new commit does nothing; the blob containing the secret is still an object in the .git database and in every clone anyone ever made. The exposure is therefore permanent and distributed:

The two classic vectors are worth naming. The first is .env files: a developer creates .env with real credentials for local development, the project’s .gitignore is missing the entry (or the file was force-added with git add -f), and it sails into the repo. The second is hard-coded constants: const API_KEY = "EXAMPLE_KEY" written “temporarily” during a spike and never removed. Config files (application.yml, settings.py, Terraform .tfvars) with inline credentials are the third.

Prevention: stop the secret reaching the commit

Defence is layered, and the cheapest layer is the one closest to the developer:

  1. .gitignore the obvious files. Always ignore .env, *.pem, *.key, *.p12, credentials, *.tfvars (unless you know they are non-secret), and IDE/secret folders. This is necessary but not sufficient.gitignore only stops untracked files, does nothing for a value pasted into a tracked file, and is bypassed by git add -f.
  2. Use a .env.example / .env.template committed to the repo with the keys but no values (DATABASE_PASSWORD=), so newcomers know what to set without any real secret being present.
  3. Pre-commit scanning. Run a secret scanner as a pre-commit hook so a commit containing a credential pattern is blocked before it is ever created. This is the highest-leverage control because it stops the leak at source, on the developer’s own machine.
  4. Server-side / CI scanning. Because pre-commit hooks can be skipped (git commit --no-verify), back them with a CI job and, where available, provider-side push protection (GitHub Secret Scanning push protection rejects a push that contains a recognised credential pattern). Defence in depth: local hook and server-side gate.

Detection: gitleaks and trufflehog

Two open-source scanners dominate. gitleaks is a fast, regex/entropy-based scanner that can scan the working tree, the staged diff, or the entire history. trufflehog goes a step further: as well as pattern-matching, it can verify found credentials by attempting a live, read-only API call, so it tells you not just “this looks like an AWS key” but “this AWS key is active right now” — which sharpens triage enormously.

Scan a whole repository’s history with gitleaks:

# Scan all commits in the current repo for secrets in history
gitleaks detect --source . --report-format json --report-path gitleaks-report.json

# Scan only what is staged, for use in a pre-commit hook (fast, blocks the commit)
gitleaks protect --staged --verbose

Scan a repository’s full history with trufflehog and verify which secrets are live:

# Deep scan of git history; --only-verified shows only credentials confirmed active
trufflehog git file://. --only-verified

Wire gitleaks into pre-commit so every commit is checked locally:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0          # pin a tag; review before bumping
    hooks:
      - id: gitleaks
pip install pre-commit        # or: brew install pre-commit
pre-commit install            # installs the git hook into .git/hooks
# now every `git commit` runs gitleaks against the staged changes

And as a CI gate in GitHub Actions, so a skipped local hook still gets caught:

# .github/workflows/secret-scan.yml
name: secret-scan
on: [push, pull_request]
permissions:
  contents: read
jobs:
  gitleaks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0           # full history so the scan sees every commit
      - uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

After a leak: the procedure (rotate FIRST)

This is the part people get wrong under pressure, so internalise the order. The instant a secret is exposed it must be treated as compromised — assume an attacker already has it. The reflex to “just delete the commit” is the wrong first move, because rewriting history takes time, may fail to reach every clone, and does nothing about the fact that the secret is already out. The correct sequence:

  1. Rotate (or revoke) the credential immediately — this is step one, full stop. Generate a new password/key, deploy it, and invalidate the old one at the provider. The moment the old credential is revoked, the leaked copy is worthless and the clock you were racing stops. Everything else is cleanup. Rotation, not deletion, neutralises the threat.
  2. Investigate exposure / blast radius. Was the repo public? For how long? Check provider access logs and audit trails for any use of the credential during the exposure window. Decide whether this is a near-miss or an active incident.
  3. Purge from history (cleanup, not the fix). Now, and only now, remove the secret from Git history with git filter-repo (the modern, recommended tool) or the BFG Repo-Cleaner, then force-push and ask collaborators to re-clone. Understand its limits: forks, cached views and existing clones may still hold the blob — which is exactly why rotation came first.
  4. Prevent recurrence. Add/repair the .gitignore entry, install the pre-commit hook and CI scanner if they were missing, and move the secret into a proper secret store so it is never a literal value in a file again.
  5. Document. A short, blameless write-up: what leaked, the exposure window, what was rotated, and the control added so it cannot recur.
# Step 3 (cleanup AFTER rotation): scrub a file from all history
pip install git-filter-repo
git filter-repo --path path/to/leaked-file --invert-paths
git push --force --all     # then have everyone delete their clone and re-clone

The mantra to carry out of here: rotate first, scrub second, prevent third. Anyone who reaches for filter-repo before rotating has misordered the emergency.

Secret stores: the comparison

Once you accept that secrets must not live in the repo or be hard-coded, they have to live somewhere purpose-built. A secret store (or secrets manager) is a service that stores secrets encrypted at rest, enforces fine-grained access control, audits every access, supports versioning and rotation, and hands secrets to authorised workloads at run time. There are five families you will meet; the table below is the one to study.

Store Type Native to Encryption / model Rotation Dynamic secrets Best for Cost model & gotcha
HashiCorp Vault Self-hosted / HCP Cloud-agnostic KV v2 + many engines; per-secret ACL policies Yes (engine-driven) Yes — generates short-lived DB/cloud/PKI creds on demand Multi-cloud, on-prem, hybrid; teams wanting dynamic secrets and one tool everywhere You operate it (unless HCP); steepest learning curve; seal/unseal & HA to run
AWS Secrets Manager Managed AWS KMS-encrypted; IAM policies Built-in with Lambda rotation (esp. RDS) Limited (rotation, not Vault-style dynamic) AWS-centric apps wanting managed rotation Priced per secret per month + per 10k API calls; cost adds up with many secrets
AWS SSM Parameter Store Managed AWS Standard (free) or SecureString (KMS) tiers Manual / via automation No AWS apps; cheap config + light secrets Standard tier is free; great for config, lighter secret features than Secrets Manager
Azure Key Vault Managed Azure HSM-backed option; RBAC or access policies Yes (with Event Grid / rotation policies) No (cloud-native, not dynamic engines) Azure apps, certificates and keys as well as secrets Priced per operation; Standard vs Premium (HSM); soft-delete/purge-protection to understand
GCP Secret Manager Managed Google Cloud Google-managed or CMEK; IAM Rotation notifications; you rotate No GCP apps; simple, versioned secrets with IAM Priced per secret version + access ops; regional vs automatic replication choice
SOPS (Mozilla) File-encryption tool Cloud-agnostic (uses KMS/age/PGP) Encrypts values in YAML/JSON/env files; keys via KMS/age Manual (re-encrypt) No GitOps — keep encrypted secrets in Git safely Not a server; you manage keys; whole-history of encrypted blobs is in Git (rotate keys carefully)
Sealed Secrets (Bitnami) Kubernetes controller Kubernetes Asymmetric: encrypt with cluster’s public key, only the controller can decrypt Re-seal on key rotation No GitOps on Kubernetes — commit a SealedSecret CRD safely Cluster-scoped; losing the controller’s private key loses decryptability; K8s-only

A few decision rules cut through the table:

The unifying point: the store is not where you achieve security; the store is where you achieve control — encryption at rest, who-can-read policies, an audit log, versioning and rotation. Choosing one is choosing how you will control secrets, not whether you will have them.

Injecting secrets: the four patterns

Storing a secret safely is half the job; getting it into a running workload without re-exposing it is the other half. There are four patterns, and they trade convenience against blast radius.

Pattern How it works Pros Cons / risk When to use
Environment variable Secret is set as an env var in the process (often from a store at deploy) Simplest; every language reads it; 12-factor-friendly Visible to the whole process tree, crash dumps, /proc/<pid>/environ, careless logging; static for the process lifetime Simple apps; secrets fetched from a store into the env, not hard-coded in a manifest
Mounted file / volume Secret written to a file the app reads (e.g. a Kubernetes Secret mounted as a volume; tmpfs/in-memory) Not in the environment; can be tmpfs (RAM-only); can update without restart; file permissions limit reach App must read a file (small code change); file perms must be tight; a readable mount is still readable Kubernetes; certificates/keys; secrets that should rotate without a redeploy
Sidecar / CSI driver A helper container or the Secrets Store CSI Driver fetches from the store and mounts it; e.g. Vault Agent sidecar Centralised fetch & refresh; app stays store-agnostic; supports rotation & short-lived creds More moving parts; sidecar lifecycle to manage; another component to secure Kubernetes at scale; Vault/cloud-store integration; auto-rotating secrets
Build-time vs run-time Build-time: secret baked during image build (anti-pattern — it is now in the image layers forever). Run-time: secret supplied only when the container runs Run-time keeps the image clean and shareable Build-time is a trap: anyone who pulls the image can extract the layer; the image becomes a secret you must guard Always prefer run-time. Use Docker BuildKit --mount=type=secret if a secret is genuinely needed during build, so it is not persisted in a layer

Two rules govern the choice. First, prefer file/volume or CSI over a raw env var for anything truly sensitive, because env vars have the widest accidental-exposure surface (logs, crash reporters, child processes). Second, never bake a secret into an image at build time. ENV API_KEY=EXAMPLE_KEY or a COPY secret.json in a Dockerfile writes the secret into an image layer that travels with every pull of that image — docker history and a layer extraction will surface it, and no later RUN rm removes it from earlier layers. If a credential is genuinely required during the build (say, to pull a private dependency), use BuildKit’s secret mount so it is present only for that step and never persisted:

# syntax=docker/dockerfile:1
FROM alpine
# Secret is available only for THIS RUN; it is never written to a layer
RUN --mount=type=secret,id=npmrc \
    cp /run/secrets/npmrc /root/.npmrc && \
    npm ci && \
    rm /root/.npmrc
# Supply the secret at build time without it landing in the image
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .

For run-time injection on Kubernetes, the modern, store-backed approach mounts secrets via the Secrets Store CSI Driver so the cluster never even holds a static Kubernetes Secret object — the driver fetches from Vault / the cloud manager and mounts the value as a file, refreshing it on rotation.

Rotation, dynamic credentials and OIDC: killing static keys

Everything above still leaves one structural weakness: a long-lived static secret that sits in a store for years is a single value whose exposure compromises everything it can reach, for as long as it remains valid. The endgame of secrets management is to shrink that window — ideally to zero. Three techniques, in increasing order of power, do this.

Rotation is the baseline: change secrets on a regular schedule and always after any suspected exposure. Rotation matters because it bounds the value of a leak — a credential that rotates every 30 days is worthless to an attacker 30 days after they stole it, even if you never noticed the theft. The hard part of rotation has always been zero-downtime changeover, and the standard solution is two active versions during the overlap: provision the new credential, deploy it everywhere, confirm everything is using it, then revoke the old one. Cloud managers automate this for common cases — AWS Secrets Manager’s built-in rotation drives a Lambda through exactly this create-new / test / promote / retire-old dance for RDS and similar — and the practice generalises: never revoke the old secret until the new one is confirmed in use.

Short-lived / dynamic credentials are rotation taken to its logical extreme: instead of a long-lived secret you rotate periodically, the consumer is issued a brand-new credential, scoped to it, that expires in minutes. The credential does not exist until it is requested and self-destructs when its short lease ends, so there is almost nothing to leak and a stolen credential is useless within minutes. This is precisely what HashiCorp Vault dynamic secrets engines do — generate a fresh database user, cloud credential or signed certificate per request — and it is the subject of the course’s advanced lesson (vault-dynamic-secrets-cicd-short-lived-credentials). The mental shift is profound: you stop storing credentials and start minting them on demand.

OIDC (OpenID Connect) federation applies the same idea to the worst class of static secret of all: the long-lived cloud access key stored in CI. The classic anti-pattern is an AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (or a service-account JSON) pasted into your CI provider’s secrets and never rotated — a permanent key to your cloud sitting in a CI system. OIDC eliminates the stored key entirely. Modern CI runners (GitHub Actions, GitLab) can mint a signed identity token describing this exact job (its repo, branch, workflow); the cloud provider is configured to trust that issuer and exchange the token for short-lived credentials. No static key is stored anywhere — the runner proves who it is with a token it generates fresh each run, and receives credentials that expire when the job ends. This is “keyless” cloud auth, and it is the single highest-impact change most teams can make to their pipeline security. Here is the shape of it in GitHub Actions for AWS (note id-token: write — the permission that lets the job request an OIDC token):

# .github/workflows/deploy.yml — keyless deploy to AWS, NO stored cloud keys
name: deploy
on: { push: { branches: [main] } }
permissions:
  id-token: write        # allow the job to request a short-lived OIDC token
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          # AWS trusts GitHub's OIDC issuer and assumes this role; no key stored
          role-to-assume: arn:aws:iam::123456789012:role/EXAMPLE-deploy-role
          aws-region: eu-west-1
      - run: aws sts get-caller-identity   # now authenticated with temp creds

The full multi-cloud treatment — trust policies, subject claims, branch/environment scoping — is the course’s dedicated lesson (github-actions-oidc-keyless-deploys-multi-cloud). The takeaway here is the principle: OIDC replaces a stored static key with a per-run, short-lived, identity-derived credential, which is the cleanest possible answer to “where do we keep the cloud key?” — you do not.

Masking secrets in CI logs

A secret you handled perfectly can still leak the moment a build step echos it or a tool prints it on error. CI systems therefore mask registered secrets — replacing them with *** wherever they would appear in log output. In GitHub Actions, anything in secrets.* is masked automatically, and you can mask a computed value with the add-mask workflow command:

- name: Use a derived secret safely
  run: |
    TOKEN=$(./make-token.sh)
    echo "::add-mask::$TOKEN"      # mask it BEFORE it can appear in any log
    ./deploy --token "$TOKEN"

Masking is a safety net, not a control — it relies on the literal string appearing, so a base64-encoded, URL-encoded or partially-printed secret can slip past it. The real defence is to never print secrets at all: avoid set -x/debug modes when secrets are in scope, do not echo them, pass them via env or stdin rather than as command-line arguments (which appear in process lists), and disable verbose modes of tools that dump configuration. Treat masking as the last line, not the plan.

Least privilege, throughout

Underpinning every technique above is the principle of least privilege: every secret should grant the minimum access needed, to the fewest identities, for the shortest time. A CI deploy credential scoped to one bucket beats an admin key; a per-environment secret beats one shared secret; a 15-minute dynamic credential beats a permanent one. Pair this with auditing — a secret store’s access log tells you who read what and when, which is both a detective control and what you reach for after an incident to scope the blast radius.

Secrets and configuration management: config-vs-secrets, the secrets-in-Git failure mode, secret stores, injection patterns, rotation and OIDC

The diagram traces a secret’s whole lifecycle — from the config/secret split, through the secret store and the four injection paths into a workload, to rotation and OIDC keyless auth — with the secrets-in-Git “cardinal sin” flagged as the path you must never take.

Hands-on lab

This lab uses only a local Git repository and free, open-source tools — gitleaks and pre-commit — so it costs nothing and touches no cloud account. You will deliberately create a (fake) leak, detect it, set up prevention, and practise the rotate-first instinct. Every “secret” is an obvious placeholder; never use a real one.

Setup

# 1. Install gitleaks and pre-commit (macOS shown; use your package manager)
brew install gitleaks pre-commit       # or: see each tool's install docs

# 2. Create a throwaway repo for the lab
mkdir secrets-lab && cd secrets-lab
git init

Step 1 — Commit a fake secret (the mistake)

# Create a config file with a CLEARLY FAKE secret (never a real one!)
printf 'db:\n  host: db.internal\n  password: dummy-not-a-real-secret\n' > config.yaml
git add config.yaml
git commit -m "Add app config"        # the secret is now in history

Step 2 — Detect it with gitleaks

gitleaks detect --source . --verbose

Expected output: gitleaks reports a finding, naming config.yaml, the rule it matched, the commit, and the line — proof the secret is now in your history, not just your working tree.

Step 3 — “Fix” it the wrong way, and see it persist

# Edit the file to remove the secret and commit
printf 'db:\n  host: db.internal\n  password: ${DB_PASSWORD}\n' > config.yaml
git commit -am "Remove hard-coded password"

# The secret looks gone... but history still has it:
git log -p -- config.yaml | grep -i "dummy-not-a-real-secret" && \
  echo ">>> The secret is STILL in history. Deleting did not fix it."

This is the lesson in your own hands: the working tree is clean, yet the secret is one git show away. In a real incident this is the moment you would have already rotated the credential.

Step 4 — Prevent recurrence (pre-commit + .gitignore)

# Ignore the usual offenders
printf '.env\n*.pem\n*.key\n' > .gitignore

# Add a pre-commit config that runs gitleaks on every commit
cat > .pre-commit-config.yaml <<'YAML'
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks
YAML

pre-commit install        # installs the local git hook

Step 5 — Confirm the gate blocks a new leak

# Try to commit a brand-new fake secret; the hook should BLOCK it
printf 'api_key: EXAMPLE_KEY_do_not_use\n' >> config.yaml
git add config.yaml
git commit -m "try to add api key"     # expect: gitleaks FAILS the commit

Expected output: the commit is rejected; gitleaks prints the finding and the hook exits non-zero, so the secret never enters even your local history. Prevention working as designed.

Step 6 — Clean up the history (the AFTER-rotation step)

# In a real incident you would have ROTATED in Step 1. This is cleanup only.
pip install git-filter-repo
git checkout config.yaml               # discard the staged fake key first
git filter-repo --path config.yaml --invert-paths --force
git log --oneline                      # config.yaml history is gone

Validation

Cleanup

cd ..
rm -rf secrets-lab          # delete the throwaway repo entirely
pre-commit uninstall 2>/dev/null || true

Cost note

Zero. Everything here is local and open-source — no cloud resources, no managed secret store, nothing billable. The only “cost” is the five minutes it takes to internalise that Git never forgets.

Common mistakes & troubleshooting

Symptom Cause Fix
Secret in repo “removed” but security still flags it Deleted in a new commit; it remains in history and in every clone Rotate the credential first, then scrub with git filter-repo/BFG; understand clones/forks may still hold it
.env keeps getting committed Missing .gitignore entry, or added with git add -f Add .env to .gitignore; commit a value-less .env.example; add a pre-commit secret scanner
Secret printed in CI logs despite “masking” Value was transformed (base64/url-encoded) or only partly printed, so the literal never matched Don’t print secrets at all; disable set -x/debug; ::add-mask:: computed values before use
Pre-commit hook skipped, secret still pushed Developer used git commit --no-verify Back the local hook with a CI scan and provider push protection — defence in depth
Secret extractable from a Docker image Baked in at build time (ENV/COPY), persisted in a layer Inject at run time; if needed during build, use BuildKit --mount=type=secret (not persisted)
Static cloud key in CI keeps triggering rotation alerts Long-lived AWS_*/service-account key stored in CI secrets Switch to OIDC keyless auth so no static key is stored at all
App can’t read the rotated secret without a restart Secret injected as an env var (fixed for process lifetime) Inject as a mounted file or via the CSI driver, which can refresh without a redeploy
Connection string (host + password) all dumped into the secret store Whole structured value treated as one secret Split: host/port in config, password in the store; compose the string at run time
Wrong environment’s config applied Per-env files copied and hand-edited, causing drift Use base + overlay (Kustomize/Helm values) so only deltas are per-environment

Best practices

Security notes

Interview & exam questions

1. What is the difference between configuration and a secret, and how do you decide? Configuration is any value that varies between deployments but is not sensitive (log level, feature flag, public URL). A secret is the subset that grants access or proves identity and would cause harm if disclosed (password, API key, private key). The test: “if this leaked, would anything bad happen?” — and the practical corollary, “could I safely print it in a log?” Config can; a secret cannot.

2. State the 12-factor rule for config and the reasoning behind it. Factor III: store config in the environment, strictly separated from code, because code is identical across deploys while config is what differs. The litmus test is whether you could open-source the codebase right now without exposing a credential. Env vars are chosen because they are language/OS-agnostic, unlikely to be committed accidentally, and let one built artifact run in every environment.

3. Why is committing a secret to Git so serious, and does deleting the file fix it? Because Git never forgets: the secret stays in history (git log -p, git show), in every clone and fork, and in caches/mirrors. Deleting the file in a new commit does not remove it from history — it is still reachable, and existing clones still hold it. You must rotate the credential and rewrite history, and even then forks/clones may retain the blob.

4. A real AWS key was just pushed to a public repo. What is your first action? Rotate/revoke the key immediately. Invalidating the credential at the provider makes the leaked copy worthless and stops the clock; everything else — investigating exposure, scrubbing history with git filter-repo, adding prevention — is cleanup that comes after. Reaching for history-rewriting before rotating is the classic misordering.

5. Name two tools that detect secrets in a repo and the key difference between them. gitleaks (fast regex/entropy scanning of working tree, staged diff, or full history) and trufflehog (also scans, but can verify a found credential by attempting a live call, so it tells you which secrets are actually active). gitleaks is the common pre-commit/CI gate; trufflehog’s verification sharpens triage.

6. Compare HashiCorp Vault with a cloud-native secrets manager. When would you choose each? Vault is cloud-agnostic, self-hosted (or HCP), and uniquely offers dynamic secrets — short-lived credentials minted on demand — making it ideal for multi-cloud/hybrid estates that want one tool and the strongest credential model. A cloud-native manager (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager) is fully managed with IAM you already use and built-in rotation for common cases; choose it when you are all-in on one cloud and want the least to operate.

7. What problem do SOPS and Sealed Secrets solve that a normal secret store does not? They make GitOps safe. GitOps wants everything declared in Git, but plaintext secrets must never be committed. SOPS encrypts the values in a YAML/JSON file (keys held in KMS/age) so the encrypted file can live in Git; Sealed Secrets (Kubernetes) lets you commit a SealedSecret CRD that only the in-cluster controller can decrypt. Both let you keep secrets in the repo encrypted, which a server-based store does not address.

8. Describe the four secret-injection patterns and which is the anti-pattern. Environment variable (simplest, widest exposure surface), mounted file/volume (out of the environment, can refresh without restart), sidecar/CSI driver (centralised fetch and rotation, e.g. Vault Agent or the Secrets Store CSI Driver), and build-time vs run-time. Build-time injection is the anti-pattern — a secret baked into an image layer ships with every pull and cannot be removed later. Always inject at run time.

9. Explain how OIDC removes the need for static cloud keys in CI. The CI runner mints a signed identity token describing the job (repo, branch, workflow). The cloud provider is configured to trust that issuer and exchanges the token for short-lived credentials. No static access key is stored anywhere; the runner proves its identity each run and receives credentials that expire when the job ends — “keyless” auth.

10. What is the difference between rotation and dynamic secrets? Rotation changes a long-lived secret on a schedule (and after exposure), bounding a leak’s value; the secret still exists between rotations. Dynamic secrets issue a brand-new, consumer-scoped credential that expires in minutes and did not exist until requested — rotation taken to the extreme, so there is almost nothing to leak. Dynamic > rotation > static.

11. Why is masking secrets in CI logs not a complete defence? Masking replaces the literal secret string with ***, so a value that is transformed (base64/URL-encoded) or only partially printed can slip past it. It is a safety net, not a control; the real defence is to never print secrets — avoid debug/set -x modes, don’t echo them, and pass them via env/stdin rather than command-line arguments.

12. How would you handle a connection string that contains both a host and a password? Split it. The host/port is configuration and can live in a per-environment config file or env var; the password is a secret and lives in the secret store. Compose the full connection string at run time. Never let the password drag the whole string into the repo, nor the host bloat the secret store.

Quick check

  1. True or false: a feature flag and a database password should be stored with the same machinery.
  2. In one sentence, what does 12-factor’s “config in the environment” achieve, and what does it not achieve?
  3. You committed a secret, then deleted the file in the next commit. Is the secret gone?
  4. After a credential leaks, what is the very first thing you do?
  5. Which secret-injection approach is an anti-pattern, and why?

Answers

  1. False. A feature flag is non-sensitive configuration (env/file/config service); a database password is a secret (secret store only). Different sensitivity, different machinery.
  2. It separates config from code so one artifact runs everywhere; it does not by itself encrypt or protect secrets (env vars are still visible to the process tree and logs).
  3. No. The secret remains in Git history (and in every clone/fork). Deleting the file does not remove it; you must rotate and rewrite history.
  4. Rotate / revoke the credential immediately — making the leaked copy worthless. History-scrubbing and prevention come afterwards.
  5. Build-time injection (baking a secret into an image layer): it persists in the image, ships to everyone who pulls it, and cannot be removed by a later layer. Inject at run time instead.

Exercise

Audit one real repository and one real pipeline you have access to — your own side project is perfect — and produce a one-page secrets posture report:

  1. Classify. List every configuration value the app reads at start-up and label each config or secret using the leak test. Note any value you are unsure about and why.
  2. Scan history. Run gitleaks detect --source . --verbose (and, if you can, trufflehog git file://. --only-verified) against the repo’s full history. Record every finding — including false positives — and, for any real secret found, write down that the correct first action is to rotate it.
  3. Check the pipeline. Find where the pipeline gets its credentials. Is there a static cloud key in CI secrets? If so, write the concrete plan to replace it with OIDC. Are secrets masked? Could any step print one?
  4. Pick a store. Based on your cloud (or multi-cloud) situation, choose which secret store you would standardise on and justify it in two sentences using the comparison table’s decision rules.
  5. Close the loop. Specify the prevention you would add: the exact .gitignore entries, the pre-commit scanner config, and the CI secret-scan job. Then write the four-step post-leak runbook (rotate, scrub, prevent, document) and pin it where your team will find it under pressure.

This is precisely the review a senior engineer runs when joining a team or hardening a service — and the report you produce is a portfolio artefact in its own right.

Certification mapping

This lesson maps to the foundational secrets-and-configuration knowledge that recurs across DevOps and security certifications:

The vocabulary here — config vs secrets, 12-factor, the secrets-in-Git failure mode, secret stores, injection patterns, rotation, dynamic secrets and OIDC — appears in the security and pipeline sections of essentially every DevOps exam.

Glossary

Next steps

You now have the secure-by-default foundation: the line between configuration and secrets, the 12-factor rule and where config belongs, the cardinal sin of secrets in Git and the rotate-first response, the secret-store landscape, the four injection patterns, and how rotation, dynamic credentials and OIDC shrink every credential’s lifetime toward zero. The next lesson, Testing in CI: the Test Pyramid, Coverage, Quality Gates & Shift-Left (testing-in-ci-test-pyramid-coverage-quality-gates), turns to the other spine of a trustworthy pipeline — proving your changes are correct before they ship. When you are ready to go deeper on the credential side, the course’s advanced lessons pick up exactly where this one stops: Dynamic Secrets in CI/CD with HashiCorp Vault (vault-dynamic-secrets-cicd-short-lived-credentials) makes “the only safe secret is one that does not exist” real with self-expiring credentials, and OIDC Keyless Deploys to Multiple Clouds (github-actions-oidc-keyless-deploys-multi-cloud) deletes static cloud keys from your pipelines for good. Together they take the principles you have just learned and turn them into a pipeline with almost nothing left to leak.

Secrets ManagementConfiguration12-FactorOIDCSecret RotationDevSecOps
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments