← Terug naar tutorials

Debugging van omgevingsvariabelen en config-injectieproblemen in Docker-apps

dockerdocker-composeomgevingsvariabelenconfiguratiedebuggingsecretsdevopscontainers

Debugging van omgevingsvariabelen en config-injectieproblemen in Docker-apps

Omgevingsvariabelen en configuratie-injectie lijken simpel (“zet ENV=waarde en klaar”), maar in Docker-omgevingen ontstaan juist hier veel hardnekkige bugs: waarden die “verdwijnen”, variabelen die in de container anders zijn dan lokaal, secrets die leeg blijken, of apps die bij startup de verkeerde config lezen. In deze tutorial leer je systematisch debuggen waar het misgaat: build-time vs run-time, shell-expansie, Compose .env vs environment, overrides, entrypoints, Kubernetes ConfigMaps/Secrets, en veelvoorkomende valkuilen.

We werken met echte commando’s en herhaalbare stappen. Je kunt de voorbeelden kopiëren en aanpassen aan je eigen stack.


Inhoud

  1. Mental model: waar komen variabelen vandaan?
  2. Build-time vs run-time: ARG en ENV
  3. Inspectie: wat draait er écht in de container?
  4. Docker CLI: variabelen doorgeven en verifiëren
  5. Dockerfile-valkuilen: RUN, CMD, ENTRYPOINT, shell vs exec
  6. Docker Compose: .env, environment, env_file en precedence
  7. Config-injectie via volumes en bestanden
  8. Secrets: veilig injecteren en debuggen
  9. Kubernetes: ConfigMaps, Secrets en Downward API
  10. Netwerk- en proxyvariabelen: subtiele problemen
  11. Checklist: snelle triage van configproblemen
  12. Praktisch debugscript en patronen

Mental model: waar komen variabelen vandaan?

Een veelgemaakte fout is denken dat er “één waarheid” is voor omgevingsvariabelen. In werkelijkheid zijn er meerdere lagen die elkaar kunnen overschrijven:

Belangrijk: “Compose .env” is niet hetzelfde als “.env dat je app leest”. Compose gebruikt .env primair voor substitutie in docker-compose.yml, niet automatisch als container environment (tenzij je het expliciet doorgeeft via environment of env_file).


Build-time vs run-time: ARG en ENV

ARG bestaat alleen tijdens build

Voorbeeld Dockerfile:

FROM alpine:3.20

ARG APP_MODE=production
RUN echo "Build-time APP_MODE=$APP_MODE" > /build-info.txt

CMD ["sh", "-lc", "echo Run-time APP_MODE=$APP_MODE; cat /build-info.txt; env | sort | grep -E '^APP_MODE=' || true"]

Build en run:

docker build -t arg-demo .
docker run --rm arg-demo

Je zult zien dat APP_MODE niet in de run-time environment zit. De RUN stap zag hem wel, maar CMD niet.

Wil je de waarde wél als default in de image opslaan, dan moet je hem doorzetten naar ENV:

FROM alpine:3.20
ARG APP_MODE=production
ENV APP_MODE=$APP_MODE
CMD ["sh", "-lc", "echo APP_MODE=$APP_MODE; env | sort | grep -E '^APP_MODE='"]

Build met override:

docker build --build-arg APP_MODE=staging -t arg-env-demo .
docker run --rm arg-env-demo

Valkuil: ARG in multi-stage builds

In multi-stage builds is ARG scope-gebonden. Als je ARG in stage 1 declareert, bestaat hij niet automatisch in stage 2.

FROM alpine:3.20 AS builder
ARG VERSION
RUN echo "$VERSION" > /version.txt

FROM alpine:3.20
# ARG VERSION ontbreekt hier!
COPY --from=builder /version.txt /version.txt
CMD ["cat", "/version.txt"]

Build:

docker build --build-arg VERSION=1.2.3 -t multi-arg .
docker run --rm multi-arg

