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
- Mental model: waar komen variabelen vandaan?
- Build-time vs run-time:
ARGenENV - Inspectie: wat draait er écht in de container?
- Docker CLI: variabelen doorgeven en verifiëren
- Dockerfile-valkuilen:
RUN,CMD,ENTRYPOINT, shell vs exec - Docker Compose:
.env,environment,env_fileen precedence - Config-injectie via volumes en bestanden
- Secrets: veilig injecteren en debuggen
- Kubernetes: ConfigMaps, Secrets en Downward API
- Netwerk- en proxyvariabelen: subtiele problemen
- Checklist: snelle triage van configproblemen
- 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:
- Host shell (jouw terminal):
export FOO=bar - Docker CLI flags:
docker run -e FOO=baz ... - Docker Compose:
.envbestand (Compose-variabele substitutie)environment:(container environment)env_file:(container environment uit bestand)
- Dockerfile:
ARG(alleen build-time, tenzij doorgezet)ENV(default run-time in image)
- Entrypoint scripts (bijv.
/docker-entrypoint.sh): kunnen variabelen aanpassen of bestanden genereren - Orchestrator (Kubernetes, Swarm, Nomad): injecteert extra variabelen
- Applicatieconfig: frameworks lezen vaak
.env-bestanden, configfiles, of hebben eigen precedence (bijv. Spring Boot, Django, Node dotenv)
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:
- Welke image is het?
- Welke command/entrypoint draait?
- Welke environment variables zijn aanwezig?
- Welke bestanden zijn gemount (configfiles, secrets)?
- 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:
- je start niet de container die je denkt te starten (verkeerde service/compose project)
- entrypoint script overschrijft de variabele
- applicatie leest config niet uit env maar uit een bestand of flags
- je gebruikt Compose en denkt dat
.envautomatisch in de container zit (doet het niet)
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):
- Variabelen expliciet in
docker compose run -e ...ofdocker run -e ... environment:in composeenv_file:in composeENVin Dockerfile (image defaults)
En voor Compose substitutie (${VAR} in YAML):
- Compose pakt eerst uit de shell environment van je host
- anders uit
.envin de project directory - anders default
${VAR:-default}als je dat gebruikt
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:
- Escape
$als$$in Compose YAML - Of zet het in
env_fileen verwijs er niet via${...}
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:
- Bestaat het bestand?
- Is het leeg?
- Is het pad correct?
- Heeft de app rechten?
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:
- Env vars vanuit ConfigMap/Secret
- Files via volume mounts (ConfigMap/Secret volumes)
- 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:
- Bestaat de ConfigMap in de juiste namespace?
- Is de key exact gelijk (case-sensitive)?
- Is de pod herstart na wijziging? (env vars updaten niet live; volumes vaak wel)
Secret naar env vs file
Secrets als env:
- Makkelijk, maar kan per ongeluk in dumps/logs belanden.
- Update van secret vereist meestal rollout/restart om env te verversen.
Secrets als volume:
- Bestand kan wel geüpdatet worden (met symlink-rotatie), maar je app moet het opnieuw lezen.
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:
- Sommige tools lezen alleen uppercase, anderen alleen lowercase.
NO_PROXYmoet hosts, domeinen en CIDRs correct bevatten.- In containers is
localhostniet je host; het is de container zelf.
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:
host.docker.internal(op Docker Desktop)- of de gateway IP van het docker network (Linux: vaak
172.17.0.1, maar niet gegarandeerd) - of een expliciet netwerk met service discovery (Compose service name)
Checklist: snelle triage van configproblemen
Gebruik deze volgorde om snel te lokaliseren waar het misgaat.
-
Bevestig de juiste container
docker ps --no-trunc docker inspect -f '{{.Name}} {{.Config.Image}}' <id> -
Check entrypoint en command
docker inspect -f 'Entrypoint={{json .Config.Entrypoint}} Cmd={{json .Config.Cmd}}' <id> -
Check env in container
docker exec -it <id> sh -lc 'env | sort | sed -n "1,200p"' -
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' -
Check app-logica (preference order)
- Leest de app env vars?
- Leest de app
.envfile (dotenv)? - Leest de app config uit flags?
- Wordt config gecachet bij startup?
-
Check Compose render
docker compose config -
Check build vs run
- Verwacht je dat
ARGop runtime bestaat? Dat klopt niet. - Verwacht je dat
RUN echo $VARiets bewijst voor runtime? Dat bewijst alleen build-time.
- Verwacht je dat
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
- Toon wat Compose invult:
docker compose config > /tmp/compose.rendered.yml
sed -n '1,200p' /tmp/compose.rendered.yml
- Start opnieuw met recreate:
docker compose up -d --force-recreate
- 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:
- Zet het in
environment:ofenv_file:in Compose. - Of laat je app zelf
.envlezen (bijv.dotenv), maar wees consistent.
Scenario B: “Waarde is correct in env, maar app gebruikt iets anders”
Oorzaak: app heeft eigen precedence (configfile wint, of flags winnen).
Oplossing:
- Zoek in docs van je framework: bijv. Spring Boot: command-line args > env > application.properties.
- Log bij startup welke configbron gekozen is.
- Controleer of er een configfile gemount is die je defaults overschrijft.
Scenario C: “In CI werkt het, lokaal niet”
Oorzaken:
- CI exporteert variabelen in de shell; lokaal niet.
- CI gebruikt
--build-argen jij verwacht runtime. - Verschil in Compose versie of
docker composevsdocker-compose.
Oplossing:
- Print
docker compose version - Render
docker compose configin beide omgevingen - Maak onderscheid tussen build-time en run-time variabelen expliciet
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:
docker inspectvoor entrypoint/cmd/env/mountsdocker execomenv, processen en bestanden te bekijkendocker compose configom te zien wat Compose echt rendert- Duidelijk onderscheid tussen
ARG(build) enENV(run) - Bewust omgaan met quoting,
$-substitutie, en secrets (niet loggen)
Als je een concreet probleem hebt, plak dan (geanonimiseerd) je Dockerfile, docker-compose.yml, de relevante .env/env_file, plus de output van:
docker compose configdocker inspect <container> | jq '...Env, .Config.Entrypoint, .Config.Cmd, .Mounts'
Dan kun je heel gericht bepalen in welke laag de configuratie “verdwijnt” of overschreven wordt.