env.dev

Docker env variables not working in containers

Docker env variables not working usually comes down to ARG vs ENV scope, exec-form CMD skipping the shell, or .env only feeding Compose interpolation.

By env.dev Updated

Your container starts, but docker exec app printenv DATABASE_URL comes back empty — or worse, the process logged a literal $DATABASE_URL straight into a connection string. Almost every "Docker env variable not working" report traces to one of eight causes: an ARG used where ENV was needed, an exec-form CMD that never runs a shell, a .env file that only feeds Compose interpolation, env-file quoting rules, multi-stage resets, or a precedence collision. None of these are bugs — they are documented Docker behaviour that bites the same way on Engine 20.10 through 28.x (current in 2026). This guide is the failure-mode companion to the Docker environment variables guide and the Docker Compose env guide; start here when something that should work doesn't.

TL;DR

  • ARG is build-time only and never exists in the running container — use ENV for anything a container needs at runtime.
  • Exec-form CMD ["node","app.js"] does not invoke a shell, so $VAR is passed as a literal string (moby/moby #42937).
  • A Compose .env file feeds ${VAR} interpolation in the YAML only — it is never auto-injected into containers.
  • docker run has no auto-loaded .env; you must pass --env-file explicitly.
  • Runtime precedence: -e > --env-file > Dockerfile ENV.

Symptom → cause → fix at a glance

SymptomCauseFix
printenv shows the variable empty in the containerIt is declared as ARG — build-time only, never embedded in the imageUse ENV for runtime values (or copy ARG into ENV)
Process receives a literal $VAR stringCMD/ENTRYPOINT in exec form (JSON array) does not run a shellWrap in a shell: CMD ["sh","-c","... $VAR"]
Var in .env but missing inside a Compose container.env only feeds ${VAR} interpolation in the compose fileAdd it under environment: or env_file:
Compose warns "variable is not set. Defaulting to a blank string"The interpolation source (shell / .env) has no such keyExport it, or add it to .env / --env-file
Value keeps its quotes or is cut off at a #env-file format is not shell: quotes are literal, inline # rules differDrop quotes; in docker run --env-file only a leading # is a comment
ENV is gone in the final image of a multi-stage buildA new FROM resets the stage; ENV is only inherited from ancestorsRe-declare ENV (and ARG) in the final stage
A --build-arg value is missing at runtimeIt was build-time only and was never promoted to ENVInject at runtime with -e / --env-file, or bake with ENV
An override is ignored / the wrong value winsPrecedence collision across -e, env_file, ENVInspect with docker inspect or docker compose config

Why is my ARG empty at runtime?

Because that is exactly what ARG is for. The Dockerfile reference is blunt: "Unlike ENV, an ARG variable is not embedded in the image and is not available in the final container." An ARG lives only during the build, from its declaration line onward. ENV, by contrast, persists into every layer and is present when a container starts.

dockerfile
# WRONG — DATABASE_URL is build-time only, gone at runtime
ARG DATABASE_URL
CMD ["node", "server.js"]   # process.env.DATABASE_URL is undefined

# RIGHT — promote the build arg into a runtime ENV
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL
CMD ["node", "server.js"]

The flip side is also true: ENV set in one place is not visible in an earlier build stage, and an ARG declared before the first FROM "can't be used in any instruction after a FROM" unless you redeclare a bare ARG inside the stage. Keep secrets out of both — ARG values surface in docker history and provenance attestations, which is why the build docs call them "inappropriate for passing secrets." Use BuildKit --mount=type=secret instead, as covered in the Docker environment variables guide.

Why does my container print $VAR literally?

This is the single most surprising Docker env gotcha, and it is not a bug. The exec form of CMD and ENTRYPOINT — the JSON array form — runs your binary directly via execve with no shell in between. The Dockerfile reference states it plainly: "Using the exec form doesn't automatically invoke a command shell. This means that normal shell processing, such as variable substitution, doesn't happen." So $PORT is handed to your program as the four characters $PORT, not its value. This is the cause behind moby/moby #42937, where ENV, ARG, and even CMD ["./${FOOBAR}"] all expand to nothing.

dockerfile
# WRONG — exec form, no shell: passes the literal "$PORT"
CMD ["node", "server.js", "--port", "$PORT"]

# RIGHT — explicitly run a shell so it expands
CMD ["sh", "-c", "node server.js --port $PORT"]

# BETTER — exec replaces the shell so your app stays PID 1 and gets SIGTERM
CMD ["sh", "-c", "exec node server.js --port $PORT"]

The naive fix — switching to the shell form CMD node server.js --port $PORT — also works because the shell form runs under /bin/sh -c. But the shell then becomes PID 1 and may not forward SIGTERM to your app, so graceful shutdown breaks. The exec prefix above keeps signal handling intact.

Why isn't my .env file reaching the container?

Two different things are both called ".env," and conflating them is the most common Compose ticket. The auto-loaded .env file next to your compose.yaml only feeds ${VAR} interpolation inside the YAML — it is "not automatically injected into containers." To get a value into the container you must reference it explicitly under environment: or load a file with env_file:.

yaml
# .env (interpolation only — NOT injected)
TAG=16
DATABASE_URL=postgres://db:5432/app

services:
  app:
    image: myapp:${TAG}          # .env feeds this substitution
    environment:
      - DATABASE_URL=${DATABASE_URL}   # explicit bridge into the container
    env_file:
      - ./app.env                 # this file IS injected into the container

Plain docker run has no auto-loaded .env at all — you must pass --env-file ./app.env by hand. If Compose prints WARN[0000] The "DATABASE_URL" variable is not set. Defaulting to a blank string., the interpolation source is missing, not the container config. The full breakdown of all five Compose mechanisms lives in the Docker Compose environment variables guide.

What is the interpolation and runtime precedence order?

Keep two precedence chains straight. The first decides which value fills ${VAR} when Compose parses the file; the second decides which value the container actually receives.

ChainOrder (highest first)
Compose interpolationShell environment → --env-file → .env
docker run runtime-e / --env → --env-file → Dockerfile ENV
Compose runtimerun -e → environment:/env_file: (interpolated from shell) → environment: literal → env_file: → image ENV

When a value surprises you, print the resolved truth instead of guessing:

bash
# Compose: see every value after interpolation
docker compose config

# Running container: what it actually got
docker exec my-container printenv DATABASE_URL

# Image vs runtime: ENV baked into the image
docker inspect my-app --format '{{json .Config.Env}}'

Why are quotes or comments mangling my values?

An env file is not a shell script, and the parser differs from one tool to the next. With docker run --env-file, quotes are literal characters and only a # at the start of a line is a comment — "a # appearing anywhere else in a line is treated as part of the variable value." That asymmetry produces values you never intended.

ini
# app.env
# docker run --env-file: NODE_ENV becomes "production" WITH the quotes
NODE_ENV="production"

# The # here is NOT a comment — TOKEN becomes: abc123 # prod key
TOKEN=abc123 # prod key

# Correct
NODE_ENV=production
TOKEN=abc123

Compose's env_file: parser is more forgiving (it strips matched quotes and honours inline comments after whitespace), so the very same file can behave differently under docker run versus docker compose. Run your file through the env validator to catch stray quotes, spaces around =, and CRLF endings before they reach a container. For the full syntax reference, see the .env file guide.

Why do my variables vanish in a multi-stage build?

Each FROM begins a fresh stage. A stage only inherits ENV "set by its parent stage or any ancestor" — and an unrelated final stage that copies artifacts with COPY --from=builder is not a descendant of the builder, so none of the builder's ENV or ARG carry over. Redeclare what the runtime stage needs.

dockerfile
FROM node:20 AS builder
ARG APP_VERSION=0.0.0
ENV NODE_ENV=production
RUN npm run build

FROM node:20-slim
# The builder's ENV/ARG are NOT inherited here — redeclare them
ARG APP_VERSION=0.0.0
ENV NODE_ENV=production
ENV APP_VERSION=$APP_VERSION
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]