Dit werkt omdat /version.txt gekopieerd wordt, maar je kunt VERSION niet meer gebruiken in stage 2 tenzij je opnieuw ARG VERSION declareert.


Inspectie: wat draait er écht in de container?

Wanneer een app “de verkeerde config” gebruikt, wil je eerst de feiten verzamelen:

  1. Welke image is het?
  2. Welke command/entrypoint draait?
  3. Welke environment variables zijn aanwezig?
  4. Welke bestanden zijn gemount (configfiles, secrets)?
  5. Welke user draait het proces (rechten op configfiles)?

Inspecteer container metadata

docker ps --no-trunc
docker inspect <container_id> | less

Handige docker inspect queries:

docker inspect -f '{{.Config.Image}}' <container_id>
docker inspect -f '{{json .Config.Env}}' <container_id> | jq
docker inspect -f '{{json .Config.Cmd}}' <container_id> | jq
docker inspect -f '{{json .Config.Entrypoint}}' <container_id> | jq
docker inspect -f '{{json .Mounts}}' <container_id> | jq

Kijk in de container: env, proces, files

docker exec -it <container_id> sh

In de shell:

env | sort
ps auxww
id
ls -la /
ls -la /app /etc /run /var/run 2>/dev/null

Als je container geen shell heeft (bijv. distroless), kun je debuggen met een tijdelijke debugcontainer in dezelfde network/pid namespace (afhankelijk van je setup) of een “debug image” bouwen. Voor Docker alleen is een praktische aanpak: start dezelfde image met een andere entrypoint als er wél een shell aanwezig is, of gebruik een variant met busybox.


Docker CLI: variabelen doorgeven en verifiëren

docker run -e en --env-file

Voorbeeld:

docker run --rm -e FOO=bar alpine:3.20 env | grep FOO

Met --env-file:

Maak app.env:

cat > app.env <<'EOF'
FOO=bar
HELLO=wereld
EMPTY=
# commentaar
EOF

Run:

docker run --rm --env-file app.env alpine:3.20 env | grep -E '^(FOO|HELLO|EMPTY)='

Let op: --env-file ondersteunt geen shell-expansie. FOO=$HOME blijft letterlijk $HOME.

Debug: is jouw variabele echt meegegeven?

Veel bugs zitten in de hostshell: een variabele die je denkt te exporteren is niet geëxporteerd.

FOO=bar
docker run --rm -e FOO alpine:3.20 sh -lc 'echo "FOO=$FOO"'

Dit geeft lege output, want FOO is niet geëxporteerd. Correct:

export FOO=bar
docker run --rm -e FOO alpine:3.20 sh -lc 'echo "FOO=$FOO"'

Of direct:

docker run --rm -e FOO=bar alpine:3.20 sh -lc 'echo "FOO=$FOO"'

Dockerfile-valkuilen: RUN, CMD, ENTRYPOINT, shell vs exec

Shell-form vs exec-form

Dockerfile:

CMD echo $FOO

Dit is shell-form (impliciet /bin/sh -c). Variabelen worden door de shell geëxpandeerd.

Maar:

CMD ["echo", "$FOO"]

Dit is exec-form. Er is geen shell, dus $FOO wordt niet geëxpandeerd; je ziet letterlijk $FOO.

Test:

docker run --rm -e FOO=bar alpine:3.20 sh -lc 'echo $FOO'
docker run --rm -e FOO=bar alpine:3.20 echo '$FOO'

Entrypoint scripts die variabelen “opeten”

Veel images gebruiken een entrypoint script dat config genereert. Als dat script set -u gebruikt of variabelen overschrijft, kun je onverwachte resultaten krijgen.

Stel /docker-entrypoint.sh bevat:

#!/bin/sh
set -e
export APP_PORT="${APP_PORT:-8080}"
exec "$@"

Dan kun je APP_PORT wel overriden, maar als het script later iets doet als:

