
Docker Environment Variables: Best Practices for 2026
Learn to master Docker environment variables. Covers ENV, ARG, docker run, Compose, & secure secrets with examples and 2026 best practices.
You build an image, run the container locally, and everything looks fine. Then staging fails because DATABASE_URL came from a different place than you expected, a frontend build can't see the variable it needs, or someone slipped an API key into a Compose file and called it done.
That's why Docker environment variables feel simple right up until they aren't. The syntax is easy. The decisions behind it aren't. The struggle isn't remembering -e or ENV; it arises from mixing up build time vs runtime and configuration vs secrets.
If you get those two distinctions right, most Docker configuration problems become predictable.
Table of Contents
- Why Docker Environment Variables Get So Confusing
- The Foundation Build-Time vs Runtime Variables in Dockerfiles
- Injecting Configuration with docker run and Compose
- Managing Secrets Securely The Right and Wrong Way
- Practical Workflows for Local Development and CI/CD
- Key Takeaways and Best Practices Checklist
Why Docker Environment Variables Get So Confusing
Most Docker variable bugs don't come from Docker being inconsistent. They come from developers treating every variable as the same kind of thing.
A version number used during docker build is not the same as a database password injected when the container starts. A default log level baked into an image is not the same as a frontend variable that must exist during static asset generation. Once those concerns get blurred together, containers start behaving differently across laptops, CI runners, and production hosts.
Docker also gives you multiple paths that look similar on the surface. You can use ARG, ENV, docker run -e, --env-file, Compose environment, Compose env_file, and shell substitution. That's a lot of overlap. Docker's own guidance warns that users need to understand precedence and the difference between build-time and runtime configuration because this isn't just syntax. It affects whether apps build at all and whether values get baked into images through the wrong mechanism, as noted in the Docker Compose environment variable best practices.
Practical rule: Ask two questions before you set any variable. Does the app need it at build time or only when the container starts? Is it non-sensitive config or a secret?
That simple split gives you a decision framework:
- Build-time and non-sensitive usually belongs in
ARG. - Runtime and non-sensitive usually belongs in
ENV, Composeenvironment, or runtime injection. - Sensitive values should be handled separately from ordinary Docker environment variables.
Teams get into trouble when they skip that classification step and default to “just put it in .env.” That works for small experiments. It breaks down fast once you have multiple services, static frontend builds, shared CI pipelines, and production credentials.
The Foundation Build-Time vs Runtime Variables in Dockerfiles
The Dockerfile is where confusion starts for a lot of people because ARG and ENV look similar but behave very differently.