When is this not actually a bug?

  • Exec form not expanding variables — intentional. It keeps your app as PID 1 with correct signal handling. Reach for a shell wrapper only when you genuinely need expansion.
  • ARG absent at runtime — by design, and a security feature. Build args are for build inputs, not runtime config or secrets.
  • .env not auto-injected by Compose — deliberate separation of "configure the compose file" from "configure the container." Don't fight it with hacks; use env_file:.
  • Secrets you can read with docker inspect — that is the system telling you env vars are the wrong home for credentials. Use Docker/BuildKit secrets instead.

Frequently Asked Questions

Why is my Docker environment variable empty inside the container?

The most common cause is declaring it as ARG, which is build-time only and never embedded in the image. Use ENV for any value the container needs at runtime, or copy the ARG into an ENV. The second most common cause is an exec-form CMD that never runs a shell to expand it.

Why does my container print $VAR literally instead of the value?

The exec form of CMD/ENTRYPOINT (the JSON array form) runs your binary directly without a shell, so $VAR is passed as a literal string. Use CMD ["sh", "-c", "exec yourcmd $VAR"] to get a shell to expand it while keeping your app as PID 1.

Does the .env file get passed into my Docker container?

Not automatically. In Docker Compose, the auto-loaded .env file only feeds ${VAR} interpolation in the compose file; to reach the container you must reference it under environment: or load a file with env_file:. Plain docker run ignores .env entirely unless you pass --env-file.