APP_PORT=8080

zonder export, dan kan het in subprocessen anders uitpakken.

Debug tip: bekijk het entrypoint:

docker inspect -f '{{json .Config.Entrypoint}}' <image> | jq
docker run --rm --entrypoint sh <image> -lc 'ls -la; sed -n "1,200p" /docker-entrypoint.sh 2>/dev/null || true'

ENV in Dockerfile vs runtime override

ENV zet defaults in de image:

ENV LOG_LEVEL=info

Maar runtime overrides winnen:

docker run --rm -e LOG_LEVEL=debug <image> env | grep LOG_LEVEL

Als je runtime override niet “pakt”, zit het probleem meestal in:


Docker Compose: .env, environment, env_file en precedence

.env in Compose: substitutie, geen automatische injectie

Een typische structuur:

project/
  docker-compose.yml
  .env

.env:

APP_PORT=8081
LOG_LEVEL=debug

docker-compose.yml:

services:
  web:
    image: nginx:alpine
    ports:
      - "${APP_PORT}:80"

Hier gebruikt Compose ${APP_PORT} om de host port mapping te bepalen. Maar in de container is APP_PORT niet automatisch gezet.

Verifieer:

docker compose up -d
docker compose exec web sh -lc 'env | grep APP_PORT || echo "APP_PORT niet gezet"'

environment: zet variabelen in de container

services:
  web:
    image: nginx:alpine
    environment:
      LOG_LEVEL: "${LOG_LEVEL}"
      APP_PORT: "${APP_PORT}"

Nu:

docker compose up -d --force-recreate
docker compose exec web sh -lc 'env | grep -E "^(LOG_LEVEL|APP_PORT)="'

env_file: laadt variabelen in de container

Maak container.env:

FOO=bar
HELLO=wereld

Compose:

services:
  web:
    image: alpine:3.20
    env_file:
      - container.env
    command: ["sh", "-lc", "env | sort | grep -E '^(FOO|HELLO)='; sleep 3600"]

Run:

docker compose up -d
docker compose logs -f

Precedence (wie wint?)

In de praktijk (meest relevant):

  1. Variabelen expliciet in docker compose run -e ... of docker run -e ...
  2. environment: in compose
  3. env_file: in compose
  4. ENV in Dockerfile (image defaults)

En voor Compose substitutie (${VAR} in YAML):

Debug tip: laat Compose de uiteindelijke config renderen:

docker compose config

Zo zie je welke waarden Compose uiteindelijk invult.

Veelvoorkomende fout: quotes en speciale tekens

Wachtwoorden met #, spaties, $, : kunnen problemen geven in .env of Compose.

Voor .env (Compose) geldt: het is geen volledige shell-syntax. Gebruik geen export, en wees voorzichtig met quotes. Test door te renderen:

docker compose config | sed -n '1,200p'

Als je een waarde met $ hebt (bijv. bcrypt hash), kan Compose proberen te substitueren. Oplossingen:

Voorbeeld:

environment:
  PASSWORD_HASH: "$$2b$$10$$abc..."

Config-injectie via volumes en bestanden

Niet alle config hoort in env vars. Vaak mount je configfiles:

docker run --rm \
  -v "$PWD/config.json:/app/config.json:ro" \
  myapp:latest

Debug: is het bestand er en leesbaar?

In de container:

ls -la /app/config.json
cat /app/config.json | head

Check ownership en user:

id
stat /app/config.json

Als je app als non-root draait, kan een bestand met verkeerde permissions “bestaan” maar toch onleesbaar zijn.

Valkuil: mount overschrijft image-bestanden

Als je in je image /app hebt gevuld, en je mount -v ./app:/app, dan overschrijf je alles in /app met je hostdirectory. Hierdoor kunnen defaults verdwijnen (bijv. config/default.json).

Debug:

