env.dev

Env Var Tips: Docker, CI/CD, Kubernetes & direnv

Tactical env-var patterns: --mount=type=secret beats ARG, GitHub / GitLab / Jenkins idioms, ConfigMaps vs Secrets in Kubernetes, direnv for local dev, build-time vs runtime split.

Last updated:

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:

bash
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:

bash
# 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 myapp

Compose env_file + environment — combine an external file with explicit overrides per service:

yaml
# 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:

dockerfile
# 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"]
bash
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:

yaml
# .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 deploy

GitLab CI

Settings → CI/CD → Variables. Mark Masked to hide from logs and Protected to limit to protected branches. Available automatically in all jobs:

yaml
# .gitlab-ci.yml
deploy:
  script:
    - echo "Deploying with $DATABASE_URL"
  environment:
    name: production
  only:
    - main

Jenkins

Credentials plugin stores secrets; bind them to environment variables in pipelines via withCredentials or the credentials() helper:

text
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.
yaml
# 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-secrets

Local 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:

bash
# .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 authorize

For 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:

bash
envsubst < config.template.json > config.json

Build-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.js NEXT_PUBLIC_*, Vite VITE_*, Rust env!() macro. Frozen at build, requires rebuild to change.
  • Runtime: Node.js process.env, Python os.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.

bash
# 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
javascript
// 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#.

Was this helpful?

Read next

GitHub Actions: Secrets vs Environment Variables

When to use repository secrets, environment secrets, and configuration variables in GitHub Actions. Includes workflow examples for Node.js, Python, and Docker.

Continue →

Frequently Asked Questions

Should I use ARG or --mount=type=secret for build-time secrets in Docker?

Always --mount=type=secret. ARG values are baked into the image and visible to anyone who pulls it — pre-2021 this leaked countless real secrets in public Docker Hub images. With BuildKit (default in Docker 23+), --mount=type=secret keeps the value out of every image layer.

What is the difference between Kubernetes ConfigMaps and Secrets?

ConfigMaps hold non-sensitive key-value pairs in plaintext. Secrets hold sensitive data, base64-encoded by default — base64 is not encryption. For real encryption at rest, configure the API server with EncryptionConfiguration backed by KMS, or layer External Secrets Operator / Sealed Secrets / SOPS on top.

Why might I want direnv on top of dotenv?

direnv loads variables when you cd into a project directory and unloads them on exit, so every command in that shell — psql, curl, aws, your tests, your app — sees them. dotenv-style libraries only inject into your application process. direnv pairs well with dotenv via the dotenv stdlib function in .envrc.

Why does my NEXT_PUBLIC_ env var not update without a rebuild?

Frontend frameworks inline NEXT_PUBLIC_* / VITE_* variables into the JavaScript bundle at build time, not runtime. Server-side code in the same project still reads process.env at runtime. Same variable name, two different "current values" depending on where it is read.

Stay up to date

Get notified about new guides, tools, and cheatsheets.