Non-root user, read-only filesystem, dropped capabilities, no Docker socket exposure, and a private network per stack: those five compose-file changes stop the bulk of real-world container attacks, cost nothing, and break far fewer apps than their reputation suggests. Everything past them — gVisor, custom AppArmor profiles, signed images — is the other 80% of effort for the remaining 20% of risk. This is the template I copy into every new stack:
services:
app:
image: ghcr.io/vendor/app:2.3.1 # pinned, never :latest
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=64m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
volumes:
- ./data:/data
networks:
- internal
networks:
internal:
internal: true
Non-root is the single biggest win
Most images still run as root by default, so an RCE in the app hands the attacker root inside the container — one kernel bug or misconfigured mount away from root on your host. user: "1000:1000" fixes it for most images; linuxserver.io images want PUID/PGID environment variables instead; a few (the official Postgres, for one) start as root and drop privileges themselves, which is fine. Verify with docker exec app id. Where a container genuinely needs root, no-new-privileges:true still blocks escalation through setuid binaries, so keep it regardless.
Read-only root filesystem kills persistence
With read_only: true, malware that lands in the container can't write a payload, edit a cron entry, or modify the binary it exploited — it dies with the process. Most apps only need a writable /tmp or a cache path, which tmpfs provides. Finding the writable paths is empirical: enable it, start the container, and read the errors; the answer is nearly always /tmp, /run, or /var/cache/something. Around two-thirds of my ~30 containers run read-only (my count, June 2026); the holdouts are apps that insist on writing plugins into their own install directory.
The Docker socket is root on the host
Mounting /var/run/docker.sock into a container makes that container root-equivalent on the host — it can start a privileged container with / mounted. Watchtower and Traefik both ask for the socket, which is exactly why they're the containers worth protecting most. Put a socket proxy in between and grant only the API sections each tool needs:
socket-proxy:
image: tecnativa/docker-socket-proxy:0.3
environment:
CONTAINERS: 1 # list containers: yes. Everything else: no.
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
Traefik only needs read access to container metadata; Watchtower needs more, which is one reason I schedule updates instead of automating them — reasoning in updating containers without fires.
One network per stack, internal: true for databases
On a shared default network, a compromised container can reach every other container's ports — your throwaway experiment can talk to your password manager's database. The pattern that fixes it: each stack gets its own network; databases sit on an internal: true network that can't route to the internet at all (a Postgres instance has no business phoning anywhere); only the reverse proxy joins a shared proxy network that reaches app frontends. It's three extra lines per stack, laid out fully in compose patterns for a sane homelab.
Image provenance: pin it, and know who built it
image: app:latest means you run whatever was pushed most recently by whoever controls that tag. Pin at least the minor version, and prefer images published by the project itself (GHCR or Quay, linked from the repo's README) over Docker Hub search results — typosquatted and cryptomining lookalike images on Hub are a documented, recurring problem. For the paranoid tier, pin the digest (image: app@sha256:...) and bump it deliberately. A pinned version is also your rollback plan when an update breaks.
Secrets don't belong in environment:
Environment variables leak: docker inspect shows them to anyone with socket access, child processes inherit them, and crash reporters love dumping them. Compose supports file-based secrets without Swarm:
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
The secret appears at /run/secrets/db_password, and most self-hosted apps accept _FILE-suffixed variables (POSTGRES_PASSWORD_FILE=...) to read it. Chmod the secrets directory to 600 and keep it out of git.
Audit what you're already running
Before retrofitting anything, measure the gap. One line shows which containers run as root and which have a writable root filesystem:
docker ps -q | xargs docker inspect --format \
'{{.Name}}: user={{.Config.User}} ro={{.HostConfig.ReadonlyRootfs}}'
An empty user= means root. When I first ran this, 19 of 27 containers were root with writable filesystems — after two weekends of retrofits it was 4, all of them apps that drop privileges internally. While you're in there, add memory limits (mem_limit: 512m) to anything you don't fully trust; a runaway or exploited container that can allocate all host RAM takes every other service down with it.
What I deliberately skip at home
Docker's default seccomp profile already blocks around 44 syscalls — custom profiles buy little for homelab apps. Rootless Docker breaks enough networking and volume assumptions that I'd only adopt it greenfield (or use Podman from the start). Image signature verification and runtime detection (Falco) generate work and noise that a single-admin homelab can't act on. The Docker security docs and the OWASP Docker cheat sheet cover these tiers if your risk profile ever grows into them.
Bottom line
Retrofit the template above one stack at a time — about an hour each, mostly spent finding tmpfs paths. Order of value: get databases onto internal networks and the socket behind a proxy first, then non-root, then read-only, then file secrets. Do that and the realistic container attacks left over are the ones no compose file fixes: you installed something malicious and gave it your data on purpose.