Firewalls & Network Security

Docker on a VPS: Why Published Ports Bypass ufw, and How to Secure Ports, the Socket and the API

Docker-published ports skip ufw rules and the docker group equals root. Bind to 127.0.0.1, use DOCKER-USER, protect the socket and consider rootless mode.

Published
Reading time
6 min read

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 -tlnp

Any 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 docker

Existing 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 ACCEPT

Replace 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 docker group; audit it with getent 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@host uses 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 boot

Ports 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 ps and ss -tlnp on every Docker host and remove any published port that does not need to be public.
  • Bind internal services to 127.0.0.1 and set the default bind address in daemon.json.
  • Use DOCKER-USER rules, 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 docker group and any container that mounts the Docker socket; consider rootless mode for new deployments.

Sources

SKYLINE Engineering

@skyline

The engineering team at SKYLINE Industrial Solutions. We publish field-tested guides drawn from real KSA and GCC deployments.

See author profile
SKYLINE engineering services

Need this implemented for you?

Reading is free — building it right takes a team. SKYLINE engineers ship Firewalls & Network Security for Aramco vendors, banks, hospitals and government agencies across Saudi Arabia. Talk to us before you start.

Aramco Approved Contractor ISO 9001 · ISO 27001 SAMA CSF aligned NCA ECC ready 247+ KSA clients

Comments

0 total · 0 threads
Be the first to leave a comment.