CI/CD Config Validation
A configuration error should fail a pipeline, not a production pod. The other pages in this section repeatedly lean on “gate it in CI” — this is the dedicated page for that gate. It instantiates the settings model in the pipeline so a missing key, a malformed URL, or a committed secret stops the build. It sits within the core configuration patterns as the enforcement layer over everything else.
The insight that makes this cheap is that you already own a complete specification of valid configuration: the settings model. Every required field, every type, every constraint and validator is a machine-checkable assertion about what a correctly configured environment looks like. Constructing that model against a given environment’s variables is therefore a full configuration test, and it costs one short script and a few seconds of pipeline time. Nothing else in the deployment pipeline gives that much verification for that little effort.
Secure implementation
# ci/validate_config.py — run as a standalone CI step
import sys
from pydantic import ValidationError
from config.settings import Settings # your one BaseSettings model
def main() -> int:
try:
settings = Settings() # extra="forbid" catches typo'd vars
except ValidationError as exc:
print("Config validation FAILED:", file=sys.stderr)
print(exc, file=sys.stderr) # field-level errors, no secret values
return 1
print("Config OK:", ", ".join(settings.model_dump(exclude={"api_key"}).keys()))
return 0
if __name__ == "__main__":
raise SystemExit(main())
# .github/workflows/config.yml
jobs:
validate-config:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -r requirements.txt
- run: gitleaks detect --no-banner # block committed secrets
- run: python -m ci.validate_config # fail build on bad config
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
The job constructs the model with extra="forbid", so a stray or misspelled variable fails the build. Secrets come from masked GitHub secrets and are never printed. The success line prints field names only, and even that goes through exclude, so a SecretStr slip cannot leak — the belt-and-braces matters because CI logs are frequently more widely readable than the secret store the values came from.
Printing the ValidationError directly is safe and deliberately chosen. Pydantic’s error output names the field and the failure reason but does not echo the offending value for most error types, which is exactly the level of detail an operator needs: database_url: Field required tells them what to add. If your model has custom validators, check that their messages follow the same rule — a validator that raises ValueError(f"invalid URL: {v}") turns a helpful message into a credential disclosure the moment the malformed value is a connection string with a password in it.
Configuration reference
| Step | Tool | Fails build when | Security implication |
|---|---|---|---|
| Secret scan | gitleaks |
A credential is committed | Stops leaks reaching history |
| Config validate | Settings() |
Missing/malformed key | Catches drift pre-deploy |
extra="forbid" |
pydantic | Unknown variable present | Surfaces typos in review |
| Masked vars | CI platform | — | Keeps secrets out of logs |
PYTHONWARNINGS=error |
Python | Any warning | Catches deprecations early |
extra="forbid" is the row that generates the most debate, because it fails builds for variables that are merely unused rather than wrong. That is the point. The overwhelmingly common cause of an unexpected extra variable is a typo — DATABSE_URL set in the environment while the model waits for DATABASE_URL — and without forbid that typo produces a “field required” error for a key the operator can plainly see is set, which is one of the more maddening debugging experiences in this area. With forbid, the error names both halves of the problem at once.
The one real friction is shared environments where other tooling’s variables land in the same namespace. Setting env_prefix on the model confines it to keys that belong to your service, after which forbid is unambiguous: anything with your prefix that the model does not declare is a mistake, and anything without your prefix is somebody else’s business.
PYTHONWARNINGS=error is a smaller lever with an outsized effect on maintenance. Deprecation warnings from pydantic, from a database driver, or from the standard library are invisible in normal CI output and accumulate silently until a major version upgrade turns them all into errors at once. Promoting them to failures means each one is fixed by whoever introduced it, on the commit that introduced it.
Validating against the right environment
The subtlety that decides whether this gate is worth anything is which variables it validates against. A pipeline that constructs the model using a set of dummy CI values proves that the model can be constructed — nothing more. It will happily pass while production is missing a key, because production’s environment was never involved.
The fix is to run the same script once per target environment, with that environment’s real variable set, immediately before deploying to it. In GitHub Actions that is a job per environment using the environment’s own secrets; in GitLab it is a job per stage with the matching scoped variables. The script is identical each time; only the injected values differ, which is precisely the property that makes the check meaningful.
# .github/workflows/deploy.yml — validate against the target before deploying to it
jobs:
validate-staging:
environment: staging # pulls staging's secrets, not the repo defaults
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python -m ci.validate_config
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
deploy-staging:
needs: validate-staging # deployment cannot start unless config is valid
runs-on: ubuntu-latest
steps: [...]
The needs: edge is doing the real work. Without it the validation job is advisory — it goes red while the deploy proceeds beside it. With it, an invalid configuration makes the deployment unreachable, which converts the check from information into enforcement. Every pipeline that claims to gate on configuration should be readable as a dependency graph where nothing deploys without a passing validation upstream of it.
One thing this cannot catch is a variable that exists and is well-formed but points at the wrong place — a staging database URL set in the production environment. Validation proves the shape, not the intent. A cheap partial defence is a validator that asserts environment-appropriate patterns, such as refusing a hostname containing staging when APP_ENV is production; it is crude, but it catches the copy-paste mistake that causes most such incidents.
A more general version of the same idea is to validate relationships rather than individual fields. A model validator can assert that the database host and the cache host belong to the same region, that a callback URL’s domain matches the configured public hostname, or that debug mode and a production log destination are never both set. Each of those is a statement about a combination that no single field can express, and each one is checkable in the same pipeline job at no extra cost. They are also the assertions most likely to catch a genuinely dangerous misconfiguration, because a value that is individually plausible and jointly wrong is exactly what slips past review.
Detecting drift between environments
Validation answers “is this environment configured?” It does not answer “are these environments configured the same way?”, and the gap between those two questions is where a large share of promotion failures live. Staging passes, production passes, and the deploy still misbehaves because production is running with a default that staging overrides — both valid, neither identical.
The check that closes the gap is an inventory rather than a validation: for each environment, record which source supplied each key, then diff the inventories. A key resolved from the environment in staging but from a field default in production is drift, and it is drift that no amount of per-environment validation will surface.
# ci/config_inventory.py — emit a source map, never values
import json, os, sys
from config.settings import Settings
def inventory() -> dict[str, str]:
fields = Settings.model_fields
return {
name: ("env" if name.upper() in os.environ else "default")
for name in fields
}
if __name__ == "__main__":
json.dump(inventory(), sys.stdout, indent=2, sort_keys=True)
Run it in each environment’s validation job, publish the JSON as a build artifact, and add a final job that fetches both and diffs them. The diff is small, readable, and reviewable — a handful of lines saying which keys differ in origin — and it turns an entire class of “worked in staging” incidents into a pipeline comment. Because it prints origins rather than values, the artifact is safe to keep and safe to attach to a pull request.
The same artifact has a second use during incidents. When production behaves unexpectedly, the inventory from the last successful deploy tells you which keys were coming from where at that moment, which is often faster than reconstructing the state from deployment manifests and secret store history.
Testing the model, not just running it
The validation job proves that a given environment satisfies the model. A small test suite proves the reverse: that the model actually rejects what it should. Both are needed, because a model whose fields are all optional with defaults passes validation against an empty environment and gates nothing at all.
# tests/test_settings.py
import pytest
from pydantic import ValidationError
from config.settings import Settings
BASE = {"DATABASE_URL": "postgres://u:p@db/app", "API_KEY": "k"}
def test_required_keys_are_required(monkeypatch):
monkeypatch.delenv("DATABASE_URL", raising=False)
for k, v in {"API_KEY": "k"}.items():
monkeypatch.setenv(k, v)
with pytest.raises(ValidationError) as exc:
Settings()
assert "database_url" in str(exc.value)
def test_unknown_variable_is_rejected(monkeypatch):
for k, v in {**BASE, "DATABSE_URL": "typo"}.items():
monkeypatch.setenv(k, v)
with pytest.raises(ValidationError):
Settings() # extra="forbid" catches the typo
def test_secret_is_not_in_repr(monkeypatch):
for k, v in BASE.items():
monkeypatch.setenv(k, v)
assert "k" not in repr(Settings()) # SecretStr masks it
Three tests, three properties that the pipeline depends on and that would otherwise be assumed. The first asserts that a required field is genuinely required, which fails the day someone adds a convenience default to unblock a local run. The second asserts that extra="forbid" is in force, which fails the day someone relaxes it to silence an unrelated error. The third asserts that a secret field does not leak through repr, which fails the day a SecretStr becomes a plain str during a refactor.
Each of those three regressions is the kind that passes review easily — they all look like small simplifications — and each one silently disables part of the gate. Pinning them in tests means the pipeline’s guarantees are themselves tested, rather than being properties everyone believes are still true.
Step-by-step deployment parity
- Local dev — run
python -m ci.validate_configand the pre-commit secret scanner before pushing. - CI — the pipeline runs the same validation plus
gitleaks; a failure blocks merge. - Staging — validate against staging’s injected variables before promotion.
- Production — a pre-deploy job validates against production variables; deployment is blocked on failure.
Notice that step one is not decoration. Making the same script runnable locally means a developer adding a required field can confirm their own environment satisfies it before pushing, and it means the CI failure they might otherwise hit is reproducible on their machine rather than mysterious. A validation gate that only exists inside the pipeline trains people to debug by pushing commits, which is slow and noisy.
Steps three and four are the ones most often collapsed into one. Validating once, against staging, and then promoting the artifact to production on the assumption that production is configured the same way, reintroduces the whole problem this page exists to solve — production’s variables were never checked. Each environment gets its own validation immediately before its own deployment, even when the artifact being deployed is byte-identical to the one already running in staging, because it is the configuration and not the artifact that differs.
Keeping the gate fast and trustworthy
A gate that people work around is worse than no gate, because it creates the appearance of enforcement without the substance. Two failure modes cause that, and both are about trust rather than correctness.
The first is slowness. A validation job that installs the full dependency tree to construct one settings object adds minutes to every pipeline run, and the pressure to skip it grows with the wait. Most of that time is installation, not validation, so the fixes are ordinary: cache the dependency install keyed on the lockfile, install only what the settings module actually imports if the model can be isolated, and run the validation job in parallel with the test job rather than after it. A gate that finishes in twenty seconds is one that nobody ever argues about or asks to have made optional.
The second is flakiness. If the validation job fails intermittently — a network call in a validator, a secret store rate limit, a dependency resolved from the network at run time — people learn to re-run it rather than read it, and a genuine failure gets re-run too. Configuration validation should be hermetic: no network, no external service, nothing but the environment and the model. A validator that checks reachability of a database is a useful thing to have, but it belongs in a separate connectivity job, precisely so its flakiness cannot erode trust in the deterministic check.
# .github/workflows/config.yml — fast, hermetic, and cached
jobs:
validate-config:
runs-on: ubuntu-latest
timeout-minutes: 5 # a hung gate is a broken gate
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip # install is the slow part, not validation
- run: pip install -r requirements.txt
- run: python -m ci.validate_config
env:
PYTHONWARNINGS: error
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
The timeout-minutes entry is worth setting deliberately. Without it, a job that hangs — waiting on a prompt, retrying a network call — occupies a runner until the platform’s default timeout expires, which on some platforms is hours. A short timeout converts a hang into a fast, obvious failure, and for a job that should take seconds there is no downside.
Security boundaries & operational guardrails
- Secret scanning runs in both pre-commit and CI; CI is the gate that cannot be skipped.
- Validation uses
extra="forbid"so unknown keys fail instead of slipping through. - CI variables are masked; validation output reports key names only, never values.
- Secrets are wrapped in
SecretStrso an accidental print is masked. - The validation step runs with
PYTHONWARNINGS=errorto catch deprecations. - Pipeline definitions are reviewed like code — a change to a
needs:edge can silently remove a gate.
Masking deserves one caution: it is a substring match on the log stream, not a guarantee. A secret that gets transformed before printing — base64-encoded, URL-escaped, split across lines, or interpolated into a JSON body — no longer matches the masked string and appears in the log in full. That is why “never echo configuration” is the primary rule and masking is the safety net, rather than the other way round. The same logic applies to SecretStr: it protects repr() and str(), but get_secret_value() returns a plain string that behaves like any other.
The last item is the one teams discover the hard way. Pipeline YAML is executable infrastructure, and a refactor that reorganises jobs can drop a dependency edge without any test noticing, leaving a validation job that runs, fails, and blocks nothing. A periodic read of the deployment workflow — asking only “can any deploy job start without a passing validation upstream?” — catches that in a minute.
Branch protection is the complement to that review. A required status check on the default branch makes the validation and scanning jobs non-optional for merges, which closes the gap where someone with push access bypasses the pull request entirely. Without it, every guarantee on this page holds only for changes that happen to go through review, and the one change that skips review is disproportionately likely to be the urgent fix made under pressure — which is exactly when a configuration mistake is most likely and least noticed.
Scanning for secrets that already exist
The scanner half of the gate has a different shape from the validation half, because it is looking for something that should never appear rather than checking that something does. Two properties decide whether it is useful: what it scans, and what it does with what it finds.
Scanning only the current working tree misses the common case. A credential committed three months ago and removed last week is still in history, still in every clone, and still valid unless someone rotated it — so the CI job should scan history on the default branch, not just the diff. Scanning the diff is right for the pull request job, where speed matters and the question is “does this change introduce a secret?”; scanning full history is right for a scheduled job, where the question is “is there anything in here we missed?”
# .github/workflows/scan.yml — diff on PRs, full history on a schedule
on:
pull_request:
schedule:
- cron: "0 3 * * 1" # weekly full-history sweep
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: ${{ github.event_name == 'schedule' && 0 || 1 }}
- run: gitleaks detect --no-banner --redact
--redact matters more than it looks. Without it, the scanner’s own output prints the secret it found, into a CI log that is often more widely readable than the place the secret was supposed to live — so a tool that exists to prevent disclosure performs one. Redacted output still names the file, the line, and the rule that matched, which is everything a developer needs in order to act on the finding without the value ever being written down again.
False positives are the other thing to plan for, because an unmanaged allow-list is how scanners get disabled. Test fixtures with fake keys, documentation examples, and high-entropy strings that are not credentials all trip detectors. Keep the ignore list in the repository, require each entry to carry a comment explaining why it is safe, and review it like any other code — an allow-list nobody reads eventually contains a real secret somebody wanted to ship.
Troubleshooting
- Build passes but production fails — CI validated against the wrong environment’s variables; validate against the target environment.
- Secret leaked despite pre-commit — someone ran
git commit --no-verify; the CIgitleaksjob is the backstop. See Pre-commit Hook to Block Committed Secrets. extra not permittedin CI only — a variable is set in CI but not in the model; add or remove it deliberately.- Secret printed in CI logs — the field is a plain
str; switch toSecretStr. - Validation passes, deploy uses different values — the deploy job reads a different secret scope than the validation job; make both use the same environment.
- Scanner reports a finding in a deleted file — history is being scanned, which is correct; rotate the credential, then decide whether to purge.
The first entry is worth a longer look because it accounts for most false confidence in this area. A pipeline that validates against repository-level secrets and deploys using environment-level secrets is testing one configuration and shipping another, and everything about the run looks green. The diagnostic is to print the set of key names the validation job resolved and compare it with the deploy job’s environment definition; if the two lists differ, the gate is checking the wrong thing. Making both jobs declare the same environment: is the structural fix, since it removes the possibility of them diverging rather than merely detecting it.
The scanner entry catches people out in the opposite direction. A finding in a file that no longer exists looks like a bug in the tool and is usually a correct report about history — the file is gone from the working tree and the credential is still in the pack. Treat the credential as live until somebody has rotated it and confirmed the old value no longer works.
Frequently asked questions
How do I validate configuration in a CI pipeline?
Add a stage that imports and instantiates your pydantic-settings model against the target environment’s variables. If construction raises a ValidationError, the stage fails and the build stops before the misconfiguration reaches production.
Should secret scanning run in CI or in pre-commit?
Both. A pre-commit hook blocks the obvious local mistake, and a CI job is the enforced gate that cannot be bypassed with --no-verify. Run gitleaks or detect-secrets in both places.
How do I keep real secrets out of CI logs?
Use the platform’s masked variables, never echo configuration, and run validation with output that reports key names only. Wrap secret fields in SecretStr so an accidental print is masked.
Key takeaways
The invariant: no configuration reaches an environment it has not passed validation for, and no secret reaches history without tripping a scanner. The pipeline, not production, is where misconfiguration dies. The whole gate is a dozen lines of Python plus a job definition, because the settings model already encodes what “valid” means — CI’s contribution is to run that specification against each environment’s real values at the moment before deployment.
Two structural details separate a gate that works from one that only looks like it does. Validate against the target environment’s variables, not a set of CI placeholders, or the check proves nothing about where you are deploying. And wire the deploy job’s needs: to the validation job, so a failure blocks rather than merely reports. Get those two right, add extra="forbid" so typos surface as themselves, and the class of incident where a service starts with the wrong configuration stops occurring. Everything beyond that — the source-map diff, the model’s own tests, the scheduled history sweep — is worth adding as the service grows, but none of it substitutes for the two structural details, and a pipeline that has them is already ahead of most.