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.

Where an unset variable finally surfaces A four-stage boot flow showing the process running healthily until it dereferences the missing value and crashes. The Late-Failure Path Start var is undefined Listen :8080 looks healthy Connect DB reads undefined Crash exit code 1 The error is reported three stages after the real fault.
Without a guard, the missing value travels through three boot stages before it is dereferenced and crashes.

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.

  1. Make Compose itself refuse to start when a required variable is missing, using the ${VAR:?error} mandatory-variable syntax. The :? form fails interpolation and aborts up when 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}
  1. 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.

  1. Wire the entrypoint and a CI schema check so the gate runs before the image is ever deployed. The CI step validates the .env file 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
}
Three guard layers in launch order A vertical sequence showing the schema, compose interpolation, and entrypoint checks each stopping a missing variable before application code runs. Defense In Depth 1 — CI: ajv validates .env schema 2 — Compose: ${VAR:?err} at up 3 — Entrypoint: required-list check App starts only if all pass
Each guard covers a launch path the one above it might miss; the app boots only after all three pass.

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.

Required or optional decision A decision splitting variables into hard-guarded required values and defaulted optional values. Guard Or Default? Is there a safe default for this variable? No add to required guard Yes default in code, no guard
Only variables with no safe fallback belong in the hard guard; everything else gets a code default.

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

  1. 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.
  2. Add a pre-commit hook running ajv validate against .env to catch omissions before push, so a teammate never commits a compose change that references a variable the schema does not know about.
  3. Use ${VAR:?} for every truly required variable in Compose so the failure surfaces at up time, 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.

Seconds until a missing variable is reported Bar chart comparing report latency for an unguarded app, a compose guard, and a CI schema gate. Time To Actionable Error unguarded app 10.0s compose guard 0.3s CI schema gate 0.15s (pre-deploy)
The guards report the same fault roughly thirty times sooner, and the CI gate catches it before deploy at all.

Platform caveats

WSL2: normalize line endings (core.autocrlf=input) so a trailing \r on a value does not make a present variable read as malformed — a DATABASE_URL=postgres://...\r will pass the entrypoint -n check but fail the schema pattern match, producing a confusing mismatch between the two guards. macOS (Docker Desktop): avoid relying on host shell inheritance; pass --env-file so the guard validates the file the container actually receives, not whatever happens to be exported in your login shell. Apple Silicon (ARM64): the ajv-cli install must resolve a native-free build; pin it in package.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.