Why does docker run --env-file keep my quotes or cut the value at a #?

An env file is not a shell. With docker run --env-file, quotes are literal characters and only a # at the start of a line is a comment — a # mid-line becomes part of the value. Remove surrounding quotes and move trailing comments to their own line.

Why do ENV values disappear in a multi-stage build?

Every FROM starts a new stage that only inherits ENV from its ancestor stages. A final runtime stage that copies artifacts with COPY --from=builder is not a descendant of the builder, so its ENV and ARG do not carry over. Redeclare them in the final stage.

Which value wins when a variable is set in several places?

For docker run: -e beats --env-file beats Dockerfile ENV. For Compose, a run -e flag wins, then environment:/env_file: interpolated from the shell, then explicit environment: values, then env_file:, then the image ENV. Verify with docker inspect or docker compose config.

References

Catch quoting, spacing, and CRLF problems before they hit a container with the env validator.

Was this helpful?

Frequently Asked Questions

Why is my Docker environment variable empty inside the container?

The most common cause is declaring it as ARG, which is build-time only and never embedded in the image. Use ENV for any value the container needs at runtime, or copy the ARG into an ENV. The second most common cause is an exec-form CMD that never runs a shell to expand it.

Why does my container print $VAR literally instead of the value?

The exec form of CMD/ENTRYPOINT (the JSON array form) runs your binary directly without a shell, so $VAR is passed as a literal string. Use CMD ["sh", "-c", "exec yourcmd $VAR"] to get a shell to expand it while keeping your app as PID 1.

Does the .env file get passed into my Docker container?

Not automatically. In Docker Compose, the auto-loaded .env file only feeds ${VAR} interpolation in the compose file; to reach the container you must reference it under environment: or load a file with env_file:. Plain docker run ignores .env entirely unless you pass --env-file.

Why does docker run --env-file keep my quotes or cut the value at a #?

An env file is not a shell. With docker run --env-file, quotes are literal characters and only a # at the start of a line is a comment — a # mid-line becomes part of the value. Remove surrounding quotes and move trailing comments to their own line.

Why do ENV values disappear in a multi-stage build?

Every FROM starts a new stage that only inherits ENV from its ancestor stages. A final runtime stage that copies artifacts with COPY --from=builder is not a descendant of the builder, so its ENV and ARG do not carry over. Redeclare them in the final stage.

Which value wins when a variable is set in several places?

For docker run: -e beats --env-file beats Dockerfile ENV. For Compose, a run -e flag wins, then environment:/env_file: interpolated from the shell, then explicit environment: values, then env_file:, then the image ENV. Verify with docker inspect or docker compose config.

Stay up to date

Get notified about new guides, tools, and cheatsheets.