What ARG actually does
ARG exists for the image build process. It's available while Docker is building layers, and it's useful for values like version tags, build switches, or package sources.
A clean example looks like this:
FROM node:20-alpine
ARG APP_VERSION=dev
ARG BUILD_MODE=production
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN echo "Building ${APP_VERSION} in ${BUILD_MODE} mode"
RUN npm run build
This is the right place for values that affect how the image is created. It is not the right place for secrets you want to protect, and it is not a good replacement for runtime application settings.
The key point is simple. If your container starts later and the application needs that value then, ARG alone won't help.
What ENV actually does
ENV creates a variable that persists in the image and is available inside running containers unless something overrides it later.
That makes it useful for defaults:
FROM node:20-alpine
WORKDIR /app
COPY . .
ENV NODE_ENV=production
ENV PORT=3000
CMD ["node", "server.js"]
This works well for stable, non-sensitive defaults that make the image runnable out of the box. A default port is fine. A default log level is fine. A hardcoded database password is not.
If you put a secret in
ENV, you haven't “configured” it. You've baked it into the image lifecycle.
A practical Dockerfile pattern
The most useful pattern is combining the two. Keep build-time flexibility in ARG, then promote a safe default into ENV when the running app should inherit it.
FROM python:3.12-slim
ARG APP_ENV=production
ENV APP_ENV=${APP_ENV}
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "app.py"]
That gives you a build that can vary by environment without forcing you to hardcode every runtime value. It also stays readable. Someone opening the Dockerfile can see what the image expects.
Docker practitioners generally land on a reliable workflow here: keep build-time values in ARG, runtime defaults in ENV, and sensitive values out of the image so they can be injected at container start. That separation avoids one of the most common failures in containerized apps, where build-only variables get confused with operational settings or end up embedded in image layers, as described in this Docker environment variable workflow guide.
A few rules hold up well in real projects:
- Use
ARGfor build switches like version labels, base image variants, or frontend build flags. - Use
ENVfor safe defaults the application can run with before production overrides. - Don't store secrets in the Dockerfile because image-layer defaults are easy to leak and hard to revoke.
- Keep frontend builds in mind because some values must exist during asset compilation, not later when the container starts.
That last point matters a lot for React, Next.js, and similar stacks. If a value is statically replaced during the build, runtime injection won't rescue you. You need to decide early whether the variable belongs to the build pipeline or the deployment environment.
Injecting Configuration with docker run and Compose
Once the image exists, the job changes. You're no longer describing how to build software. You're telling a specific container how to behave in a specific environment.
Single container commands
For ad hoc testing or simple deployments, docker run -e is still the fastest way to inject a value:
docker run -e LOG_LEVEL=debug myapp
That's great for one-off overrides, local debugging, or proving that the application reacts correctly to a setting. It's less great when you've got a long list of variables and want repeatability.
That's where --env-file helps:
docker run --env-file app.env myapp
Now you can keep non-sensitive runtime config in a file that's easier to review and reuse. For a solo app on a laptop, that may be enough.
Compose for multi-service apps
Multi-container setups need more structure. In Compose, the two workhorses are environment and env_file.
services:
app:
image: myapp:latest
environment:
LOG_LEVEL: info
APP_ENV: staging
env_file:
- ./app.env
That looks straightforward, but at this point, many teams accidentally create hidden behavior. A shared .env or env file can bleed values across services if you aren't careful. In practice, the cleaner approach is to scope values per service and use override files for local-only differences. Docker and community guidance also warn that a global .env is easy to misuse as a catch-all, even though that often spreads config farther than intended, as discussed in Configu's Docker environment variable guidance.
If you're splitting configuration across multiple Compose files, this guide on Docker Compose multiple files is a useful reference for keeping overrides readable instead of turning them into guesswork.
Docker Variable Injection Methods at a Glance
| Method | Scope | Best For | Example |
|---|---|---|---|
ARG |
Build only | Version switches, build flags | docker build --build-arg APP_VERSION=1.2.0 |
Dockerfile ENV |
Image default and runtime | Safe defaults inside the image | ENV PORT=3000 |
docker run -e |
Single container start | Quick overrides, local testing | docker run -e LOG_LEVEL=debug myapp |
docker run --env-file |
Single container start from file | Reusable local or server config | docker run --env-file app.env myapp |
Compose environment |
Per service | Explicit service config | environment: { APP_ENV: staging } |
Compose env_file |
Per service from file | Shared non-sensitive settings | env_file: [./app.env] |
The part people miss is precedence. When the same variable is defined in multiple places, Docker doesn't merge them semantically. It picks a winner. The precedence chain is clear: -e overrides Compose environment, which overrides env_file, which overrides Dockerfile ENV, as described in this precedence summary.
How to debug what the container actually received
When a variable behaves oddly, stop reading files and inspect the container that's running.
Use checks like these:
- Inspect inside the container with
docker exec mycontainer env - Check one value directly with
docker exec mycontainer printenv APP_ENV - Inspect container config with
docker inspect - Resolve Compose before launch with
docker compose config
The fastest fix for a Docker env bug is usually to inspect the effective environment, not to stare harder at YAML.
Also validate required values at startup. If your application needs DATABASE_URL, fail immediately when it's missing. Silent fallback behavior creates the worst kind of deployment problem: containers that start successfully but point at the wrong backend.
Managing Secrets Securely The Right and Wrong Way
Teams often start by putting secrets in Docker environment variables because it's convenient. It works. It's also one of the easiest ways to leak credentials in a containerized system.

Why environment variables are the wrong place for secrets
Security guidance is consistent on this point. Secrets placed in Docker environment variables can show up in process lists, docker inspect output, orchestration UIs, and logs. Docker's secrets model exists partly because env vars have too many inspection and leakage paths. Docker also documents that secrets are handled separately rather than set directly as environment variables, and a single secret can hold up to 500 KB while being managed in memory instead of persisted on disk, as covered in this Docker secret exposure analysis.
That doesn't mean environment variables are bad. It means they're the wrong transport for high-value data like:
- Database passwords
- API keys
- Access tokens
- Encryption keys
- TLS private material
If your app reads process.env.DB_PASSWORD, that may be technically simple, but the operational exposure is wider than many teams expect.
For engineering managers or security leads trying to formalize policy, this broader guide on preventing data breaches for leaders is worth reading because it connects secret handling mistakes to access control and organizational process, not just code-level hygiene.
A short visual summary helps when you need to explain this to a team:
What Docker Secrets changes
Docker's native answer is to use secrets as mounted files rather than ordinary Docker environment variables. That changes the threat model in important ways.
Instead of doing this:
environment:
DB_PASSWORD: supersecret
you configure the service to receive a secret file and let the application read from that file path. Operationally, this is better because the secret is granted only to allowed services, and Docker treats it as secret material rather than routine container configuration.
That approach also scales better when values rotate. You aren't rebuilding images to change a password. You're updating the secret and redeploying the service.
Where teams still go wrong
Even after they know the rule, teams still make three repeat mistakes:
- They treat
.envas a secret vault. It isn't. It's just a convenient file format. - They hardcode a fallback secret in the Dockerfile. That creates a permanent liability in image history.
- They mix secret delivery with ordinary config delivery. The result is inconsistent handling across environments.
Keep non-sensitive configuration and secrets on separate paths from day one. The implementation gets easier, not harder.
If you want a practical checklist for operating secret workflows across environments, this article on secrets management best practices is a good operational companion.
For small hobby projects, runtime env injection for a temporary token may feel acceptable. For production systems, it isn't enough. Use a proper secrets mechanism. Treat secret access as something explicit, auditable, and revocable.
Practical Workflows for Local Development and CI/CD
A good workflow doesn't just protect production. It also keeps local development from turning into config archaeology.

