The naming and security rules cover what a good env-var setup looks like; this guide covers how to actually ship it through Docker, CI/CD, Kubernetes, and local-dev tooling. The tactical knowledge that bites real teams: which Docker mechanism to use for which secret kind (--mount=type=secret beats ARG for build-time secrets), the GitHub Actions / GitLab / Jenkins idioms, the difference between Kubernetes ConfigMaps and Secrets, why direnv is the lazy-developer's secret weapon, and the one rule every multi-stage Docker build must follow to keep secrets out of layer history.
How do you pass environment variables into Docker containers?
Docker offers four mechanisms with different trade-offs. Use them in roughly this order:
--env / -e flag — convenient for one-off variables in dev or debugging:
docker run \
-e DATABASE_URL=postgres://localhost/mydb \
-e LOG_LEVEL=debug \
myapp--env-file — bulk-load from a file. Note: Docker's parser is simpler than dotenv; no quoting, no interpolation, no multi-line values:
# app.env — Docker syntax, not full dotenv
DATABASE_URL=postgres://db:5432/production
REDIS_URL=redis://cache:6379
LOG_LEVEL=info
docker run --env-file app.env myappCompose env_file + environment — combine an external file with explicit overrides per service:
# docker-compose.yml
services:
api:
image: myapp
env_file:
- .env
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL} # from host shell or root .env--mount=type=secret — for build-time secrets (e.g., NPM tokens, private package registries). The secret is mounted into the build container without being written to any image layer:
# syntax=docker/dockerfile:1
FROM node:22 AS builder
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
RUN npm run build
FROM node:22-slim
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]docker build --secret id=npm_token,env=NPM_TOKEN -t myapp .Never use ARG for secrets. Build args are baked into image history and visible to anyone who pulls the image — pre-2021, this leaked countless real secrets in public Docker Hub images. --mount=type=secret with BuildKit (the default in Docker 23+) is the right tool.
How do major CI/CD platforms handle secrets?
Every modern CI/CD platform stores secrets in encrypted-at-rest storage and exposes them as environment variables during job execution. The mechanism is similar across providers; the syntax differs:
GitHub Actions
Store under Settings → Secrets and variables → Actions. Reference with ${{ secrets.SECRET_NAME }}. Values are auto-masked in logs. Use Environments (e.g. "production", "staging") with required-reviewer gates for sensitive deployments:
# .github/workflows/deploy.yml
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # gates the secrets to this environment
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
steps:
- uses: actions/checkout@v4
- run: npm run deployGitLab CI
Settings → CI/CD → Variables. Mark Masked to hide from logs and Protected to limit to protected branches. Available automatically in all jobs:
# .gitlab-ci.yml
deploy:
script:
- echo "Deploying with $DATABASE_URL"
environment:
name: production
only:
- mainJenkins
Credentials plugin stores secrets; bind them to environment variables in pipelines via withCredentials or the credentials() helper:
pipeline {
agent any
environment {
DATABASE_URL = credentials('database-url')
}
stages {
stage('Deploy') {
steps {
sh 'npm run deploy'
}
}
}
}ConfigMaps vs Secrets in Kubernetes
Kubernetes splits config into two resources. ConfigMaps hold non-sensitive key-value pairs in plaintext. Secrets hold sensitive data, base64-encoded by default — note that base64 is not encryption. To get real encryption at rest, configure the API server with an EncryptionConfiguration backed by KMS. For most production clusters, layer one of:
- External Secrets Operator — sync secrets from AWS Secrets Manager, Vault, GCP Secret Manager, or Azure Key Vault into Kubernetes Secrets.
- Sealed Secrets (Bitnami) — encrypt secrets so they can be committed to git, decrypted only inside the cluster.
- SOPS + age/KMS — file-based encryption that keeps your GitOps manifests free of plaintext secrets.
# ConfigMap — non-sensitive
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
LOG_LEVEL: "info"
MAX_CONNECTIONS: "100"
FEATURE_NEW_UI: "true"
---
# Secret — created via kubectl, no manual base64
# kubectl create secret generic app-secrets \
# --from-literal=DATABASE_URL='postgres://prod:5432/mydb' \
# --from-literal=API_KEY='sk-live-abc123'
---
# Pod — load both as env vars in one shot
spec:
containers:
- name: api
envFrom:
- configMapRef:
name: app-config
- secretRef:
name: app-secretsLocal development: dotenv, direnv, envsubst
For local dev, the dotenv-loader pattern (load .env at process startup) is universal — every language has a port. But there is a more powerful option for shell-driven workflows: direnv, a shell extension that auto-loads variables when you cd into a directory and unloads them on exit. Every command in that shell — not just your application — sees the values, which is exactly what you want when running ad- hoc psql / curl / aws commands during debugging:
# .envrc — direnv supports shell logic
export DATABASE_URL=postgres://localhost:5432/myapp_dev
export LOG_LEVEL=debug
export API_KEY=$(cat ~/.secrets/api-key) # pull from a separate keyring
# Or load .env file directly
dotenv
# After editing, run `direnv allow` to authorizeFor template-based config files, envsubst (GNU gettext) replaces $VAR references in a file with their environment values — useful for generating Kubernetes manifests, nginx configs, or systemd unit files from a template:
envsubst < config.template.json > config.jsonBuild-time vs runtime variables: the framework trap
Frontend frameworks blur the line between build-time and runtime in ways that catch every new contributor. In Next.js, NEXT_PUBLIC_* variables are inlined into the JavaScript bundle at next build — changing the value requires a rebuild. In Vite, the same applies to VITE_*. Server-side code in the same project still reads process.env at runtime, so the same variable name can have two different "current values" depending on where it is read.
- Build-time: Docker
ARG, Next.jsNEXT_PUBLIC_*, ViteVITE_*, Rustenv!()macro. Frozen at build, requires rebuild to change. - Runtime: Node.js
process.env, Pythonos.environ, anything read by the running process at startup. Restarting the process picks up new values.
Use a multi-stage Docker build to ensure secrets needed during build do not persist into the final image. The pattern from the Docker section above — --mount=type=secret in the builder stage, COPY --from=builder only the artifacts in the runtime stage — is the standard.
Debugging: how to inspect env safely
When debugging configuration issues, you usually want to know "is the variable set?" without revealing the value. The pattern: presence-only checks, with redaction when forced to log values.
# Linux / macOS — list all
printenv
# Check a specific variable
printenv DATABASE_URL
# Presence check that doesn't print the value
[ -n "$DATABASE_URL" ] && echo "DATABASE_URL is set" || echo "DATABASE_URL NOT set"
# In Kubernetes
kubectl exec -it my-pod -- env | grep DATABASE// In application code — log presence, not value
const required = ['DATABASE_URL', 'API_KEY', 'REDIS_URL'];
for (const name of required) {
console.log(` ${name}: ${process.env[name] ? 'SET' : 'MISSING'}`);
}Most CI/CD platforms auto-mask secret values in logs but local debugging output does not — when sharing terminal sessions or logs, redact actual values to [REDACTED] first. docker inspect reveals secrets too, so restrict access to the Docker socket on shared hosts.
Practical checklist
- --mount=type=secret, never ARG for build-time secrets in Docker.
- Use platform-native secret stores in CI/CD; never hardcode secrets in workflow YAML.
- Layer real secret encryption on top of base64 Kubernetes Secrets via External Secrets Operator, Sealed Secrets, or SOPS.
- direnv for local dev if you spend time in the shell alongside your app.
- Validate .env files before committing or deploying with the .env validator; generate starter files with the .env builder.
For naming, validation, and rotation rules, see environment variable best practices. For per-language details, browse the language-specific guides: Node.js, Python, Go, Rust, Java, Ruby, PHP, C#.