docker inspect -f '{{json .Mounts}}' <container_id> | jq

Secrets: veilig injecteren en debuggen

Docker secrets (Swarm) of Compose secrets

In Docker Compose (niet Swarm) is “secrets” ondersteuning afhankelijk van je omgeving, maar veel setups gebruiken files in /run/secrets/....

Voorbeeld (conceptueel): secret als file, app leest van pad.

Debug:

In container:

ls -la /run/secrets 2>/dev/null || true
for f in /run/secrets/*; do echo "== $f =="; wc -c "$f"; done 2>/dev/null

Let op: print geen secrets in logs. Gebruik wc -c of sha256sum om te verifiëren zonder inhoud te tonen:

sha256sum /run/secrets/db_password 2>/dev/null || true

Veelvoorkomende fout: newline aan het einde

Secrets uit files hebben vaak een trailing newline. Sommige apps (of eigen scripts) falen dan bij authenticatie.

Check:

python3 - <<'PY'
p="/run/secrets/db_password"
with open(p,"rb") as f:
    data=f.read()
print("bytes:", len(data))
print("eindigt op newline:", data.endswith(b"\n"))
PY

Oplossing: trim in je entrypoint of in app-code, of schrijf secret zonder newline.


Kubernetes: ConfigMaps, Secrets en Downward API

In Kubernetes zijn er drie hoofdmanieren om config te injecteren:

  1. Env vars vanuit ConfigMap/Secret
  2. Files via volume mounts (ConfigMap/Secret volumes)
  3. Downward API (pod metadata als env of file)

Debug: wat ziet de pod?

Eerst: check de live manifesten:

kubectl get pod <pod> -o yaml | sed -n '1,200p'
kubectl describe pod <pod> | sed -n '1,200p'

In de container:

kubectl exec -it <pod> -- sh -lc 'env | sort | sed -n "1,120p"'
kubectl exec -it <pod> -- sh -lc 'ls -la /etc/config /run/secrets 2>/dev/null || true'

ConfigMap naar env

Manifest (voorbeeld):

kubectl create configmap app-config --from-literal=LOG_LEVEL=debug --from-literal=FEATURE_X=true

Pod snippet (conceptueel):

kubectl get configmap app-config -o yaml

Debug:

Secret naar env vs file

Secrets als env:

Secrets als volume:

Debug volume secret:

kubectl exec -it <pod> -- sh -lc 'ls -la /var/run/secrets 2>/dev/null || true'
kubectl exec -it <pod> -- sh -lc 'find /var/run/secrets -maxdepth 3 -type f -print -exec wc -c {} \; 2>/dev/null'

Downward API debug

Als je pod labels/naam in env verwacht:

kubectl exec -it <pod> -- sh -lc 'env | grep -E "POD_NAME|POD_NAMESPACE|NODE_NAME" || true'

Als het ontbreekt: check je deployment spec of de env entries aanwezig zijn.


Netwerk- en proxyvariabelen: subtiele problemen

Proxyvariabelen zijn berucht: HTTP_PROXY, HTTPS_PROXY, NO_PROXY (en lowercase varianten). Problemen:

Debug:

docker exec -it <container> sh -lc 'env | grep -i proxy'

Test connectivity:

docker exec -it <container> sh -lc 'apk add --no-cache curl >/dev/null 2>&1 || true; curl -v https://example.com 2>&1 | sed -n "1,80p"'

Als je app naar een hostservice moet verbinden, gebruik vaak:


Checklist: snelle triage van configproblemen

Gebruik deze volgorde om snel te lokaliseren waar het misgaat.

  1. Bevestig de juiste container

    docker ps --no-trunc
    docker inspect -f '{{.Name}} {{.Config.Image}}' <id>
  2. Check entrypoint en command

    docker inspect -f 'Entrypoint={{json .Config.Entrypoint}} Cmd={{json .Config.Cmd}}' <id>
  3. Check env in container

    docker exec -it <id> sh -lc 'env | sort | sed -n "1,200p"'
  4. Check mounts (configfiles/secrets)

    docker inspect -f '{{json .Mounts}}' <id> | jq
    docker exec -it <id> sh -lc 'ls -la /run/secrets /etc/config /app 2>/dev/null || true'
  5. Check app-logica (preference order)

    • Leest de app env vars?
    • Leest de app .env file (dotenv)?
    • Leest de app config uit flags?
    • Wordt config gecachet bij startup?
  6. Check Compose render

    docker compose config
  7. Check build vs run

    • Verwacht je dat ARG op runtime bestaat? Dat klopt niet.
    • Verwacht je dat RUN echo $VAR iets bewijst voor runtime? Dat bewijst alleen build-time.

Praktisch debugscript en patronen

Patroon 1: “Print env bij startup” zonder secrets te lekken

Maak een entrypoint wrapper die alleen whitelisted keys toont:

#!/bin/sh
set -e

echo "== Startup env (whitelist) =="
for k in APP_MODE LOG_LEVEL APP_PORT DATABASE_HOST DATABASE_NAME; do
  v="$(printenv "$k" || true)"
  echo "$k=$v"
done

exec "$@"

Build in je image en zet als entrypoint. Dit voorkomt dat je per ongeluk DATABASE_PASSWORD logt.

Patroon 2: “Config snapshot” in container

Voer ad-hoc uit:

docker exec -it <container> sh -lc '
set -e
echo "## id"; id
echo "## process"; ps auxww | sed -n "1,15p"
echo "## env"; env | sort | sed -n "1,120p"
echo "## mounts"; mount | sed -n "1,80p"
echo "## app dir"; ls -la /app 2>/dev/null || true
'

Patroon 3: Compose: verifieer welke waarden je echt gebruikt

  1. Toon wat Compose invult:
docker compose config > /tmp/compose.rendered.yml
sed -n '1,200p' /tmp/compose.rendered.yml
  1. Start opnieuw met recreate:
docker compose up -d --force-recreate
  1. Check env in service:
docker compose exec <service> sh -lc 'env | sort | grep -E "^(APP_|LOG_|DB_)"'

Patroon 4: Debuggen van “lege variabele” door shell quoting

Als je dit doet:

docker run --rm -e PASSWORD='$ecret' alpine:3.20 sh -lc 'echo "PASSWORD=$PASSWORD"'

Dan is de waarde letterlijk $ecret (single quotes). Als je juist shell-expansie wil (meestal niet bij passwords), gebruik double quotes. Maar bij $ in passwords wil je vaak geen expansie; geef dan correct door met single quotes of escape.

Test altijd met:

docker run --rm -e PASSWORD='$ecret' alpine:3.20 sh -lc 'python3 - <<PY
import os
print(os.environ.get("PASSWORD"))
PY'

Zo zie je exact wat de app zou lezen.


Veelvoorkomende scenario’s en oplossingen

Scenario A: “Ik heb .env maar de app ziet de variabelen niet”

Oorzaak: je bedoelt Compose .env maar je app verwacht env vars.

Oplossing:

Scenario B: “Waarde is correct in env, maar app gebruikt iets anders”

Oorzaak: app heeft eigen precedence (configfile wint, of flags winnen).

Oplossing:

Scenario C: “In CI werkt het, lokaal niet”

Oorzaken:

Oplossing:


Afsluiting

Debuggen van omgevingsvariabelen en config-injectie in Docker is vooral: lagen uit elkaar trekken en verifiëren wat er werkelijk in de container aanwezig is. De kerntechnieken zijn:

Als je een concreet probleem hebt, plak dan (geanonimiseerd) je Dockerfile, docker-compose.yml, de relevante .env/env_file, plus de output van:

Dan kun je heel gericht bepalen in welke laag de configuratie “verdwijnt” of overschreven wordt.