Compose + env vars
A docker-compose.yml tends to end up in a git repo — which is exactly where API keys shouldn't be. The pattern this guide describes is the one we run our own homeserver on: the compose file is committed and holds structure; a git-ignored .env file next to it holds values.
The pattern
Compose automatically reads a file named .env in the project directory and substitutes ${VAR} references before starting anything:
# docker-compose.yml — committed
services:
lector:
image: ghcr.io/heuwels/lector:${LECTOR_VERSION:-latest}
ports:
- "3400:3000"
- "3457:3457"
volumes:
- ./data:/app/data
environment:
- NODE_ENV=production
- API_URL=https://lector.home.yourdomain.com
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- GOOGLE_CLOUD_API_KEY=${GOOGLE_CLOUD_API_KEY} # .env — git-ignored, chmod 600
LECTOR_VERSION=3.2.0
ANTHROPIC_API_KEY=sk-ant-...
GOOGLE_CLOUD_API_KEY=AIza... Add .env to .gitignore before the first commit. Values are plain KEY=value lines — no quotes needed unless the value contains spaces or #.
Pin the version
image: ghcr.io/heuwels/lector:${LECTOR_VERSION:-latest} gives you deliberate upgrades: docker compose pull can never surprise you with a new major. Upgrading becomes an audit trail in one line:
# .env
LECTOR_VERSION=3.2.0 # was 3.1.0 — upgraded for OIDC login then
docker compose pull lector && docker compose up -d lector The :-latest part is a default: if the variable is unset, compose falls back to latest. Handy for optional settings; for the version pin you may prefer omitting the default so a missing .env fails loudly instead of floating.
Two ways to pass values in
There are two distinct mechanisms, and it pays to know which one you're using:
- Interpolation (
environment:entries with${VAR}) — compose substitutes the value into the compose config, then passes it to the container. The compose file documents exactly which variables the container sees. Unset variables become empty strings (and compose warns), so pair optional ones with:-defaults. env_file:— hands the whole file to the container as-is. Less self-documenting, but variables that don't exist in the file are simply absent from the container (no empty-string overrides), which is better for optional secrets:
services:
lector:
env_file:
- ./lector.env # every key in the file lands in the container Both are fine. We use interpolation for our homeserver (explicit, greppable) and env_file where secrets come and go (a key that's deleted from the file disappears from the container on the next up, rather than lingering as an empty string).
Verify what the container will get
# Show the fully-resolved config, secrets substituted:
docker compose config
# Check a running container's actual environment (names only):
docker exec lector env | cut -d= -f1 | sort docker compose config is the fastest way to catch a typo'd variable name — it renders exactly what up would use.
Applying changes
Environment values are read at container start. After editing .env or the compose file:
docker compose up -d Compose diffs the resolved config and recreates only the containers whose config actually changed — an untouched service isn't restarted.
Rotating a secret is the same flow: edit .env, docker compose up -d, done. Nothing to rebuild — Lector reads all configuration at runtime; no secrets are baked into the image.