Docker Compose Hardening Checklist for Home Servers
Published 2026-06-25 · Updated 2026-07-31 · 22 min read · By Ben Liu
Practical defaults for networks, secrets, updates, and least privilege on a personal Docker host — with lab notes from real breakages.
On this page
- Least privilege by default
- Network topology that forgives mistakes
- Reference stack: edge + app + db
- Validate before you publish
- Lab failure: Postgres on 0.0.0.0:5432
- Secrets outside git
- STACK.md and .env practice
- Pin by tag, not by hope
- Update with intent
- Observability that catches silent failure
- Reverse proxy basics
- Lab notes from careful ops practice
- Related reading
Least privilege by default
Run containers as non-root whenever images support it. Drop unnecessary Linux capabilities, prefer read-only root filesystems, and mount only the volumes each service needs. Treat every published port as an attack surface you chose deliberately — not a convenience default.
Prefer to run Docker inside an unprivileged LXC or a small VM, never directly on the hypervisor root. Compose projects live under `/srv/compose/<name>/` with a single canonical `compose.yaml` and a sibling `STACK.md`. That layout survives "which folder was production?" panic at 2 a.m.
Network topology that forgives mistakes
Do not publish every port to `0.0.0.0`. Keep databases on internal Docker networks and expose only the reverse proxy. Bind admin UIs to localhost or a private VPN interface. A misconfigured database port on the public internet is the most common home-lab incident report we see — and the fastest to prevent.
Reference stack: edge + app + db
This is the shape we deploy for most Orivana review services. Caddy terminates TLS and talks to the app on an internal bridge. Postgres has no `ports:` block — only the app container joins both networks.
services:
caddy:
image: caddy:2.8.4
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
networks:
- edge
- internal
restart: unless-stopped
app:
image: ghcr.io/example/notes-app:1.4.2
env_file: .env
networks:
- internal
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16.4-alpine
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pg_data:/var/lib/postgresql/data
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
edge:
name: edge
internal:
name: notes_internal
internal: true
volumes:
caddy_data:
pg_data:Notice what is missing: no `5432:5432` on `db`. The app reaches Postgres at hostname `db` on the internal network. Users reach only Caddy on 443.
Validate before you publish
Run `docker compose config` after every edit. It expands variables, merges overrides, and catches typos before containers start. We treat a green config as the minimum bar — not optional polish.
cd /srv/compose/notes
docker compose config --quiet
docker compose up -d
docker network inspect notes_internal --format '{{json .Containers}}' | jq .The network inspect step is boring until it saves you. Confirm only `app` and `db` appear on `notes_internal`, and that `db` has no published host binding. If you see a surprise IP on the public bridge, stop and fix before walking away.
Lab failure: Postgres on 0.0.0.0:5432
During a March demo on a bench VPS, someone uncommented `ports: ["5432:5432"]` "just to run pgAdmin from a laptop." Within four hours, Shodan-style scanners logged connection attempts from three continents. No data exfiltration — default creds were not in use — but the noise in logs was unmistakable.
Terminal note from that incident:
# what we saw in auth logs within hours
# FATAL: password authentication failed for user "postgres"
# FATAL: password authentication failed for user "admin"
# FATAL: role "root" does not existFix: remove the port mapping, restore internal-only networking, rotate `POSTGRES_PASSWORD`, and re-run `docker compose config` to prove nothing else leaked. We now add a pre-flight grep in upgrade notes: `grep -R '5432:5432' compose*.yaml || true`.
Secrets outside git
Store secrets in environment files excluded from version control, or in a secrets manager. Rotate credentials after any accidental commit. Avoid baking API keys into custom images that get pushed to public registries.
STACK.md and .env practice
Every compose project on our benches ships with:
- `compose.yaml` — checked into git, no secret values inline
- `.env` — gitignored, lives only on the host
- `env.example` — lists keys with placeholder values
- `STACK.md` — human runbook: pinned tags, last upgrade date, backup command, rollback note
Example `STACK.md` excerpt:
## notes stack (example host)
- Last upgrade: 2026-07-28
- app: ghcr.io/example/notes-app:1.4.2
- postgres: postgres:16.4-alpine
- Backup: `docker exec notes-db-1 pg_dump -U postgres notes > /backup/notes.sql`
- Rollback: re-tag compose to previous commit, `docker compose up -d`When `.env` lands in git by mistake, we rotate the same day — not "after the weekend." `git filter-repo` does not undo a credential that already hit a public remote.
Pin by tag, not by hope
Floating `:latest` is fine for throwaway demos. Production-ish home services get explicit tags tested on a scratch clone first.
# before (review bench mistake)
image: vaultwarden/server:latest
# after (pinned, recorded in STACK.md)
image: vaultwarden/server:1.32.5After a successful upgrade night, update `STACK.md` and commit the tag bump. Digest pinning is optional for households; tag pinning plus a restore drill covers most cases.
Update with intent
Schedule updates: read changelogs, snapshot volumes, pull the pinned tag, migrate, verify health checks. Blind `:latest` pulls couple convenience to surprise breakages — especially when upstream removes an env var you depended on.
Our ritual on Proxmox clones: snapshot VM → `docker compose pull` → `docker compose up -d` → hit the HTTPS health URL → only then touch production.
Observability that catches silent failure
Alert on disk free space, container restart loops, and TLS certificate expiry. A silent full disk is the most common home-lab outage. Uptime Kuma checks the HTTPS path users hit — `https://notes.example.com/health` — not `container_ip:8080`.
Certificate expiry and disk free space page louder than vanity CPU graphs. Even a weekly cron email of `df -h` and `docker ps --filter status=restarting` beats discovering failure when relatives cannot sync photos.
Reverse proxy basics
Terminate TLS at a maintained proxy, enable HTTP security headers where appropriate, and rate-limit authentication endpoints. Keep proxy configs in the same backup set as compose files so rebuilds do not depend on tribal memory.
Caddy and Traefik both work; pick one edge stack and stay consistent across projects. Mixing three proxies because a tutorial did is how you lose track of which ACME account owns which cert.
Lab notes from careful ops practice
A typical review host runs one edge compose project and six app projects on shared internal networks where it makes sense. Databases never publish host ports — full stop.
Immich and Vaultwarden are pinned by release tag; only disposable tools may float. Accidental Postgres exposure taught us to grep for port mappings before every demo rebuild.
Health checks belong in compose for anything with a database dependency. `depends_on: condition: service_healthy` prevents apps from crash-looping against a Postgres still initializing.
If you cannot rebuild the stack from files plus `.env` in under an hour, simplify before adding the next container.
Related reading
See reverse proxy TLS basics, Traefik + Authelia SSO tutorial, and secure remote access for the exposure half of this checklist.
Explore more
Related guides
- Ollama on a Homelab: Local LLMs Without Melting the Rack
Operator guide to running Ollama at home — install paths, Docker, model disk gravity, GPU vs CPU, API exposure, and a sane first weekend with Open WebUI.
- Reverse Proxy and Automatic TLS for Homelabs
One ops guide for reverse proxy + ACME: what a proxy solves, Caddy/Traefik/Nginx fit, certificate renewal, Docker wiring, failure modes, and troubleshooting — without three overlapping primers.
- Vaultwarden vs Password SaaS: When Self-Hosting a Vault Makes Sense
Decide when a self-hosted Bitwarden-compatible vault is rational versus password SaaS — threat model, availability, family sharing, sync, recovery, and deploy minimums.
- Self-Hosted Files: Sync, Suite Choice, and Share-Link Hardening
Choose Syncthing vs Nextcloud vs Seafile for the job, then harden uploads and share links — size limits, expiry, isolation, scanning, and incident response.