Docker on a VPS Bypasses Your Firewall: Why It Happens and How to Fix It
Why published Docker ports ignore ufw and nftables INPUT rules, how to fix it with DOCKER-USER and localhost binds, plus real container hardening on a VPS.
You set up a firewall on your VPS, allowed only SSH, then started a container with -p 8080:80 — and suddenly port 8080 is reachable from the entire internet. Nothing is broken. This is exactly how Docker is designed to work, and it catches almost everyone the first time.
This guide explains the packet path that causes the bypass, the fixes that actually work, and the container-level hardening that matters once your ports are under control.
Direct answer
Docker publishes ports by writing its own nat and filter rules directly into the kernel firewall. Incoming traffic to a published port is DNAT'd in nat/PREROUTING and then evaluated in the FORWARD chain, not the INPUT chain. Tools like ufw and most hand-written nftables baselines filter input, so they never see that traffic.
Three fixes, in order of preference:
- Bind published ports to localhost —
-p 127.0.0.1:8080:80— and put a reverse proxy in front. This is the safest default. - Filter in the
DOCKER-USERchain, which Docker evaluates before its own rules and never flushes. - Disable Docker's firewall management (
"iptables": false) only if you are prepared to write NAT and masquerade rules yourself. Most people should not do this.
Why the bypass happens
When you run docker run -p 8080:80 nginx, Docker adds roughly this:
- A
nat/DOCKERrule that DNATstcp dport 8080to the container IP on port 80. - A
filter/DOCKERrule that accepts forwarded traffic to that container IP and port. - Masquerade rules so container egress works.
FORWARDjumps: first toDOCKER-USER, then toDOCKER-ISOLATION-STAGE-1, then toDOCKER.
A packet arriving on your public interface for port 8080 is destination-NAT'd before routing decisions complete, so the kernel routes it to the bridge network. That makes it forwarded traffic, not locally delivered traffic. Your input chain, where your ssh only policy lives, is simply not on the path.
On modern Debian and Ubuntu, iptables is the iptables-nft shim, so Docker's rules end up in nftables anyway. You can see them:
nft list table ip filter
nft list table ip nat
You will find DOCKER, DOCKER-USER, DOCKER-ISOLATION-STAGE-1 and DOCKER-ISOLATION-STAGE-2 chains there. If you wrote a clean inet filter table following a practical nftables baseline, it coexists with Docker's ip filter table — and because nftables evaluates every base chain registered on a hook, both rulesets run. That is also the key to the advanced fix below.
Confirm your real exposure
Never trust assumptions here. Check from the host:
ss -tulpn | grep -v 127.0.0.1
docker ps --format '{{.Names}}\t{{.Ports}}'
If docker ps shows 0.0.0.0:8080->80/tcp, that port is public. 127.0.0.1:8080->80/tcp is not. Then verify from outside the VPS — another host, or a VPN endpoint you control:
nmap -Pn -p 1-10000 --open your.vps.ip
Scan IPv6 separately with nmap -6. IPv6 exposure is the most commonly missed case because people test with a v4 address and assume they are done.
Fix 1: bind to localhost and use a reverse proxy
The cleanest architecture on a single VPS: only 80/443 (and SSH) are publicly reachable, everything else listens on loopback or on an internal Docker network.
services:
app:
image: myapp:1.4.2
expose:
- "3000" # visible to other containers only
db:
image: postgres:16
ports:
- "127.0.0.1:5432:5432" # local psql/tunnel access only
proxy:
image: caddy:2
ports:
- "80:80"
- "443:443"
Note the difference between expose and ports. expose publishes nothing to the host; it is documentation plus container-to-container reachability on a shared network. ports creates the DNAT rule that causes the bypass.
If you need remote admin access to a database or dashboard, use an SSH tunnel (ssh -L 5432:127.0.0.1:5432 user@vps) or put the service behind a WireGuard tunnel and bind it to the VPN interface address instead of 0.0.0.0.
One caveat: 127.0.0.1 binds are not a security boundary against other local users or other containers that can reach the host loopback via a host-mode network. On a single-tenant VPS with only trusted admins, it is a strong control. In multi-user environments, add real authentication too.
Fix 2: filter in DOCKER-USER
When you genuinely must publish a port but restrict who can reach it, put your rules in DOCKER-USER. Docker jumps to this chain first in FORWARD and does not touch its contents, so your rules survive daemon restarts and container recreation.
Because -I inserts at the top, add rules in reverse order of the logic you want:
# 3. default: drop container-bound traffic from the internet
iptables -I DOCKER-USER 1 -i eth0 -j DROP
# 2. allow a specific admin network
iptables -I DOCKER-USER 1 -i eth0 -s 203.0.113.0/24 -j RETURN
# 1. keep replies to established connections working
iptables -I DOCKER-USER 1 -i eth0 -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
Replace eth0 with your real public interface (ip -br addr). RETURN sends the packet back to FORWARD, where Docker's own accept rules can match it — that is the correct verdict here, not ACCEPT.
Two things to remember:
- Do the same for IPv6 with
ip6tables, if your Docker version manages IPv6 rules. Checkdocker infoand your/etc/docker/daemon.jsonfor theip6tablessetting — the default has changed across Docker Engine releases, so verify on your machine instead of assuming. - These rules are not persistent by themselves. Save them with
iptables-persistent, a systemd unit that runs beforedocker.service, or your configuration management tool.
Advanced: an nftables chain in front of Docker
If you prefer pure nftables and don't want iptables commands in your stack, create your own table with a forward hook at a lower priority number than Docker's filter chain (priority 0). A drop in any base chain is final, so this works as a pre-filter:
table inet dockergate {
chain forward {
type filter hook forward priority -10; policy accept;
ct state established,related accept
iifname != "eth0" accept
ip daddr 172.16.0.0/12 tcp dport { 80, 443 } accept
ip daddr 172.16.0.0/12 drop
}
}
This is more fragile than DOCKER-USER because it depends on your bridge subnets and hook priorities. Test it with a container running and a second SSH session open. Which brings us to the general rule: never edit a remote firewall without a recovery path — the lockout recovery notes in the nftables guide apply here too.
Fix 3: "iptables": false — usually a mistake
Setting this in /etc/docker/daemon.json stops Docker from writing any rules. You then own everything: DNAT for published ports, masquerade for container egress, inter-network isolation. Miss the masquerade rule and containers lose outbound connectivity; miss isolation and networks that should be separate can talk to each other.
Use it only if you are building a deliberate, documented firewall design and you understand Docker's bridge networking. Otherwise you have traded an unexpected open port for a subtly broken and unmaintainable network stack.
Hardening beyond the firewall
Closing ports fixes exposure. It does not fix what happens after a process inside a container is compromised.
Never mount the Docker socket. -v /var/run/docker.sock:/var/run/docker.sock grants root-equivalent control of the host to that container. Anything that can talk to the socket can start a privileged container with / mounted. If a monitoring or CI tool asks for it, use a socket proxy with a strict allowlist, or accept that the container is now part of your trusted control plane.
Drop privileges and capabilities. A sensible baseline for most application containers:
user: "10001:10001"
read_only: true
tmpfs:
- /tmp
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
Add back only the capabilities a service genuinely needs (NET_BIND_SERVICE for ports below 1024, though rebinding to a high port and mapping it is usually cleaner). Avoid --privileged and --network host unless the workload truly requires them; both remove most of the isolation you think you have.
Consider rootless mode or user namespaces. Rootless Docker runs the daemon as an unprivileged user, so a container escape lands in a normal user account rather than root. Trade-offs are real: extra setup for low ports, different networking behaviour (source IPs can be rewritten depending on the port driver), and some storage drivers and features behave differently. userns-remap is a middle ground for the standard daemon but conflicts with privileged and host-network containers and can confuse volume ownership. Test either option on a throwaway VPS before committing production to it.
Cap resources. A single runaway container can OOM-kill your database. Set mem_limit, cpus and pids_limit per service — the same reasoning as in low-RAM VPS tuning, just applied through the container runtime.
Cap logs. Docker's default json-file driver grows without bound. Set it globally in daemon.json:
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }
If your reason for self-hosting is privacy, remember that container logs are logs like any other and belong in your overall log minimization and retention policy.
Pin images. image: myapp:latest is unreproducible and makes supply-chain problems invisible. Pin tags, ideally by digest (image: nginx@sha256:...), and update deliberately.
Common mistakes
- Trusting
ufw statuson a Docker host. It reports its own rules and says nothing about Docker's chains. Verify with an external port scan. - Assuming the container's internal listener is a filter. If the app binds
0.0.0.0inside the container and you published the port to0.0.0.0, you are exposed. If you bind the host side to127.0.0.1, the app can keep listening on0.0.0.0inside its namespace — that is fine. - Forgetting IPv6. A firewall that only covers v4 while Docker or the host answers on v6 is an open door.
- Publishing databases "temporarily". Exposed Postgres, Redis, MongoDB and Elasticsearch instances are scanned and hit within minutes on any public IP.
- Putting drop rules in
FORWARDinstead ofDOCKER-USER. Docker inserts its jumps at the top ofFORWARD, so your appended rules may never be reached, and the ordering can change on restart. - Debugging without a second session open. Every firewall change on a remote host should be reversible. Combine this with proper SSH hardening so your recovery path is itself not the weak point.
FAQ
Does ufw work with Docker at all?
It manages host INPUT correctly, so SSH and host-level services are protected. It does not control published container ports unless you also add rules to DOCKER-USER or bind ports to localhost.
Is rootless Docker enough on its own? It significantly reduces the impact of a container escape, but it does not change port publishing semantics or protect against application-level flaws. Firewall discipline is still required.
What about Podman? Rootless Podman is the default and it does not install global firewall rules the way the Docker daemon does, so the surprise-open-port problem is less common. You still control exposure through how you publish ports, and you still need a host firewall.
Do I need Docker's DOCKER-USER rules if everything is behind a reverse proxy?
Not for exposure, but they are useful defense in depth — a mistyped compose file that publishes 0.0.0.0:5432 will then be blocked by default instead of silently going live.
Does this change on a Romanian or any other EU VPS? No. This is kernel and Docker behaviour, identical everywhere. Jurisdiction affects legal and privacy considerations, not packet paths. On our Romania-based KVM VPS you get full root and an unfiltered public IP, which means the firewall design is entirely yours to get right.
Conclusion
Docker does not have a security bug here — it has a documented networking model that most firewall tutorials ignore. Assume every -p flag punches a hole through your input rules, default to 127.0.0.1 binds plus a reverse proxy, use DOCKER-USER when a port genuinely must be public, and verify with an external scan rather than a local status command.
Then spend the remaining effort where post-exposure risk actually lives: no Docker socket mounts, dropped capabilities, non-root users, resource limits and pinned images. That combination is what separates a container host you can defend from one that just happens not to have been scanned yet.
