One of the most dangerous, well-documented security pitfalls on a modern Linux VPS is the silent conflict between Docker and UFW (Uncomplicated Firewall). By default, publishing a container port exposes the service to the entire public internet, bypassing UFW firewall rules entirely.
This runbook explains the underlying mechanism of Docker’s iptables manipulation, why standard naive fixes fail, and the three production architectures for securing containerized ports in 2026.
1. The Problem: How Docker Bypasses UFW
When you enable UFW on a fresh VPS, you configure default incoming traffic to be dropped:
sudo ufw default deny incoming
sudo ufw allow 22/tcp
sudo ufw enable
You then launch a containerized service (such as Redis, an internal admin dashboard, or a database) using standard port binding in compose.yaml:
services:
database:
image: postgres:16-alpine
ports:
- "5432:5432" # DANGER: Bypasses UFW!
Even though UFW is active and port 5432 is not explicitly permitted in UFW, PostgreSQL is now reachable by any IP address on the public internet.
Why This Happens
UFW inserts packet filtering rules into the standard INPUT chain of the Linux kernel’s iptables/nftables table. However, Docker configures network address translation (NAT) and routing rules inside the PREROUTING and FORWARD chains via its custom DOCKER chain. Packets destined for container ports are evaluated and forwarded before they ever reach the UFW INPUT filter chain.
2. Why Setting “iptables”: false Is Anti-Pattern
Many legacy blog posts suggest setting {"iptables": false} in /etc/docker/daemon.json. In production, do not do this:
- Disabling Docker’s iptables integration completely breaks outbound container networking (SNAT). Containers lose the ability to query public DNS or make external HTTP API requests unless you manually manage complex custom NAT tables.
- Container-to-container name resolution across user-defined bridge networks frequently fails.
3. Production Remediation Strategies
Strategy A: Explicit Localhost Binding (Recommended Baseline)
The cleanest, most resilient way to prevent unwanted public exposure without altering host firewall routing is to bind published ports explicitly to the loopback interface (127.0.0.1):
services:
internal-app:
image: my-service:latest
ports:
- "127.0.0.1:8080:8080" # Secure: Only accessible locally
When bound to 127.0.0.1, Docker instructs the kernel to only accept packets originating from the host itself. External internet scanners receive a connection refused or timeout. Only your local reverse proxy (Caddy / Nginx) or an SSH/Tailscale tunnel running on the host can communicate with the port.
Strategy B: Internal Overlay/Bridge Networks (Zero Exposed Host Ports)
For modern web services fronted by a reverse proxy (like Caddy or a Cloudflare Tunnel), publish zero ports to the host. Instead, join services to a dedicated internal Docker network:
# /opt/stacks/vaultwarden/compose.yaml
services:
vaultwarden:
image: vaultwarden/server:latest
networks:
- web_net # No "ports:" block!
networks:
web_net:
external: true
Traffic flows entirely through Docker’s internal virtual network switch. The host OS exposes only ports 80/443 (for Caddy) or zero inbound ports (if using Cloudflare Tunnels).
Strategy C: Hardening the DOCKER-USER iptables Chain
Docker officially reserves the DOCKER-USER chain for custom administrative firewall rules. Rules added here are evaluated before Docker forwards any packet to a container.
To restrict a published container port (e.g. port 8080) to a specific trusted management IP (e.g. 203.0.113.50) while blocking all other public traffic:
# Insert rule into DOCKER-USER chain
sudo iptables -I DOCKER-USER -i eth0 -p tcp --dport 8080 ! -s 203.0.113.50 -j DROP
4. Verification & Auditing Commands
Verify that your containers are not listening on 0.0.0.0:
# Inspect active listening sockets on the host
sudo ss -tulpn | grep -E "docker|LISTEN"
# Good output (Bound strictly to loopback):
# tcp LISTEN 0 4096 127.0.0.1:8080 0.0.0.0:* users:(("docker-proxy",pid=...))
# Dangerous output (Bound to all network interfaces):
# tcp LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:(("docker-proxy",pid=...))
Enforcing Firewall Policies with Ansible
To ensure that published ports and host firewall rules are configured deterministically across your servers, define your UFW policy and DOCKER-USER chains in Ansible:
# roles/security/tasks/firewall.yml
---
- name: Set UFW default deny incoming
community.general.ufw:
direction: incoming
default: deny
- name: Set UFW default allow outgoing
community.general.ufw:
direction: outgoing
default: allow
- name: Allow SSH custom management port
community.general.ufw:
rule: allow
port: "{{ ssh_port | string }}"
proto: tcp
- name: Allow standard reverse proxy ports if hosting edge ingress
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop: ["80", "443"]
when: open_web | bool
- name: Enable UFW service
community.general.ufw:
state: enabled
Related Production Runbooks
- Production Docker Engine Setup & daemon.json Hardening on a Linux VPS
- Modern Reverse Proxy Architecture: Caddy Server & Cloudflare Tunnels for Container Ingress
- Fleet-Wide Intrusion Prevention: Deploying CrowdSec with Docker, Caddy & Firewall Bouncers
- How to Lock Down and Harden a Fresh Linux VPS (Ubuntu / Debian)