Definition: Why Docker Does Not See Your .env File
A Docker container is a completely isolated process with its own filesystem, network namespace, and environment. When Docker starts a container from an image, it does not inherit the host machine's environment variables, and it does not automatically read any .env files from your project directory.
Your local Node.js app reads .env because you use dotenv and the file exists on your host machine's disk. Inside Docker, that file does not exist unless you explicitly copy it in or mount it. Even if you do copy it, dotenv still needs to load it — and even then, putting secrets in a Docker image is a serious security mistake.
The correct Docker approach is to inject environment variables at runtime using Docker's built-in mechanisms — ENV in the Dockerfile for defaults, --env-file or -e flags for docker run, or the environment / env_file keys in docker-compose.yml. This way, your app receives process.env.DATABASE_URL without any .env file inside the container at all.
Key Point: Docker containers are isolated. Your .env file does not exist inside the container. You must explicitly inject environment variables using Docker's runtime mechanisms — never by copying the .env file into the image.
What: Docker ARG vs ENV — Build-time vs Runtime
Understanding the difference between ARG and ENV is the single most important concept for fixing process.env issues in Docker.
| Feature | ARG (build-time) | ENV (runtime) |
|---|---|---|
| Available during docker build | Yes | Yes (after ENV instruction) |
| Available in running container | No — not in process.env | Yes — in process.env |
| Syntax in Dockerfile | ARG MY_VAR=default | ENV MY_VAR=value |
| Passed at build time | --build-arg MY_VAR=val | N/A (set in Dockerfile or at runtime) |
| Overridable at runtime | No | Yes — with -e or --env-file |
| Visible in docker history | Yes (if used in RUN commands) | Yes — avoid for secrets |
| Best use case | Base image version, build flags | Runtime defaults (PORT, LOG_LEVEL) |
# Common MISTAKE — using ARG when you need ENV
FROM node:20-alpine
ARG DATABASE_URL # ← This is available during build only
RUN echo $DATABASE_URL # ← Works here, during build
# But process.env.DATABASE_URL will be UNDEFINED in the running container!
COPY . .
RUN npm ci
CMD ["node", "server.js"]# Correct — using ENV for runtime variables
FROM node:20-alpine
# ENV is available in process.env inside the running container
ENV PORT=3000
ENV LOG_LEVEL=info
# Do NOT hardcode secrets here — inject them at runtime instead
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]Valid ARG use case
Selecting the Node.js version: ARG NODE_VERSION=20, then FROM node:{NODE_VERSION}-alpine. Or a build-time flag: ARG BUILD_ENV=production to control which npm scripts run during build.
Valid ENV use case
Runtime defaults that are safe to bake into the image: ENV PORT=3000 NODE_ENV=production LOG_LEVEL=info. These appear in process.env and can be overridden when running the container without rebuilding.
ARG + ENV combo: You can combine both to allow a build-time argument to set an ENV value: ARG DB_URL then ENV DATABASE_URL=$DB_URL. This passes the value from build time into runtime — but be aware it gets baked into the image layer and is visible in docker history. Only use this for non-secret values.
When: Situations That Cause process.env to Be Undefined in Docker
This problem appears in four distinct situations. Knowing which one you're hitting determines the correct fix:
Missing ENV in Dockerfile
Your app expects process.env.PORT but neither the Dockerfile has an ENV PORT=3000 directive nor is the value passed at runtime. The container starts with an empty environment for that variable.
Using ARG where ENV is needed
You declare ARG DATABASE_URL in the Dockerfile and pass --build-arg DATABASE_URL=... at build time. The build succeeds but at runtime, process.env.DATABASE_URL is undefined because ARG variables do not persist into the running container.
Not using --env-file when running docker run
You run docker run myapp without the --env-file .env flag. Your local .env file exists on disk but the container does not know about it — it was never mounted or referenced.
docker-compose.yml missing environment config
You have a docker-compose.yml but forgot to add the environment: section or the env_file: key. Compose will not automatically pass your shell environment or host .env file to the service unless explicitly configured.
Quick diagnosis: Run docker exec <container_id> printenv while your container is running. If your variable is not in the output, it was never injected. Compare against printenv on your host machine to see what the container is missing.
How To Fix It: 4 Methods
1Dockerfile ENV Directive (Non-secret defaults)
Use the ENV instruction in your Dockerfile to set default runtime values. These are baked into the image and appear in process.env automatically. Good for non-sensitive config like port numbers, log levels, and feature flags.
# Dockerfile
FROM node:20-alpine
WORKDIR /app
# Set safe non-secret defaults with ENV
ENV PORT=3000 NODE_ENV=production LOG_LEVEL=info CACHE_TTL=3600
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
# Healthcheck using the PORT env var
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://localhost:${PORT}/health || exit 1
CMD ["node", "server.js"]# Your Node.js server — process.env.PORT is available
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000; // Gets 3000 from the ENV directive
const NODE_ENV = process.env.NODE_ENV; // Gets 'production' from ENV
app.get('/health', (req, res) => {
res.json({ status: 'ok', env: NODE_ENV, port: PORT });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT} in ${NODE_ENV} mode`);
});Override at runtime: ENV values in the Dockerfile are defaults. You can always override them when running the container with -e PORT=8080 or --env-file .env without rebuilding the image.
2docker run --env-file (Development workflow)
Pass your local .env file directly to docker run using the --env-file flag. Docker reads each KEY=VALUE line and injects them as environment variables. The file itself is never copied into the image — it is read on the host at runtime.
# Your local .env file (stays on host, never in image)
DATABASE_URL=postgresql://user:secret@localhost/mydb
REDIS_URL=redis://localhost:6379
STRIPE_SECRET_KEY=sk-test-YOUR_KEY
JWT_SECRET=my-super-secret-key
API_BASE_URL=http://localhost:8080# Build the image (no secrets baked in)
docker build -t myapp .
# Run with env file — Docker reads .env from current directory
docker run --env-file .env -p 3000:3000 myapp
# Or pass specific env file paths
docker run --env-file ./config/.env.staging -p 3000:3000 myapp
# Mix: env file + individual overrides
docker run --env-file .env -e NODE_ENV=production -e LOG_LEVEL=debug -p 3000:3000 myapp
# Verify variables are set inside the running container
docker exec <container_id> printenv DATABASE_URLHow Docker reads --env-file: Docker parses the file line by line, ignoring blank lines and lines starting with #. It does NOT expand shell variables or run subshells — so DATABASE_URL=$(cat secret) will not work. Values are taken literally.
3docker-compose.yml environment / env_file (Recommended for multi-service)
Docker Compose offers two complementary approaches for environment variables. Use them together for the most flexible setup:
# docker-compose.yml
version: '3.9'
services:
app:
build: .
ports:
- "3000:3000"
# Method A: env_file — reads the file and injects all variables
env_file:
- .env # loads base variables
- .env.development # loads dev overrides (if exists)
# Method B: environment — explicit key=value or variable substitution
environment:
- NODE_ENV=development
# Variable substitution: reads from host shell environment
- DATABASE_URL=${DATABASE_URL}
# Or set literal values directly
- REDIS_URL=redis://redis:6379
- LOG_LEVEL=debug
depends_on:
- db
- redis
db:
image: postgres:16-alpine
environment:
- POSTGRES_DB=myapp_dev
- POSTGRES_USER=myuser
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD} # from host env or .env
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
postgres_data:# .env (in the same directory as docker-compose.yml)
# Docker Compose automatically reads this file for variable substitution
# in the compose file itself (the ${VAR} syntax above)
POSTGRES_PASSWORD=dev-password-local
REDIS_PASSWORD=redis-local-secret
DATABASE_URL=postgresql://myuser:dev-password-local@db/myapp_dev
# Note: Docker Compose reads .env for ${VAR} substitution in the YAML.
# For actual container env vars, use env_file: or environment: in the service.# Run and check that env vars are injected correctly
docker compose up
# Verify in a running container
docker compose exec app printenv | grep DATABASE_URL
# Use a custom env file for variable substitution
docker compose --env-file .env.staging up
# Override a single variable for a one-off run
DATABASE_URL=postgresql://localhost/test docker compose upenv_file vs environment precedence: When both are used on the same service, environment: values take precedence over env_file: values for any overlapping keys. Use env_file for the bulk of your config and environment for explicit overrides.
4Docker Secrets (Production — sensitive data)
Docker Secrets is the production-grade solution for sensitive values in Docker Swarm or Kubernetes. Secrets are mounted as files in /run/secrets/ inside the container — they are never exposed as environment variables directly, preventing accidental logging or process inspection leaks.
# Create a Docker secret (Docker Swarm)
echo "postgresql://user:prod-password@db.internal/prod" | docker secret create database_url -
echo "sk_live_your_real_stripe_key" | docker secret create stripe_secret_key -
# List secrets (values are never shown)
docker secret ls# docker-compose.yml with secrets (Docker Swarm mode)
version: '3.9'
services:
app:
image: myapp:latest
secrets:
- database_url
- stripe_secret_key
environment:
- NODE_ENV=production
- PORT=3000
# Do NOT put sensitive values here in production
secrets:
database_url:
external: true # Secret was created with 'docker secret create'
stripe_secret_key:
external: true// Node.js app — read secrets from files (not process.env)
const fs = require('fs');
const path = require('path');
function readSecret(secretName) {
try {
// Docker mounts secrets at /run/secrets/<secret_name>
return fs.readFileSync(
path.join('/run/secrets', secretName),
'utf8'
).trim();
} catch (err) {
// Fallback to process.env for local dev (where secrets aren't mounted)
return process.env[secretName.toUpperCase().replace(/-/g, '_')];
}
}
const DATABASE_URL = readSecret('database_url');
const STRIPE_SECRET = readSecret('stripe_secret_key');
// Now use DATABASE_URL and STRIPE_SECRET as normal stringsCloud alternatives to Docker Secrets: On Kubernetes use kubectl create secret. On AWS use AWS Secrets Manager or Parameter Store. On Google Cloud use Secret Manager. On Azure use Key Vault. These all mount secrets as files or inject them as environment variables with proper access control and audit logging.
Which method to use? Local development → docker-compose with env_file pointing to your .env. CI/CD → inject via your pipeline's secret management (GitHub Secrets, GitLab CI variables). Staging → docker-compose environment with platform secrets. Production → platform secret manager or Docker Secrets.
Common Mistakes to Avoid
Mistake 1: COPY .env into the Docker image
# BAD — Never do this
FROM node:20-alpine
WORKDIR /app
COPY . . # This copies .env into the image!
# OR
COPY .env . # Explicitly worse — definitely don't do this
RUN npm ci
CMD ["node", "server.js"]Why this is dangerous: the .env file with all your secrets becomes part of the image layer permanently. Anyone who can pull the image can run docker history myapp or extract the layer to read the file. Even if you delete it in a later RUN command, it remains in the previous layer.
# GOOD — add .env to .dockerignore and pass vars at runtime
# .dockerignore
.env
.env.local
.env.*.local
.git
node_modules
*.log
# Dockerfile — no COPY .env
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . . # .dockerignore prevents .env from being copied
CMD ["node", "server.js"]
# Run with variables injected at runtime
docker run --env-file .env -p 3000:3000 myappMistake 2: Using ARG instead of ENV for runtime variables
# BAD
ARG DATABASE_URL
# process.env.DATABASE_URL === undefined in the running container
# GOOD — convert ARG to ENV if needed for runtime
ARG DATABASE_URL_BUILD
ENV DATABASE_URL=$DATABASE_URL_BUILD
# But this bakes the value into the layer — only for non-secretsMistake 3: Forgetting required vars in docker-compose
# BAD docker-compose.yml — no environment config at all
services:
app:
build: .
ports:
- "3000:3000"
# Missing: env_file or environment — app will have empty process.env!
# GOOD
services:
app:
build: .
ports:
- "3000:3000"
env_file:
- .envMistake 4: Hardcoding secrets as ENV in Dockerfile
# BAD — secrets are visible in docker history and any image layer inspection
ENV DATABASE_URL=postgresql://user:realpassword@prod.db.internal/prod
ENV STRIPE_SECRET_KEY=sk_live_actual_key_here
# GOOD — ENV only for safe defaults, inject secrets at runtime
ENV PORT=3000
ENV NODE_ENV=production
# DATABASE_URL and STRIPE_SECRET_KEY injected via --env-file at runtimeWhy: The Security Case for Runtime Injection
The core principle behind all four methods is the same: never bake secrets into Docker images. Here is why this matters in practice:
Image layers are permanent
Docker images are built from immutable layers. If you COPY .env or set ENV SECRET=value in a layer, that data exists permanently in the image — even if you delete or overwrite it in a later layer. The previous layer is still in the image filesystem and can be extracted.
docker history exposes ENV values
Run docker history --no-trunc myimage and every ENV instruction you set is visible in plain text — including database passwords and API keys. Anyone with read access to the image (or the registry) can extract them this way.
Registry exposure
If you push an image to Docker Hub, ECR, GCR, or any registry — even a private one — you are trusting that registry's access controls with every secret baked into the image. A registry misconfiguration, a stolen token, or an insider threat exposes everything. Runtime injection limits the blast radius.
Runtime injection is the secure model
When secrets are injected at runtime via --env-file, docker-compose, or a secret manager, the image itself is clean and can be pushed publicly if needed. Secrets are controlled by the deployment environment and can be rotated without rebuilding the image. This is the twelve-factor app model.
# Demonstrate the risk — inspect a naive image
docker build -t naive-app-with-secrets .
docker history --no-trunc naive-app-with-secrets
# Output reveals secrets:
# IMAGE CREATED CREATED BY SIZE
# sha256:abc123 2 minutes ago ENV DATABASE_URL=postgresql://user:REALPASS@... 0B
# sha256:def456 2 minutes ago ENV STRIPE_KEY=sk_live_actual_key 0B
#
# Anyone who can pull this image can see your production credentials.
# Correct approach: clean image, secrets at runtime
docker build -t secure-app .
docker history --no-trunc secure-app
# Only shows: ENV PORT=3000 NODE_ENV=production — no secretsThe rule: Docker images should be immutable, portable artefacts that contain only your application code and its dependencies. Configuration and secrets are injected by the deployment environment at runtime. This is the foundation of secure container operations.
Frequently Asked Questions
Why is process.env undefined in Docker?
Docker containers run in completely isolated environments. Your local .env file is not automatically copied or mounted into the container. Unless you explicitly pass environment variables using Dockerfile ENV directives, the docker run --env-file flag, docker-compose environment section, or docker run -e flags, process.env will return undefined for any variables your app expects.
How do I pass environment variables to a Docker container?
Four main methods: (1) Dockerfile ENV PORT=3000 for non-secret defaults baked into the image; (2) docker run --env-file .env myapp to pass all variables from a local file at runtime; (3) docker-compose.yml env_file: or environment: for orchestrated deployments; (4) Docker Secrets for production sensitive data mounted as files. Use --env-file or docker-compose for development, platform secrets for production.
What is the difference between ARG and ENV in Dockerfile?
ARG defines a build-time variable available only during docker build — it is NOT available to the running container via process.env. ENV defines a runtime variable that IS available to the container and persists in the image. Use ARG for build-time customisation (base image version, compile flags). Use ENV for runtime config that process.env should see. The most common bug is declaring ARG DATABASE_URL and wondering why the running app can't see it.
How do I use .env file with Docker Compose?
Two approaches work together. First, the env_file: key injects every variable from the file directly into the container: env_file: [.env]. Second, the environment: key with ${VAR} syntax reads from your shell or a .env file in the same directory as docker-compose.yml (Docker Compose reads this file automatically for variable substitution). Combine both: use env_file for bulk config, use environment for explicit overrides.
Should I COPY .env file into Docker image?
No — never COPY .env into your Docker image. The .env file containing secrets becomes baked into an image layer permanently, visible via docker history and extractable by anyone with image access. Even deleting it in a later RUN layer does not help — the previous layer still contains it. Instead, add .env to .dockerignore and inject secrets at runtime using --env-file, docker-compose, or your cloud platform's secret manager.
Related guides & tools
More developer guides and free tools:
Share this article with Your Friends, Collegue and Team mates
Stay Updated
Get the latest tool updates, new features, and developer tips delivered to your inbox.
Occasional useful updates only. Unsubscribe in one click — we never sell your email.
Feedback for process.env Undefined in Docker Guide
Tell us what's working, what's broken, or what you wish we built next — it directly shapes our roadmap.
Good feedback is gold — a rough edge you hit today could be smoother for everyone tomorrow.
- Feature ideas often jump the queue when lots of you ask.
- Bug reports with steps get fixed faster — paste URLs or examples if you can.
- Name and email are optional; we won't use them for anything except replying if needed.