Your ufw rules don't cover your Docker ports (and the log-rotation setting to make before your first container)
Two things bite almost every new Docker host in a homelab. One is a security surprise. The other is a disk that fills up a few weeks later. Both are a five-minute fix if you do them before the first real container.
Two things bite almost every new Docker host in a homelab. One is a security surprise. The other is a disk that fills up a few weeks later. Both are a five-minute fix if you do them before the first real container.
The firewall surprise
Docker's own documentation on packet filtering says it plainly: Docker and ufw are incompatible. When you publish a container's port, Docker routes the traffic in the nat table, before it reaches the INPUT chain that ufw uses. Your "deny all" rule never sees it.
So this does not do what it looks like it does:
sudo ufw default deny incoming
docker run -d -p 9000:9000 some/admin-tool
Port 9000 is open to anyone who can reach the host, ufw or not.
The fix is a habit, not a firewall rule
-
Publish as little as possible. On a well-laid-out Docker host only the reverse proxy publishes ports (80 and 443). Every other container is reached through the proxy on an internal Docker network and has no
ports:section at all. -
Bind admin ports to localhost. If a tool must publish a port for you alone, write
127.0.0.1:9000:9000and reach it through an SSH tunnel:
# Reach a localhost-only admin port from your laptop
ssh -L 9000:127.0.0.1:9000 [email protected]
-
Filter at the network edge. Your router decides which VLANs can reach the Docker VM at all. Rules for traffic to containers can also go in Docker's
DOCKER-USERchain; the "Packet filtering and firewalls" page in Docker's docs covers it.
A compose file that follows this looks like:
services:
app:
image: some/app:1.2
networks: [proxy]
# no ports: the reverse proxy reaches it on the proxy network
admin:
image: some/admin-tool:3.1
ports:
- "127.0.0.1:9000:9000"
networks:
proxy:
external: true
Set log rotation before the first real container
By default Docker uses the json-file logging driver and performs no log rotation. A chatty container can fill the disk with logs. Docker's docs suggest either the local driver or rotation options for json-file. Set them once in /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
Note the quotes around "3": log options in daemon.json must be strings. Restart Docker to apply:
sudo systemctl restart docker
docker info --format '{{.LoggingDriver}}'
Common mistake: changing daemon.json and expecting old containers to pick it up. Logging options apply to containers created after the change. Recreate the running ones:
docker compose up -d --force-recreate
One more thing about the docker group
The post-install page warns that the docker group grants root-level privileges: anyone in it can start a container that mounts / from the host. On a single-admin homelab VM that is usually acceptable. Never add a service account or a shared login to it, and anything that can talk to /var/run/docker.sock controls the host, so mount the socket read-only where the tool allows it and only into tools you trust.
Checklist
- Only the reverse proxy publishes ports (80 and 443).
- Admin tools are bound to 127.0.0.1 or sit behind the proxy.
- VLAN rules on the router decide who can reach the Docker VM.
- Log rotation is set in
/etc/docker/daemon.json, and existing containers were recreated. - Nobody but you is in the docker group.
This is adapted from my book Containers Without the Chaos (Homelab the Right Way, Book 8). The free sample is here and the full book is here.
Drafted with AI help and edited by the author; based on his book.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.