Docker is amazing for homelabs; but the “it works” default is often not the “it’s safe” default.
This guide covers 8 common Docker mistakes and gives you real-world, copy‑paste fixes using popular homelab services (Vaultwarden, Postgres, Portainer, Uptime Kuma, etc.).
Goal: safer containers without turning your homelab into a PhD project.
- Docker + Docker Compose
- A test host (or VM) where you can safely break stuff
- Optional but recommended: a reverse proxy (Caddy/NPM/Traefik)
We’ll use a simple “app + database” stack and tighten it up as we go.
Bad (common) starting point:
services:
vaultwarden:
image: vaultwarden/server:latest
ports:
- "8080:80"
environment:
- ADMIN_TOKEN=supersecret
- DATABASE_URL=postgresql://vaultwarden:pw@db:5432/vaultwarden
db:
image: postgres:latest
environment:
- POSTGRES_PASSWORD=pw
- POSTGRES_USER=vaultwarden
- POSTGRES_DB=vaultwarden
ports:
- "5432:5432"We’ll fix problems like root containers, latest tags, flat networks, socket mounts, etc.
Why it’s bad/ If the app gets exploited, the attacker starts as root inside the container, which can help them:
- tamper with files mounted from the host
- abuse Linux capabilities
- pivot to other containers
Bad example/ Most images will run as root unless you change it.
services:
uptimekuma:
image: louislam/uptime-kuma:1.23.16
ports:
- "3001:3001"Fix/ Prefer images that already drop privileges. If they don’t, set a non-root user.
services:
uptimekuma:
image: louislam/uptime-kuma:1.23.16
user: "1000:1000"
ports:
- "3001:3001"
volumes:
- uptimekuma-data:/app/data
restart: unless-stopped
volumes:
uptimekuma-data:mkdir -p uptimekuma-data
sudo chown -R 1000:1000 uptimekuma-dataIf the container needs to listen on 80/443, put a reverse proxy in front and keep apps on high ports.
Why it’s bad/
privileged: true essentially turns off most container isolation. It grants broad access to the host.
Bad example/
services:
someapp:
image: ghcr.io/linuxserver/swag
privileged: trueFix/
Remove privileged and add only what’s needed:
- specific devices
- specific capabilities
- explicit mounts
Example: allow a container to read the time zone file (read-only) without privilege:
services:
app:
image: ghcr.io/linuxserver/heimdall:2.6.3
volumes:
- /etc/localtime:/etc/localtime:ro
restart: unless-stoppedIf you truly need host‑level access (rare), consider running that component on a dedicated VM.
Why it’s bad/
Anyone who can access /var/run/docker.sock can control Docker (and often the host). This is one of the most common “own the box” shortcuts.
Bad example/ Portainer:
services:
portainer:
image: portainer/portainer-ce:latest
ports:
- "9000:9000"
volumes:
- /var/run/docker.sock:/var/run/docker.sockFix/ options
Option A (best): Don’t mount the socket/ If you only need a web UI for one service, you probably don’t need Portainer at all.
Option B (safer): Use a Docker Socket Proxy/ This limits what the UI can do.
services:
socket-proxy:
image: tecnativa/docker-socket-proxy:0.1.1
environment:
- CONTAINERS=1
- SERVICES=1
- NETWORKS=1
- TASKS=1
- POST=0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks: [mgmt]
restart: unless-stopped
portainer:
image: portainer/portainer-ce:2.21.5
command: -H tcp://socket-proxy:2375
ports:
- "9000:9000"
networks: [mgmt]
depends_on: [socket-proxy]
restart: unless-stopped
networks:
mgmt:Still treat it as high risk: never expose it publicly.
Why it’s bad/
- restarts can pull breaking changes
- impossible to reproduce your environment
- can turn minor updates into outages
Bad example/
image: traefik:latestFix/ Pin versions:
image: traefik:v3.0.4Minimal update routine:/
- Update during a weekly window
- Read release notes for major upgrades
- Backup before updating stateful services
Why it’s bad/ If compromised, attackers can:
- drop tooling
- modify app files
- add persistence
Bad example/
services:
gatus:
image: twinproduction/gatus:latestFix/ Make the root filesystem read-only and explicitly mount only what must be writable.
services:
gatus:
image: twinproduction/gatus:v5.13.1
read_only: true
tmpfs:
- /tmp
volumes:
- ./config:/config:ro
ports:
- "8081:8080"
restart: unless-stoppedWhy it’s bad/ Linux capabilities are “root powers”. Many apps need none.
Bad example/ A lot of containers implicitly run with default capabilities.
Fix/ Drop everything and add back only what’s required.
services:
app:
image: ghcr.io/immich-app/immich-server:v1.132.2
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
restart: unless-stoppedIf you must bind to ports below 1024 inside the container, add only this:
cap_add:
- NET_BIND_SERVICEWhy it’s bad/ A flat network makes lateral movement easy: compromise one container --> scan and attack everything.
Bad example/ Everything on default network, database exposed to the LAN:
services:
app:
image: vaultwarden/server:1.32.7
ports:
- "8080:80"
db:
image: postgres:16
ports:
- "5432:5432"Fix/ Use separate networks:
proxyfor inbound trafficbackendfor internal services
...and don’t publish database ports.
services:
vaultwarden:
image: vaultwarden/server:1.32.7
networks: [proxy, backend]
# only expose via reverse proxy, or bind to localhost for testing
ports:
- "127.0.0.1:8080:80"
db:
image: postgres:16.4
networks: [backend]
# no ports -> not reachable from LAN
environment:
- POSTGRES_USER=vaultwarden
- POSTGRES_DB=vaultwarden
- POSTGRES_PASSWORD=pw
networks:
proxy:
backend:Why it’s bad/ Environment variables are convenient, but secrets often leak:
- compose exports
- logs
- process listings
Bad example/
environment:
- POSTGRES_PASSWORD=pw
- ADMIN_TOKEN=supersecretFix/ (recommended) Use Docker secrets (Compose supports this fine for local homelabs).
services:
db:
image: postgres:16.4
environment:
- POSTGRES_USER=vaultwarden
- POSTGRES_DB=vaultwarden
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txtAnd lock file permissions:
mkdir -p ./secrets
openssl rand -base64 32 > ./secrets/db_password.txt
chmod 700 ./secrets
chmod 600 ./secrets/db_password.txtrestart: unless-stoppedhealthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:80/"]
interval: 30s
timeout: 5s
retries: 3services:
app:
image: nginx:1.27.3
mem_limit: 1g
cpus: "1.0"Note:
deploy:limits are enforced by Swarm; for plain Docker Compose you can usemem_limit,cpus, etc. depending on your compose version.
security_opt:
- no-new-privileges:truetrivy image vaultwarden/server:1.32.7services:
app:
image: yourimage:1.2.3 # pin version (or digest)
container_name: app
# Prefer reverse proxy networks. If you must publish ports, bind to localhost:
ports:
- "127.0.0.1:8080:8080"
# Run as non-root (set UID/GID that matches your host/user or volume perms)
user: "1000:1000"
# Least privilege
cap_drop: ["ALL"]
# cap_add: ["NET_BIND_SERVICE"] # only if you really need low ports <1024
security_opt:
- no-new-privileges:true
# Filesystem hardening
read_only: true
tmpfs:
- /tmp
- /run
# Only mount what you need, prefer read-only
volumes:
- app_data:/data
# - ./config:/config:ro
# Put secrets in files, not plain env
environment:
- TZ=Europe/Berlin
# - PASSWORD_FILE=/run/secrets/app_password
secrets:
# - app_password
[]
# Health + restart hygiene
healthcheck:
test: ["CMD", "sh", "-lc", "echo ok"] # replace with real check
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# Network segmentation
networks:
- frontend
- backend
# Resource limits (compose support varies by engine, but still useful)
mem_limit: 512m
cpus: 1.0
# Avoid: privileged: true
# Avoid: mounting /var/run/docker.sock
networks:
frontend:
backend:
internal: true # blocks outbound access from that network (optional)
volumes:
app_data:
secrets:
# app_password:
# file: ./secrets/app_password.txt- Put a reverse proxy in front (Caddy / NPM / Traefik) and remove random exposed ports
- Segment “risky” services into separate VMs (especially anything internet-facing)
- Add backups + restore tests for stateful services
- Centralize logs (Loki/Graylog/Wazuh) and actually look at them