A local development workflow that stays sane
For local work, keep the base Compose file focused on service definitions and safe defaults. Then use an override file for developer-specific behavior.
A simple pattern looks like this:
docker-compose.ymldefines app, database, cache, ports, and non-sensitive defaults.docker-compose.override.ymlholds local-only mounts, debug flags, and personal tweaks.- A personal
.envfile supplies non-sensitive values that differ from one machine to another. - Git ignores local-only files so nobody commits workstation-specific config by accident.
This works because the base file stays team-readable while the override layer absorbs local variation. Mid-level developers can reason about it quickly. Senior engineers don't have to untangle one giant Compose file packed with conditional behavior.
A practical example:
# docker-compose.yml
services:
app:
build: .
environment:
APP_ENV: development
LOG_LEVEL: info
depends_on:
- db
# docker-compose.override.yml
services:
app:
environment:
LOG_LEVEL: debug
volumes:
- .:/app
For day-to-day setup details, this guide on how to set environment variables is useful if your team wants a more standardized local process.
A CI/CD workflow that doesn't bake in secrets
CI/CD should reuse the same application definition without reusing local assumptions. The pipeline should build the image, then inject environment-specific values at deploy time.
That usually means:
- Build phase uses
ARGonly for legitimate build inputs. - Test phase provides temporary, scoped config needed for integration runs.
- Deploy phase injects runtime config and secrets from the CI platform's secret store.
- Production runtime reads secrets from a proper secret delivery mechanism, not from image defaults.
A common failure point for many release pipelines occurs when someone builds an image in CI with production values because it seems simpler, and now those values are embedded in the wrong artifact. That's the exact boundary you want to protect.
If your team is still tightening up deployment discipline, this explainer on solving software release challenges gives helpful context on why consistent CI practices matter beyond just passing builds.
Local development should optimize for clarity. CI/CD should optimize for repeatability and separation of concerns.
The healthy pattern is boring by design. One base definition. Clear runtime injection. No secrets in Git. No production values baked into images. No guessing whether a variable is supposed to exist during build or only after the container starts.
Key Takeaways and Best Practices Checklist
Docker environment variables aren't hard because the syntax is hard. They're hard because teams use the same mechanism for different classes of problems.
Use this checklist when you're deciding where a value belongs:
- Use
ARGfor build-time inputs such as versions, feature toggles used during image creation, or static frontend build values. - Use Dockerfile
ENVfor non-sensitive defaults that make the image runnable without locking you into one deployment environment. - Use runtime injection for deployment-specific config through
docker run,--env-file, or Compose. - Know the precedence chain so you don't debug the wrong layer when values conflict.
- Scope config per service in Compose instead of relying on one global file for everything.
- Validate required variables at startup so containers fail fast when critical config is missing.
- Inspect the effective environment inside running containers when behavior doesn't match your expectation.
- Never treat environment variables as a secure secret store for production credentials.
- Use Docker Secrets or another dedicated secret manager when the value is sensitive.
- Keep build-time and runtime concerns separate so values don't get baked into images accidentally.
The practical decision framework is straightforward:
- Does the app need this during image build or only when the container starts?
- Is this regular configuration or sensitive secret material?
Those two questions eliminate most of the confusion around Docker environment variables. They also push teams toward cleaner Dockerfiles, safer Compose setups, and production systems that don't leak credentials through convenience.
EnvManager helps teams replace scattered .env files, shared secrets in chat, and manual dashboard copy-paste with a secure, versioned workflow for environment variables and API secrets. If you want a cleaner way to manage local development, staging, production, and CI access from one place, take a look at EnvManager.