I have seen three production incidents caused by the same mistake: someone committed a .env file with database credentials to the repo, the Docker image packaged it, and the variables leaked into the registry. It was not malware. It was not an attack. It was laziness with env vars. The container started, worked fine, and nobody noticed until a security audit six months later.
Environment variables look simple. You define them, the app reads them, done. But in production with Docker there are layers: the local .env file, docker run --env, env_file in Compose, ENV in the Dockerfile, secrets from the orchestrator. Each mechanism has a different use case, and when you mix them up you get silent bugs or leaks.
This guide sorts out when to use each mechanism and shows how to configure env vars in Node.js in a way that works on both your laptop and a production cluster.
Quick answer
To manage environment variables in Docker for production: never put .env inside the image, never pass secrets via ENV in the Dockerfile (they are recorded in image layers), use docker run --env-file or the secrets mechanism from your deployment platform. In application code, read process.env and validate values at startup with a schema, not at runtime.
Key takeaways
.envis for local development. In production, it should not exist inside the container.ENVin the Dockerfile defines build values. Do not put credentials there. Anyone with access to the image can inspect layers withdocker history.- Use
--env-fileor environment variables injected by the platform at deploy time. - Validate env vars at application startup. If a required variable is missing, the process should fail immediately, not after 200 requests.
- In Node.js, use a validation module (Zod,
envalid) instead of scatteringprocess.env.X ?? 'default'throughout the code.
When this guide applies
It works for any application running in a Docker container that needs external configuration: Node.js APIs, Python services with FastAPI, Go applications, workers consuming queues. The pattern is the same: the application reads from the environment, the infrastructure injects the values.
If you use docker-compose in production (which I do not recommend, but many people do), the guide applies as well. The difference is that Compose has its own env_file mechanism and interpolation that adds an extra layer of complexity.
When not to use this pattern
If the application is serverless (Lambda, Cloud Functions), the way you pass configuration changes entirely. AWS uses Parameter Store or Secrets Manager integrated into the event. Google Cloud uses Secret Manager with volume mounts. The concept is the same (separate code from configuration), but the mechanisms differ.
If the project uses config files in YAML/JSON (Kubernetes ConfigMaps, for example), the 12-factor pattern still holds, but injection happens via volume mount or pod sub-resource, not via --env.
Before you start
- A Node.js project (or any language) that uses environment variables
- Docker installed locally
- A Guara Cloud account for production deployment
1. The problem with .env in Docker images
The most common Dockerfile I see in open source projects:
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "server.js"] The COPY . . copies everything. Including .env. That file is now part of an image layer. Anyone who runs docker pull on the image can extract the contents with docker create + docker cp. The secrets are in the image history forever, even if you delete .env later and rebuild. The old layers are still there.
2. Keep .env out of the build
Add .env to .dockerignore:
node_modules
.env
.env.*
.git
Dockerfile
docker-compose*.yml Now COPY ignores the file. But that only solves half the problem. You still need the values to reach the container at runtime.
3. Inject variables at runtime, not build time
The right way with docker run:
docker run --rm \
--env-file .env \
-p 3000:3000 \
my-app:latest The --env-file reads the file on the host and injects values as environment variables into the container. The .env never enters the image. If the file changes, you do not need to rebuild anything. Just restart the container.
For docker-compose:
services:
api:
image: my-app:latest
env_file:
- .env
ports:
- "3000:3000" Compose resolves ${VAR} interpolation inside the compose file itself, which sometimes causes confusion. If you have a .env at the project root, Compose reads it automatically to substitute variables in the YAML. That is separate from env_file which injects values into the container. Two different mechanisms, same file. Pay attention to that.
4. Validate env vars at startup
The most common mistake I find in Node.js codebases: the application reads process.env.DATABASE_URL in some random function, does not validate it, and only discovers the variable was empty when the first query fails. In production, this can mean 4 thousand requests with errors before anyone looks at the logs.
Use boot-time validation:
import { z } from 'zod';
const envSchema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(['development', 'production', 'test']),
REDIS_URL: z.string().url().optional(),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment variables:');
console.error(parsed.error.format());
process.exit(1);
}
export const env = parsed.data; If DATABASE_URL is not defined or is not a valid URL, the process dies with exit code 1 and a clear message. The orchestrator (Kubernetes, Docker Swarm, Guara Cloud) tries to restart, fails again, and you see on the dashboard that there is a configuration problem. Much better than the container coming up “healthy” and failing silently on every request.
5. Secrets in the Dockerfile: what to do and what not to do
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"] Notice there is no ENV DATABASE_URL=... anywhere. The Dockerfile defines the image (the code and its dependencies). Environment variables with sensitive values come from outside, at the moment the container starts.
If you need a build-time variable (like NEXT_PUBLIC_API_URL for Next.js), use ARG in the Dockerfile and pass it with --build-arg:
# ARG: available only during build, not in the final image
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
# ENV: recorded in the image layer. Use only for non-sensitive values.
ENV NODE_ENV=production ARG does not persist in the image. ENV persists. For public build values (API URLs, frontend feature flags), ARG + ENV works. For secrets, do not even think about it.
6. Deploying on Guara Cloud: managed env vars
On Guara Cloud, you do not need an .env file anywhere. Variables are configured through the dashboard and injected into the container automatically:
Configure env vars in the dashboard
- Open the service in the Guara Cloud dashboard
- Click the "Environment Variables" tab
- Add each variable (name and value)
- Click "Save and Restart"
- The container restarts with the new variables injected
Example configuration for a Node.js API
| Variable | Value |
|---|---|
DATABASE_URL | postgresql://user:***@host:5432/db |
NODE_ENV | production |
LOG_LEVEL | info |
JWT_SECRET | (generated automatically, never commit) |
The platform keeps “DATABASE_URL” separate from the code and the image. If you need to change the connection string (migrate to a different database, for example), you change the value in the dashboard and the container restarts. No rebuild, no new deploy, no risk of leaking into the registry.
Troubleshooting
Common problems
- Problem The application cannot see the environment variable inside the container
- Solution Check that you are using --env-file or --env in docker run. If using docker-compose, confirm that env_file points to the correct path relative to the compose file.
- Problem docker-compose resolves ${VAR} and replaces it with an empty string
- Solution Compose tries to substitute ${VAR} in the YAML before anything else. If VAR is not defined on the host, the result is an empty string. Use $$VAR (double dollar sign) for a literal, or define the variable on the host before running compose up.
- Problem The variable is in .env but the container reads the old value
- Solution If you use ENV in the Dockerfile, it only has priority over --env-file when the env-file does not define that same variable. Actually, --env-file overrides ENV from the Dockerfile. Confirm there is no hardcoded ENV in the Dockerfile with the same name.
- Problem Secrets appear in docker inspect or docker history
- Solution This happens when you use ENV in the Dockerfile to define secrets. Remove the ENV, rebuild the image, and pass variables only via --env-file or through the platform dashboard. For old images, you need to rebuild from scratch (layers with secrets cannot be removed).
FAQ
What is the difference between ENV in the Dockerfile and --env in docker run?
ENV in the Dockerfile records the variable in the image. Anyone with access to the image can read it with docker inspect. --env in docker run injects the variable only in that container at runtime, without recording it in the image. For production, always use runtime injection.
Can I use dotenv in Node.js alongside Docker?
You can, but you should not in production. The dotenv package reads a .env file from disk. If the file does not exist (because it is in .dockerignore), dotenv simply loads nothing and your code keeps depending on process.env, which was injected by the platform. In production, configure dotenv with override: false so real environment variables take priority.
How do I rotate secrets without downtime?
On platforms like Guara Cloud, you update the variable in the dashboard and the container restarts with the new value. The restart takes a few seconds. For zero downtime, use rolling updates with multiple replicas (the platform restarts one pod at a time).
What is the best way to share env vars between development and production?
Do not share them. Use different values. The local .env has development credentials. The production platform has real credentials. What should be identical across environments is the list of variable names, not the values.
Wrapping up
Environment variables in Docker have three rules that are hard to get wrong: never put secrets in the image, validate configuration at application boot, and let the deployment platform handle injection in production. If you follow this, you eliminate 90% of the problems I see in codebases from teams migrating to containers.
Manage env vars without the headache
On Guara Cloud, environment variables stay safe in the dashboard and are injected into the container automatically. No .env in the repo, no rebuild when values change.