Docker on a VPS: Why It Bypasses Your Firewall and How to Harden It
Docker's published ports skip UFW and nftables INPUT rules. Here's exactly why, plus DOCKER-USER rules, loopback binds, rootless mode and container hardening.
You set up a firewall, allowed only SSH and HTTPS, then started a container with -p 5432:5432. A week later someone is brute-forcing your Postgres from the open internet. The firewall was never wrong — Docker simply wrote its own rules in front of yours.
This is the single most common way a properly firewalled VPS ends up exposing internal services. Below is why it happens at the packet level, and the fixes that actually hold, followed by the container-level hardening most guides skip.
The direct answer
Traffic to a published container port is DNAT'd in nat/PREROUTING and then evaluated in the FORWARD chain, not INPUT. Firewall rules written for the host (UFW's default policy, or an inet filter table with an input chain) only guard INPUT, so they never see that traffic.
You have four reliable options, in order of preference for most setups:
- Publish to loopback only:
-p 127.0.0.1:5432:5432, and expose services to the world through a reverse proxy on the host. - Don't publish at all. Put containers on a shared Docker network and let them reach each other by service name.
- Filter in the
DOCKER-USERchain, which Docker evaluates before its own accept rules. - Set
"iptables": falseindaemon.json— only if you are prepared to write every NAT and forward rule yourself. This breaks container egress until you do.
What does not work: adding ufw deny 5432 or a matching input drop rule in nftables. It will look correct in your config and do nothing.
Why the packet path skips INPUT
When the Docker daemon starts, it creates NAT and filter rules through the iptables interface. On Debian 11+/Ubuntu 22.04+ that interface is iptables-nft, so the rules physically live in the nftables ruleset — but in separate ip filter/ip nat tables named filter and nat, not in your hand-written inet filter table.
Two consequences follow, and both surprise people:
1. Your input rules are irrelevant for published ports. A packet arriving on the public interface for 10.0.0.5:5432 (the container) is destination-NAT'd in PREROUTING. Because the new destination is not a local address, routing sends it to FORWARD, where Docker's DOCKER chain accepts it. Your input chain is never consulted.
2. Every table with a forward base chain votes. In nftables, all tables are evaluated; a drop anywhere wins. So if you wrote a baseline ruleset with chain forward { type filter hook forward priority 0; policy drop; }, container networking may break in confusing, partial ways — outbound DNS from containers failing while docker exec ... ping 8.8.8.8 works, for example. If you are building a ruleset from scratch, the nftables baseline for a VPS is the right starting point; just be deliberate about your forward policy on a Docker host.
Verify what's actually loaded rather than trusting either config:
sudo nft list ruleset | less # see Docker's tables alongside yours
sudo iptables -S DOCKER-USER
sudo ss -tulpn | grep -E 'docker|LISTEN'
ss -tulpn on a default install shows docker-proxy listening on published ports. That userland proxy handles hairpin and loopback cases; external traffic still takes the DNAT path, which is why seeing a listener does not mean INPUT filtering applies.
Fix 1: bind to loopback and terminate TLS on the host
For anything that shouldn't be world-reachable — databases, Redis, admin panels, metrics endpoints, an app behind a proxy — publish to loopback:
services:
db:
image: postgres:16
ports:
- "127.0.0.1:5432:5432" # NOT "5432:5432"
app:
image: myapp:1.4
ports:
- "127.0.0.1:8080:8080"
Then let nginx or Caddy on the host proxy 127.0.0.1:8080 for the public site. This keeps TLS, HTTP rate limiting and access logging on the host where your normal tooling lives.
Two caveats:
- Loopback binding does not isolate containers from each other. Anything on the same Docker bridge network can still reach
db:5432directly. Use separate networks per stack if that matters. - If the host has IPv6, check whether your Docker version manages
ip6tables. Historically it did not, which meant IPv6-published ports could be reachable even when the IPv4 path was filtered. Checkdocker infoand yourdaemon.json, then confirm withnft list rulesetand an external scan rather than assuming.
Best of all, drop ports: entirely when a service only needs to talk to its siblings. expose: (or nothing at all, plus a shared network) publishes no host port and cannot be reached from outside.
Fix 2: filter properly in DOCKER-USER
Sometimes you genuinely need a published port open to a limited set of sources — a game server for known IPs, a webhook receiver, a staging app. Docker inserts a jump to DOCKER-USER at the top of FORWARD specifically so you can filter before its accept rules.
Rules there are matched on the original destination via conntrack, since DNAT has already happened. A workable pattern:
# allow established/related
sudo iptables -I DOCKER-USER 1 -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
# allow one source to the published port
sudo iptables -I DOCKER-USER 2 -s 203.0.113.7/32 -p tcp --dport 5432 -j RETURN
# drop everything else arriving from the public interface
sudo iptables -A DOCKER-USER -i eth0 -j DROP
Order matters: the final DROP must come after your RETURN rules. Test from an outside host, not from the VPS itself, and remember to mirror the logic in ip6tables if IPv6 is in play. Persist the rules the same way you persist the rest of your firewall — a reboot that restores your nftables file but loses these rules silently reopens the port.
DOCKER-USER is powerful but easy to get wrong, which is why loopback publishing remains the better default. Use DOCKER-USER as a safety net: a blanket drop from the public interface with explicit exceptions means a careless -p 6379:6379 in the future fails closed instead of open.
Container-level hardening that matters
Closing the network exposure is half the job. The other half is limiting what a compromised container can do to the host.
Never mount the Docker socket
-v /var/run/docker.sock:/var/run/docker.sock grants root on the host, full stop. Any process that can talk to the socket can start a privileged container mounting /. If a management UI or CI runner needs it, treat that container as part of the host's trust boundary — or use a socket proxy that whitelists specific API endpoints.
Drop privileges by default
A reasonable Compose baseline for a web service:
services:
app:
image: myapp@sha256:0f8c... # pin by digest, not :latest
read_only: true
tmpfs:
- /tmp
user: "10001:10001"
cap_drop: ["ALL"]
security_opt:
- no-new-privileges:true
mem_limit: 512m
pids_limit: 256
no-new-privileges blocks setuid escalation inside the container. cap_drop: ALL removes capabilities most apps never use; add back only what breaks (NET_BIND_SERVICE if you insist on binding port 80 inside the container). A read-only root filesystem with a tmpfs for scratch space turns many exploitation attempts into write errors.
Avoid --privileged and --network host unless you know exactly why you need them. Host networking puts the container's listeners directly on the host's INPUT path — which does mean your firewall applies again, but it also removes the network namespace boundary entirely.
Pin images and keep them updated
:latest makes your deployment non-reproducible and your rollback impossible. Pin a digest, then update deliberately. Containers do not benefit from the host's unattended security updates — a base image from eight months ago carries eight months of unpatched libraries no matter how current the kernel is. Rebuild or repull on a schedule and restart the stack.
Cap memory before the OOM killer chooses for you
Containers without mem_limit compete with the host for RAM, and the kernel's OOM killer may pick your database or sshd instead of the misbehaving app. On small instances this is a frequent cause of "the server randomly died." Setting per-service limits plus sane swap configuration is covered in more depth in the guide to low-RAM VPS tuning.
Bound the logs
The default json-file driver grows without limit. One chatty container can fill the disk and take down everything else. Either configure the driver per service or globally in /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
If you also care about how much request data you keep, container access logs deserve the same treatment as host logs — see VPS log minimization for anonymized formats and realistic retention.
Rootless Docker: what it changes
Rootless mode runs the daemon and containers as an unprivileged user with user namespaces. Two practical effects:
- Port publishing goes through RootkitKit's port forwarder, which binds the host port as a normal process. That means published ports do traverse the host
INPUTchain, so your ordinary firewall rules apply. This alone removes the surprise described in this article. - Unprivileged users cannot bind ports below 1024 by default, so you either use high ports behind a host reverse proxy or adjust
net.ipv4.ip_unprivileged_port_start.
Trade-offs are real. Depending on the port driver, the original client source IP may not be preserved (traffic appears to come from the forwarder), which breaks IP-based rate limiting and logging unless you use the slirp4netns driver or a proxy that passes X-Forwarded-For. Some storage drivers, cgroup features and networking modes behave differently, and --privileged workflows generally don't work. For single-tenant app hosting, rootless is a solid security win; for anything depending on low-level networking, test carefully first.
Common mistakes
- Trusting
ufw statuson a Docker host. It reports your intent, not the effective path for container traffic. Scan from outside:nmap -Pn -p- <ip>from another machine is the only honest test. - Assuming
EXPOSEin a Dockerfile opens a port. It's documentation and an aid to linked containers; it publishes nothing. - Publishing a database port "temporarily" for a migration and forgetting it. Use an SSH tunnel instead:
ssh -L 5432:127.0.0.1:5432 user@hostagainst a loopback-bound container. - Restarting Docker after loading firewall rules, or vice versa. Rule order in
FORWARDdepends on who wrote last. Reload both and re-verify after any change to either. - Treating a container breakout as impossible. It's rarer than a leaked credential or a vulnerable app, but not theoretical. If something looks wrong, work through a structured triage rather than guessing — the compromised VPS response guide covers what to preserve before you start deleting containers.
Pros and cons of running Docker on a small VPS
Advantages: reproducible deployments, clean dependency isolation, trivial rollback to a previous image digest, and easy multi-service stacks without polluting the host.
Costs: an extra networking layer that fights your firewall unless configured deliberately, image bloat on small disks, memory overhead per container, and a second patching surface (host packages and base images). Containers are also not a security boundary equivalent to a VM — a kernel exploit escapes them, whereas separate KVM instances offer stronger isolation. If two workloads have genuinely different trust levels, separate VPS instances are the safer architecture.
FAQ
Does -p 127.0.0.1:5432:5432 protect me completely?
From the public internet, yes, provided IPv6 is handled and no other container on the same network is compromised. It does not protect against a hostile process on the host or a sibling container on the same bridge.
Should I just set "iptables": false?
Only with a plan. Docker will stop creating masquerade rules, so container egress breaks until you write NAT yourself. Most people who try this end up with either broken networking or a rule set that's harder to audit than DOCKER-USER.
Is Podman a way out of this?
Podman's rootless-by-default model avoids the daemon and the FORWARD surprise for published ports, and its Compose-compatible tooling is usable. The container hardening advice above still applies unchanged.
Where should the reverse proxy run — host or container?
Either works. On the host it's simpler to combine with nftables rate limiting and host-level logging; in a container it's easier to version. Just make sure only the proxy publishes a public port.
Takeaway
Docker doesn't disable your firewall — it operates in a chain your rules don't cover. Default to 127.0.0.1 binds, publish nothing you don't need publicly, keep a blanket DOCKER-USER drop as a safety net, and verify with an external port scan after every change. Then spend the remaining effort on the things that limit blast radius: dropped capabilities, pinned digests, memory and log limits, and never handing out the Docker socket.
If you're building this on an IronBalkans VPS, you get full root on KVM, so the entire firewall and daemon configuration is yours to control — which also means the exposure is yours to verify. Scan your own ports from outside before you call it done.
