Docker makes self-hosting easy, but convenience comes with risk. A misconfigured container can expose your entire host. This guide covers the security fundamentals every Docker homelab needs — from image hygiene to network isolation to secrets management.
1. Image Hygiene
Every container starts with an image. If the image is compromised or bloated, no amount of runtime security will save you.
- Use official or verified images — prefer images from Docker Hub verified publishers or the official library. Check the "Docker Official Image" badge.
- Pin versions — never use
:latestin production. Pin to a specific version tag (e.g.nginx:1.25-alpine) so updates are deliberate, not accidental. - Prefer Alpine-based images — smaller images have fewer packages and therefore a smaller attack surface. Alpine images are typically 5-10x smaller than their Debian counterparts.
- Scan images — use
docker scout cves <image>or Trivy (trivy image <image>) to scan for known CVEs before deploying.
Scan an image with Trivy
# Install Trivy
sudo apt install trivy
# Scan before you deploy
trivy image nextcloud:28-apache
# Scan your running containers
docker ps --format '{{.Image}}' | xargs -I{} trivy image {}
2. Container Isolation
By default, Docker containers share the host kernel and can escalate privileges. Lock them down.
- Never run as root — add
user: "1000:1000"to your compose services. Most apps work fine as a non-root user. - Drop capabilities — containers inherit Linux capabilities they rarely need. Drop all and add back only what is required.
- Read-only filesystem — set
read_only: trueand mount writable volumes only where the app needs to write. - No new privileges — prevent privilege escalation with
security_opt: [no-new-privileges:true]. - Never mount the Docker socket — unless absolutely necessary (Traefik, Watchtower). A container with socket access controls the entire host.
Hardened compose service example
services:
app:
image: myapp:1.2.0
user: "1000:1000"
read_only: true
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
tmpfs:
- /tmp
volumes:
- ./data:/app/data
3. Docker Network Isolation
By default, all containers on the same Docker bridge can talk to each other. That is a problem.
- Create dedicated networks — group related services on their own bridge network. Your database should not be on the same network as your public-facing web app.
- Disable inter-container communication on the default bridge:
"com.docker.network.bridge.enable_icc": "false". - Never expose ports to 0.0.0.0 — bind to
127.0.0.1if the service is only used by other containers or your reverse proxy:127.0.0.1:8080:80.
Network isolation in compose
services:
web:
networks: [frontend, backend]
db:
networks: [backend]
proxy:
networks: [frontend]
ports:
- "443:443"
networks:
frontend:
backend:
internal: true # no internet access
4. Secrets Management
Passwords in plain-text compose files are a breach waiting to happen. Use proper secrets handling.
- .env files — at minimum, move secrets to a
.envfile and reference them with${VAR}. Set file permissions to600. - Docker secrets — for Swarm mode, use
docker secret create. Secrets are mounted as files in/run/secrets/and never stored in the image layer. - Never commit secrets — add
.env,*.key, and*.pemto your.gitignore. Usegit-secretsorgitleaksas a pre-commit hook. - Rotate regularly — database passwords, API keys, and TLS certificates should all have a rotation schedule. Automate it where possible.
5. Reverse Proxy & TLS
Never expose services directly. Put everything behind a reverse proxy with automatic TLS.
- Traefik or Nginx Proxy Manager — both handle Let's Encrypt auto-renewal. Traefik discovers services via Docker labels; NPM has a web UI.
- Force HTTPS — redirect all HTTP to HTTPS. Set HSTS headers with a minimum 6-month max-age.
- TLS 1.2 minimum — disable TLS 1.0 and 1.1. Use strong cipher suites.
- Rate limiting — add rate limits to login endpoints and APIs to slow brute-force attacks.
- Auth middleware — for internal tools (Portainer, Grafana), add Authelia or Authentik as an SSO layer with 2FA.
Traefik with Let's Encrypt (compose labels)
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`app.yourdomain.com`)"
- "traefik.http.routers.app.tls.certresolver=letsencrypt"
- "traefik.http.routers.app.middlewares=secure-headers"
- "traefik.http.middlewares.secure-headers.headers.stsSeconds=31536000"
- "traefik.http.middlewares.secure-headers.headers.forceSTSHeader=true"
6. Logging & Updates
Containers are ephemeral but their logs are gold. And unpatched containers are the easiest target.
- Centralise logs — use the Docker logging driver to send logs to Loki, Graylog, or a syslog server. Do not rely on
docker logsalone — it is lost when the container is recreated. - Watchtower for auto-updates — schedule updates during low-traffic hours:
--schedule "0 4 * * 1"(4am every Monday). Enable notifications so you know what was updated. - Test before production — run a staging compose stack that updates first. If it survives 24 hours, update production.
- Prune regularly — dangling images and stopped containers waste disk and can contain old vulnerable versions:
docker system prune -a --volumes(use with care).