Docker makes it easy to run a database, a cache and a web app on one VPS. It also brings three security surprises that catch many teams: ports you publish can be reachable from the internet even when ufw says they are closed, anyone who can talk to the Docker daemon effectively has root on the host, and the daemon's API must never be exposed without protection. This guide explains each one with the fixes Docker's own documentation recommends.
Why published ports bypass ufw and firewalld
Docker creates its own firewall rules to implement port publishing. Its packet filtering documentation explains that traffic to a published container port is redirected in the nat table before it reaches the INPUT and OUTPUT chains that ufw uses, so the firewall configuration is effectively ignored. The Debian and Ubuntu install guides repeat the warning: with ufw or firewalld, exposed container ports bypass your firewall rules. Docker also states that firewall rules created directly with nft are not supported on a host with Docker installed.
The port publishing page is blunt: publishing container ports is insecure by default. Without a host address, -p 8080:80 publishes on all host addresses, IPv4 and IPv6.
Fix 1: publish only what must be public
Containers on the same user-defined network can reach each other by service name without any published port. Publish only the reverse proxy (80/443). Bind anything you need from the host itself, such as a database for a backup script or an admin panel reached through an SSH tunnel, to the loopback address:
# docker run
docker run -d -p 127.0.0.1:8080:80 nginx
# docker compose
services:
db:
image: postgres:16
ports:
- "127.0.0.1:5432:5432"Then check what is actually listening on public addresses:
docker ps --format '{{.Names}} {{.Ports}}'
sudo ss -tlnpAny 0.0.0.0: or [::]: entry in the Ports column is reachable from outside unless something in front of the server blocks it.
Fix 2: change the default bind address
To make loopback the default when a mapping has no host address, Docker documents two settings in /etc/docker/daemon.json: "ip" for the default bridge network, and default-network-opts for user-defined bridge networks such as those Compose creates:
{
"ip": "127.0.0.1",
"default-network-opts": {
"bridge": {
"com.docker.network.bridge.host_binding_ipv4": "127.0.0.1"
}
}
}
sudo systemctl restart dockerExisting containers keep their old bindings until they are recreated. Docker also warns that in releases older than 28.0.0, hosts on the same layer-2 network segment could reach ports published to localhost, so check docker version and do not rely on loopback binding alone on older engines.
Fix 3: filter published ports in DOCKER-USER
When a port must be published but only some sources should reach it, Docker's iptables page points to the DOCKER-USER chain, which is processed before Docker's own rules. Its example drops traffic from everything except one network on the external interface:
sudo iptables -I DOCKER-USER -i ext_if ! -s 192.0.2.0/24 -j DROP
sudo iptables -I DOCKER-USER -m state --state RELATED,ESTABLISHED -j ACCEPTReplace ext_if with your public interface (see ip -br addr). Because -I inserts at the top of the chain, running the commands in this order leaves the ESTABLISHED rule above the DROP rule, which lets replies to outbound container traffic through. Packets in this chain have already been translated to the container's address and port; to match the original destination, Docker recommends the conntrack options --ctorigdst and --ctorigdstport. Rules added with the iptables command do not survive a reboot unless you save them, for example with Debian's iptables-persistent package.
Docker 29.0.0 introduced an experimental nftables backend ("firewall-backend": "nftables"). It has no DOCKER-USER chain and cannot be used in Swarm mode, so read the migration notes before switching.
The firewall in front of the server
A cloud firewall that filters traffic before it reaches the VPS is not affected by Docker's host rules. On Skyline Cloud's Riyadh VPS, the console firewall applies outside the server (VPS overview). Allow only 22, 80 and 443 there, and an accidentally published database port stays unreachable from the internet.
The docker group is root
The post-installation guide warns that the docker group grants root-level privileges. The security overview explains why: whoever controls the daemon can start a container with the host's / directory mounted and change the host filesystem without restriction. In practice:
- Add only trusted administrators to the
dockergroup; audit it withgetent group docker. - Treat a container that mounts
/var/run/docker.sock(common for dashboards, proxies and CI agents) as having root on the host. - Never expose the daemon API on a TCP port without TLS. Docker refuses to start with an unauthenticated plain-HTTP TCP socket and recommends that the endpoint is reachable only from a trusted network or VPN. For remote management,
DOCKER_HOST=ssh://user@hostuses SSH instead.
Rootless mode
Rootless mode runs both the daemon and the containers as an ordinary user inside a user namespace, which limits the damage a daemon or runtime flaw can do. It needs the uidmap package and at least 65,536 subordinate UIDs and GIDs for the user in /etc/subuid and /etc/subgid:
sudo apt install uidmap
# as the non-root user:
dockerd-rootless-setuptool.sh install
systemctl --user start docker.service
sudo loginctl enable-linger $USER # start at bootPorts below 1024 need extra setup, either a capability on rootlesskit or net.ipv4.ip_unprivileged_port_start, as described in Docker's rootless tips. Test your workload first; some features behave differently.
What you should do
- Run
docker psandss -tlnpon every Docker host and remove any published port that does not need to be public. - Bind internal services to
127.0.0.1and set the default bind address indaemon.json. - Use
DOCKER-USERrules, not ufw, to restrict who can reach published ports, and make them persistent. - Keep a cloud firewall in front of the server with only the ports you intend to serve.
- Review the
dockergroup and any container that mounts the Docker socket; consider rootless mode for new deployments.
Sources
- docs.docker.com/engine/network/packet-filtering-firewalls
- docs.docker.com/engine/network/firewall-iptables
- docs.docker.com/engine/network/firewall-nftables
- docs.docker.com/engine/network/port-publishing
- docs.docker.com/engine/install/debian
- docs.docker.com/engine/install/linux-postinstall
- docs.docker.com/engine/security
- docs.docker.com/engine/security/rootless
- docs.docker.com/engine/security/rootless/tips
- docs.docker.com/reference/compose-file/services
- tracker.debian.org/pkg/iptables-persistent
- cloud.alskyline.com/vps-saudi-arabia
Comments
0 total · 0 threads