Validate config in GitHub Actions before deploy
The cheapest place to catch a missing DATABASE_URL is a GitHub Actions job; the most expensive is a crash-looping pod. This page adds a validation gate that runs before deploy, extending CI/CD Config Validation.
Two Actions features do all the work, and both are easy to leave out by accident. GitHub Environments decide which secrets a job can see, which is what makes validation meaningful rather than symbolic. Job dependencies decide whether a failure blocks anything, which is what makes it a gate rather than a notification. A workflow with both is a real control; a workflow missing either one looks identical in the UI and enforces nothing.
Problem 1: deploying first, discovering config errors second
# ANTI-PATTERN: no validation step — the bad config is found in production
jobs:
deploy:
steps:
- run: ./deploy.sh # boots the app for the first time in prod
The first time the settings model runs against production variables is in production. What happens next depends on the orchestrator: a rolling update may hold the old pods while the new ones crash-loop, which is the good case, or a recreate strategy may take the service down entirely while the new pods fail on a missing key. Either way the discovery happens with traffic involved and a rollback to consider, when the same information was available for free minutes earlier.
The rollback is the expensive part. A crash-looping deploy is usually recoverable, but the fix — set the missing variable, then redeploy — takes a full pipeline run while the incident is open, and it happens under the kind of time pressure that produces second mistakes. A validation job that fails before the deploy starts turns the same error into a red check on a pull request that nobody has to page anyone about.
Problem 2: validating against the wrong environment
# ANTI-PATTERN: validates with dev secrets, deploys to prod
- run: python -m ci.validate_config
env: { DATABASE_URL: ${{ secrets.DEV_DATABASE_URL }} } # not the deploy target
Passing dev secrets proves nothing about the environment you are about to deploy to. This is the subtler failure of the two, because the workflow looks complete: there is a validation step, it runs the real script, and it goes green. What it actually proves is that the model can be constructed from some set of values — a fact you already knew from local development.
The reason this happens is that repository-level secrets are the path of least resistance. They are visible to every workflow with no extra configuration, so a quick “add validation” pull request naturally reaches for them, and the difference only shows up when production has a key that development does not. Naming secrets identically across environments and letting the environment: key select the right set removes the temptation entirely: the workflow refers to secrets.DATABASE_URL and GitHub resolves it per environment.
Secure implementation
# .github/workflows/deploy.yml
jobs:
validate:
runs-on: ubuntu-latest
environment: production # pull this environment's protected secrets
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
- run: python -m ci.validate_config
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
deploy:
needs: validate # deploy only if validation passed
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
The validate job uses GitHub Environments so it reads the production secrets, and deploy declares needs: validate so a failed model construction blocks the rollout. Note that secrets are passed through env: on the individual step rather than at the job level. That scoping means the pip install and gitleaks steps run without the credentials in their environment at all, so a compromised or over-curious dependency in the install step has nothing to read.
The environment: production key has a side effect worth knowing about: if that environment has protection rules — required reviewers, a wait timer, a branch filter — the job waits for them. For a validation job that is usually not what you want, since the point is fast feedback before anyone is asked to approve anything. The common arrangement is to keep approvals on the deploy job and either accept the wait on validation or define a separate environment carrying the same secrets with no protection rules.
The matrix form has one property worth calling out: each entry is an independent job with its own environment, so a staging failure and a production failure are reported separately and neither blocks the other’s validation. That is usually what you want during a promotion, because knowing that production is missing a key and staging is fine is more useful than a single red job that stops at the first problem. Pair it with fail-fast: false if you rely on that, since the default cancels sibling matrix jobs as soon as one fails.
Scaling to several targets is a matrix over environment names, with each entry validating against its own secrets in parallel:
# validate every target with its own secrets, in parallel
jobs:
validate:
strategy:
matrix:
target: [staging, production]
environment: ${{ matrix.target }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python -m ci.validate_config
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }} # resolves per environment
Reporting the failure where people will see it
A validation job that fails with a stack trace buried in step logs is technically a gate and practically an annoyance. Actions gives two mechanisms for surfacing the result where it will be read, and both take a couple of lines.
The job summary is a Markdown file the runner renders at the top of the run page. Writing the missing keys there means the person who opened the pull request sees the actual problem without expanding a single step:
# ci/validate_config.py — add a summary for humans
import os, sys
from pydantic import ValidationError
from config.settings import Settings
def report(lines: list[str]) -> None:
path = os.environ.get("GITHUB_STEP_SUMMARY")
if path: # no-op outside Actions
with open(path, "a") as fh:
fh.write("\n".join(lines) + "\n")
def main() -> int:
try:
Settings()
except ValidationError as exc:
rows = [f"| `{'.'.join(str(p) for p in e['loc'])}` | {e['msg']} |"
for e in exc.errors()]
report(["### Config validation failed", "", "| field | problem |",
"| --- | --- |", *rows])
print("config validation failed", file=sys.stderr)
return 1
report(["### Config validation passed", "", "All required keys resolved."])
return 0
if __name__ == "__main__":
raise SystemExit(main())
The second mechanism is workflow commands: printing a line beginning with ::error file=… annotates the run and, when the file and line refer to something in the diff, attaches the message to the pull request’s changed-files view. That is worth doing when the failure is attributable to a specific file — a settings module that declared a new required field without a matching entry in the deployment manifest, say.
Both are strictly output formatting; neither changes what is checked. They matter because the difference between a gate people act on and a gate people re-run is almost entirely about how quickly the failure explains itself. A table naming three missing keys is acted on immediately; a ValidationError traceback in step seven of a collapsed log is re-run first and read second.
Gotchas & version-specific behaviour
- Use
environment:to scope secrets to the deploy target, not repo-wide secrets. needs:is what gates deploy on validation — without it the jobs run independently.- Mark required reviewers on the
productionenvironment for a manual gate if needed. - Keep
extra="forbid"so a stray repo secret injected as an env var fails the job. - Secrets are not available to workflows triggered by
pull_requestfrom a fork, so a validation job on fork pull requests fails on missing keys rather than on real problems — gate it with anif:on the event. - GitHub masks secret values in logs by exact match; a value that is transformed before printing — base64-encoded, URL-escaped, embedded in JSON — is no longer masked.
The fork caveat trips up open-source repositories in particular. The workflow behaves perfectly for internal branches and fails confusingly on every external contribution, which trains maintainers to ignore a permanently red check. Either restrict the validation job to non-fork events, or split it in two: run the model’s unit tests, which need no secrets at all, on every pull request, and run the environment validation only where secrets are available.
The masking caveat has a practical consequence for the validation script itself. Because masking is a literal substring match on the log stream, any transformation defeats it — and a settings model is full of transformations. A PostgresDsn field re-serialises the URL, a validator may normalise a host, and model_dump_json() escapes characters. That is the concrete reason the script prints field names rather than values: not because masking is unreliable in principle, but because the values a settings model holds have usually been reshaped by the time anything could print them, and the reshaped form is not what the masker is looking for.
Production parity checklist
- The validate job reads the same environment’s secrets the deploy will use.
deploydeclaresneeds: validate.gitleaksruns before validation.- Secrets are masked; validation prints key names only.
- The job runs on the Python version used in production.
- Secrets are scoped to the step that needs them, not to the whole job.
The last item is the cheapest hardening available in Actions and the least commonly applied. Every third-party action in a job with job-level secrets can read them, and a typical workflow uses several actions maintained by people the team has never evaluated. Moving the env: block down to the one step that needs the values costs two lines of indentation and removes that exposure for the rest of the job.
Pinning actions by commit SHA rather than by tag belongs in the same category of cheap hardening. A tag is mutable, so @v4 resolves to whatever the maintainer last pushed under that name, and a validation job holding production secrets is a high-value place for that to matter. Pinning costs a longer line and a periodic update, and it makes the workflow’s supply chain reproducible.
One more structural point about needs: is worth stating plainly, because it is where multi-job workflows quietly lose the gate. A dependency edge is transitive but not implicit: deploy needing build, and build needing nothing, means validation running in parallel is not upstream of anything. When a workflow grows past three or four jobs, the reliable way to check the property is to read the needs: keys and ask whether every deployment job has a path back to a validation job. If the answer requires tracing more than two edges, the workflow is probably worth flattening into something a reviewer can hold in their head.
if: always() deserves a similar caution. It is a legitimate tool for cleanup and notification jobs, and it is also the most common accidental way to defeat a gate — a deploy job carrying if: always() runs regardless of whether its dependency failed, which is precisely the behaviour the dependency was added to prevent. Reserve it for jobs that genuinely must run after failures, and never put it on a job that ships anything. A quick grep for always() in the workflow directory is a reasonable thing to run before trusting a pipeline you did not write yourself.
Key takeaways
Gate deploy behind a validate job that constructs the model with the target environment’s secrets, and a bad config never reaches a pod. The two things to get right are structural rather than clever: use environment: so the job reads the values the deployment will actually use, and use needs: so a failure blocks rather than merely reports. Everything else — the matrix over targets, step-scoped secrets, restricting the job on fork events, SHA-pinned actions — refines those two without substituting for either. A useful way to audit an existing workflow is to answer three questions in order: which secrets does the validation job actually see, can any deploy job start without it passing, and would the person who broke it understand the failure from the run page alone. If all three answers are good, the gate is doing its job; if any is not, the fix is a few lines of YAML rather than a redesign. For the GitLab equivalent, see GitLab CI Config Validation Stage.