Catching Missing Env Vars Before Container Startup
A container starts, runs for ten seconds, then crashes with a null-pointer error deep in application code — all because DATABASE_URL was never set. This guide is part of environment variable validation within the environment sync, secrets and CI parity baseline, and it turns that late, misattributed crash into an immediate, precise refusal to start.
The cost of a missing variable is not the missing value itself — it is when you find out. A guard at the docker compose up boundary reports the problem in milliseconds with the variable name; an unguarded app reports it seconds later as a stack trace in a file no onboarding engineer has opened. This page installs three overlapping guards — a Docker Compose interpolation check, a container entrypoint check, and a CI schema gate — so a missing variable can never reach application code on any developer's machine or in the pipeline.
Diagnostic
Reproduce the late failure by starting the service with a required variable unset. The point of the exercise is to watch how far the boot sequence travels before anything complains.
#!/usr/bin/env bash
set -euo pipefail
unset DATABASE_URL
docker compose up app
Expected BAD output (failure happens late and the message hides the real cause):
app-1 | Server starting on :8080
app-1 | Connecting to database...
app-1 | TypeError: Cannot read properties of undefined (reading 'replace')
app-1 | at parseConnectionString (/app/db.js:14:21)
app-1 exited with code 1
The crash is a generic runtime error, not "DATABASE_URL is required", so the root cause is obscured. Notice the ordering: the server prints a healthy-looking startup banner, opens its listener, and only fails when it reaches the code that actually dereferences the connection string. Anyone reading the log top-to-bottom concludes the database is unreachable, not that a variable was never provided.
To confirm the variable really is the culprit rather than a network problem, print the environment the container received before it boots:
#!/usr/bin/env bash
set -euo pipefail
docker compose run --rm app env | grep -E '^(DATABASE_URL|API_PORT|JWT_SECRET)=' || \
echo "one or more required vars are absent from the container environment"
An empty or partial result here is the real signal — the variable never made it into the process, so no amount of retrying the connection will help. Run this same command on a machine where the service works and diff the two outputs; the variables present in the working environment but absent in the broken one are precisely the ones your guard needs to enforce. This is also the fastest way to onboard a teammate whose first up fails: the diff turns a vague "it does not start" into a concrete list of missing names.
Root cause
By default an unset environment variable is simply absent. The application reads undefined, carries it through initialization, and only fails when it finally dereferences it — far from where the variable should have been validated. Nothing between the unset value and the crash asserts that the variable is present, so the failure is both late and misattributed.
Three separate boundaries could each have caught the problem and none of them did. The shell that launched Compose did not care that DATABASE_URL was empty. Compose interpolated the empty value into the container environment without objection, because plain ${DATABASE_URL} substitution treats "unset" and "empty string" as ordinary, valid inputs. The container's entrypoint executed the application directly, with no precondition check. Each of those boundaries is a place you can make fail loudly, and the resolution below adds a check at all three so that at least one fires before application code runs, regardless of how the container was launched.
The deeper reason this bug is so common is that the failure is non-deterministic across environments. A developer who happens to have DATABASE_URL exported in their shell profile never sees it; the new hire cloning the repository for the first time hits it immediately. That asymmetry is exactly what a fail-fast guard removes — it makes the required contract explicit and identical for everyone.
Resolution
The strategy is defense in depth: add the same required-variable contract at three boundaries so no single missing check lets a bad environment through.
- Make Compose itself refuse to start when a required variable is missing, using the
${VAR:?error}mandatory-variable syntax. The:?form fails interpolation and abortsupwhen the variable is unset or empty, printing your message.
# docker-compose.yml
services:
app:
image: app:local
environment:
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
API_PORT: ${API_PORT:?API_PORT is required}
- Add an entrypoint guard so the check also fires when the container is run outside Compose (CI,
docker run, a Kubernetes job). The entrypoint is the last line of defense that runs inside the image itself, so it protects every launch path.
#!/usr/bin/env bash
# docker-entrypoint.sh
set -euo pipefail
REQUIRED=("DATABASE_URL" "API_PORT" "JWT_SECRET")
missing=()
for key in "${REQUIRED[@]}"; do
[ -n "${!key:-}" ] || missing+=("$key")
done
if [ "${#missing[@]}" -gt 0 ]; then
echo "FATAL: missing required env vars: ${missing[*]}" >&2
exit 1
fi
exec "$@"
The ${!key:-} indirect expansion reads the value of the variable whose name is held in $key, and the :- default keeps set -u from aborting the loop before the guard can report a friendly message. Collecting every missing name into an array before exiting means one run tells you about all the gaps, not just the first.
- Wire the entrypoint and a CI schema check so the gate runs before the image is ever deployed. The CI step validates the
.envfile against a JSON schema, catching not only absence but wrong types and typos in variable names.
# docker-compose.yml (entrypoint wiring)
services:
app:
image: app:local
entrypoint: ["/app/docker-entrypoint.sh"]
command: ["node", "server.js"]
#!/usr/bin/env bash
# CI: validate .env against the schema before tests run
set -euo pipefail
ajv validate -s env-schema.json -d .env --strict-types
The env-schema.json names every required key once and becomes the single source of truth the other two guards derive from. A minimal schema looks like this:
{
"type": "object",
"required": ["DATABASE_URL", "API_PORT", "JWT_SECRET"],
"properties": {
"DATABASE_URL": { "type": "string", "pattern": "^postgres://" },
"API_PORT": { "type": "string", "pattern": "^[0-9]+$" },
"JWT_SECRET": { "type": "string", "minLength": 16 }
},
"additionalProperties": true
}
Deciding which variables to guard
Not every variable belongs in the required list. Guarding an optional variable turns a working environment into a broken one and trains people to bypass the check. Classify each variable before adding it: a variable is required only if the service cannot function correctly without it and there is no safe default. Anything with a sensible fallback — a log level, a feature flag, a cache TTL — should get a default in code, not a hard guard.
For optional values, prefer the ${VAR:-default} form in Compose, which supplies a fallback instead of aborting. Reserve ${VAR:?message} strictly for the values in your schema's required array so the two stay in sync.
Expected output
$ docker compose up app
app The DATABASE_URL variable is required but not set.
$ echo $?
1
With the entrypoint guard and a missing variable supplied at docker run time:
FATAL: missing required env vars: JWT_SECRET
The container exits immediately with a precise message, before any application code runs. Compare the two logs side by side: the guarded run names the exact variable in its first line, while the unguarded run from the diagnostic buried a stack trace after a misleading startup banner. That difference is the entire return on the work.
Prevention
- Keep the required list in one machine-readable schema (
env-schema.json) and drive both the entrypoint and CI from it, so there is a single source of truth. When a new required variable is added, it changes in one place and all three guards pick it up. - Add a pre-commit hook running
ajv validateagainst.envto catch omissions before push, so a teammate never commits a compose change that references a variable the schema does not know about. - Use
${VAR:?}for every truly required variable in Compose so the failure surfaces atuptime, not at runtime.
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: env-schema
name: validate .env against schema
entry: ajv validate -s env-schema.json -d .env --strict-types
language: system
files: '^\.env$'
pass_filenames: false
The measurable payoff is how early the failure is reported. The chart below shows, for the same missing variable, how many seconds elapse before a human sees an actionable message at each guard boundary.
Platform caveats
WSL2: normalize line endings (
core.autocrlf=input) so a trailing\ron a value does not make a present variable read as malformed — aDATABASE_URL=postgres://...\rwill pass the entrypoint-ncheck but fail the schemapatternmatch, producing a confusing mismatch between the two guards. macOS (Docker Desktop): avoid relying on host shell inheritance; pass--env-fileso the guard validates the file the container actually receives, not whatever happens to be exported in your login shell. Apple Silicon (ARM64): theajv-cliinstall must resolve a native-free build; pin it inpackage.json(ajv-cli@^5) so CI on an ARM64 runner does not silently skip the schema step because the binary failed to install.
Rollback
The guard only blocks genuinely incomplete environments, so the fix is to supply the variable, not to remove the guard. To unblock temporarily while debugging, export the value for one run: DATABASE_URL=postgres://localhost/dev docker compose up app. If you must disable the entrypoint check to isolate an unrelated problem, override the entrypoint for a single run rather than editing the image: docker compose run --entrypoint node app server.js. Restore the guarded entrypoint immediately afterwards so the protection is not lost across sessions.
Frequently Asked Questions
What is the difference between ${VAR:?err} and ${VAR:-default} in Compose?
${VAR:?err} aborts interpolation and stops docker compose up when the variable is unset or empty, printing err. ${VAR:-default} does the opposite: it substitutes default when the variable is unset or empty and never fails. Use :? for required variables and :- for optional ones. Both also have colon-less forms (${VAR?err}, ${VAR-default}) that treat an empty string as a valid value; the colon variants — which reject empty strings too — are almost always what you want for a startup guard.
Why add an entrypoint check if Compose already guards the variables?
The Compose ${VAR:?} guard only runs when the container is launched through docker compose up. In CI, a docker run, or a Kubernetes Job, Compose is not in the picture, so its interpolation guard never executes. The entrypoint check lives inside the image and runs on every launch path, which is why the two are complementary rather than redundant.
Does an empty string count as "set" for these guards?
It depends on the form. The colon variants used here — ${VAR:?} in Compose and the [ -n "${!key:-}" ] test in the entrypoint — both treat an empty string as missing and fail. That is deliberate: an exported-but-empty variable is almost never intentional and causes the same late crash as an unset one. If you genuinely need to allow an empty value, drop the colon in Compose and use [ -z "${key+x}" ] presence testing in the script instead.
How do I keep the required list from drifting across the three guards?
Treat env-schema.json as the single source of truth. Its required array names every mandatory variable once; generate the entrypoint's REQUIRED array from it (a small jq -r '.required[]' step at build time) and let CI validate against the same file. When you add a variable you edit the schema, and the other two guards inherit the change instead of being maintained by hand.