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.