Automating .env File Generation from CI Artifacts
Your local .env falls out of sync with the canonical config your CI pipeline produces, so the app boots against stale hosts and missing keys. This page shows how to download a CI-generated config artifact and turn it into a validated local .env deterministically, as part of the broader environment sync and CI parity baseline and its dotenv configuration management practices.
The failure is easy to spot once you name it: the pipeline resolves a full set of variables — database hosts, cache TTLs, feature flags, TLS toggles — into a single artifact, but every engineer's laptop carries a hand-edited copy that was last correct three sprints ago. Because nothing forces the two to reconcile, the divergence is silent until a service crashes on boot or, worse, connects to the wrong backing store. The rest of this guide replaces the hand-edited copy with a generated one whose provenance is the CI run itself.
The approach rests on one inversion of ownership: the local .env stops being a file you edit and becomes a file you derive. You never open it in a text editor again. Instead, a single command rebuilds it from the most recent trustworthy artifact, and every other control on the page exists to make that command safe to run repeatedly — authenticated, atomic, permission-locked, and validated. Once the file is a build output rather than a source, the question "is my config correct?" reduces to "did I regenerate from the current artifact?", which is answerable with a timestamp instead of a code review.
Diagnostic
Compare your local file against the template's key set. Drift shows up as keys present on one side only.
#!/usr/bin/env bash
set -euo pipefail
diff <(grep -oE '^[A-Z_]+' .env.example | sort) \
<(grep -oE '^[A-Z_]+' .env.local | sort) \
&& echo "PARITY OK" || echo "DRIFT DETECTED"
Expected BAD output — keys diverge and the file is stale:
2a3
> CACHE_TTL
5d5
< REDIS_TLS_VERIFY
DRIFT DETECTED
The diff above only compares key names, which catches added and removed variables but not value drift. A key that exists on both sides with a different value — POSTGRES_HOST=localhost locally versus postgres.internal in the artifact — passes this check and still breaks the app. Widen the diagnostic to compare the full KEY=VALUE line set once you have the artifact in hand, then treat any non-empty diff as a signal to regenerate rather than to hand-patch.
Confirm whether the artifact you need still exists and is reachable:
#!/usr/bin/env bash
set -euo pipefail
gh api repos/{owner}/{repo}/actions/artifacts \
--jq '.artifacts[] | select(.name=="env-config") | {name, created_at, expires_at}'
Expected BAD output — the artifact has aged out:
{"name":"env-config","created_at":"2026-02-14T14:02:11Z","expires_at":"2026-05-15T14:02:11Z"}
An expires_at in the past means the download will 404 no matter how you authenticate: GitHub garbage-collects the blob storage on expiry, and the API row lingers only briefly after. Before you spend time debugging tokens, decide from the timestamps whether you are fetching a live artifact or need to trigger a fresh run. The decision tree below captures that branch.
Root cause
CI runners are ephemeral and isolated: artifacts expire under a retention policy, and secret masking can strip required payloads if you dump raw .env text through the log surface. When the local file was hand-edited or the artifact was never downloaded, the two states drift apart. The fix is to treat the CI artifact as the source of truth, fetch it over an authenticated channel, parse it from a structured format (JSON/YAML rather than a masked raw dump), and write it atomically so a partial download never leaves a half-written .env.
Two properties of CI environments make this drift structural rather than accidental. First, runners are provisioned fresh per job and discarded, so the only durable output is whatever you explicitly upload as an artifact — there is no persistent filesystem to read back later. Second, the platform's log redaction rewrites any string that matches a registered secret, which means a raw .env echoed into logs comes back with values replaced by ***. Emitting structured JSON as the artifact sidesteps both problems: the values travel inside an uploaded blob rather than through the log surface, and a parser can validate types and required keys instead of trusting a flat text blob. The pipeline stage that assembles this JSON becomes the single authority, and every laptop derives its .env from it.
There is a third, quieter cause worth calling out: format ambiguity. A flat .env dump has no schema, so a value like DEBUG=false is indistinguishable from the string "false", and a quoted PORT="5432" reads back differently than a bare 5432 depending on the loader. When the artifact is JSON, types survive the round trip — booleans stay booleans, numbers stay numbers — and the conversion to KEY=VALUE becomes the one place where you decide serialization rules explicitly instead of inheriting whatever the shell happened to do. That determinism is what lets two engineers on different operating systems regenerate byte-identical files from the same run.
Resolution
Run these four steps in order. Each is idempotent — re-running regenerates the file from the current artifact rather than mutating your local edits in place, which is exactly the property you want.
- Authenticate the CI CLI with a short-lived, scoped token. Prefer a token restricted to
actions:readfor a single repository over a broad personal token, and let it expire rather than storing it long-term:#!/usr/bin/env bash set -euo pipefail gh auth login --with-token <<< "${GITHUB_TOKEN}" gh auth status - Download and unpack the latest successful artifact into a staging directory. Staging under
/tmpkeeps a corrupt or partial download away from your working tree until you have verified it:#!/usr/bin/env bash set -euo pipefail gh run download "${CI_RUN_ID}" -n env-config -D /tmp/ci-artifacts unzip -o /tmp/ci-artifacts/env.zip -d /tmp/ci-artifacts - Convert the structured payload to
.envlines and write atomically via a temp file. Themvis the commit point: because a rename on the same filesystem is atomic, any reader either sees the old file or the new one, never a truncated mix:#!/usr/bin/env bash set -euo pipefail [ -f .env.local ] && cp .env.local .env.local.bak jq -r 'to_entries[] | "\(.key)=\(.value)"' /tmp/ci-artifacts/config.json > .env.local.tmp mv .env.local.tmp .env.local - Lock down permissions so the file is not world-readable. A generated
.envinherits the umask of whatever process wrote it, which is often644— readable by every user on a shared machine:#!/usr/bin/env bash set -euo pipefail chmod 600 .env.local stat -c '%a' .env.local # expect: 600
The temp-file-then-rename pattern in step 3 is the load-bearing detail. If you redirect jq straight into .env.local, a network hiccup or a jq parse error mid-stream leaves the file half-written and the app reads garbage on its next boot. Writing to .env.local.tmp first means the destination is only ever replaced by a fully materialized file, and the earlier cp to .env.local.bak gives you a one-command rollback if the new values turn out wrong.
Expected output
Logged in to github.com account ci-bot
Downloading artifact env-config...
Archive: /tmp/ci-artifacts/env.zip
inflating: /tmp/ci-artifacts/config.json
Injected 24 variables
600
A clean run ends with the file mode 600 echoed back, confirming the permission tightening in step 4 took effect. If you see 644 here, the chmod was skipped or overridden by an editor that rewrote the file — re-run the final step before trusting the result on a multi-user host.
The line Injected 24 variables is worth asserting on in CI-adjacent scripts: a sudden drop to a handful of keys usually means the artifact was truncated or jq matched an empty object, and catching that count regression is cheaper than debugging why a downstream service cannot find its database URL. Wrap the whole flow in a wrapper that fails non-zero when the emitted count falls below a known floor, and you turn a silent partial write into a loud, actionable error at generation time rather than at application boot.
Prevention
Generating the file once is not enough; the goal is to make a stale .env impossible to keep. Each control below closes a different gap — schema drift, branch switches, and payload corruption respectively.
- Validate the generated file against a schema derived from the same artifact, and fail non-zero on any violation:
#!/usr/bin/env bash set -euo pipefail dotenv-validator --schema .env.schema.json --file .env.local --strict --exit-code - Add a
post-checkoutGit hook that re-validates.env.localso a stale file is caught the moment you switch branches. A branch that introduced a new required variable will fail the hook immediately instead of at runtime:#!/usr/bin/env bash set -euo pipefail # .git/hooks/post-checkout dotenv-validator --schema .env.schema.json --file .env.local --strict --exit-code \ || echo "WARNING: .env.local drifted from schema — re-run the artifact sync" - Set explicit artifact retention (
retention_days: 30) and ship structured JSON, not raw masked.envdumps, so masking never corrupts the payload. The schema rules live in environment variable validation.
The measurable payoff is fewer regeneration events over a sprint: teams that only sync on demand tend to accumulate drift until something breaks, while a validating hook flattens the curve by catching each divergence at its source. The chart below contrasts the two regimes.
Platform caveats
WSL2: A
CRLF-terminated artifact breaksjqline splitting and validator parsing. Setcore.autocrlf=inputand pipe throughsed 's/\r$//'before the atomicmv. macOS:stat -cis GNU syntax; usestat -f '%Lp' .env.localon BSD/macOS to read the mode. Apple Silicon (ARM64): If you invoke a containerizeddotenv-validatorimage, pull thearm64variant or set--platform linux/arm64— an emulatedamd64binary adds seconds per validation and can mask architecture-specific parsing bugs.
Rollback
#!/usr/bin/env bash
set -euo pipefail
[ -f .env.local.bak ] && mv .env.local.bak .env.local && echo "Restored prior .env.local" || echo "No backup; re-run sync"
Because step 3 wrote .env.local.bak before regenerating, this restores the exact file you had before the sync. If no backup exists — a first-ever run, or a machine where you skipped the cp — the safe move is to re-run the full Resolution flow rather than hand-editing, so the file's provenance stays the CI artifact.
Frequently Asked Questions
Why write to a temp file and mv instead of redirecting jq straight to .env.local?
A rename within the same filesystem is atomic, so any process reading .env.local sees either the complete old file or the complete new one — never a half-written mix. Redirecting jq output directly truncates the destination the instant the command starts; if jq errors on malformed JSON or the download was partial, you are left with a corrupt .env that the app reads on its next boot. The temp-file pattern makes the swap all-or-nothing.
The gh api call shows the artifact but gh run download returns 404 — what happened?
Almost always the artifact expired. The API row for an artifact lingers briefly after its expires_at, but the underlying blob is garbage-collected on schedule, so the metadata query succeeds while the download fails. Check the expires_at timestamp first; if it is in the past, re-run the pipeline to produce a fresh artifact instead of debugging your token.
Should the CI artifact contain plaintext secrets, or only non-sensitive config?
Keep true secrets out of the generated .env. Ship non-sensitive config — hosts, ports, feature flags, TTLs — in the artifact, and resolve secrets locally from a vault so they never sit in a downloadable blob or in CI logs. See rotating secrets without restarting containers for keeping those values current without a full regeneration.
How do I catch value drift, not just missing or extra keys?
The name-only diff in the Diagnostic compares key sets, so a key present on both sides with different values slips through. After downloading the artifact, compare the full KEY=VALUE line set — for example diff <(sort .env.local) <(jq -r 'to_entries[]|"\(.key)=\(.value)"' config.json | sort) — and treat any non-empty result as a signal to regenerate rather than to hand-patch the local file.