Docker on a VPS: Why It Bypasses Your Firewall and How to Harden It
Docker's published ports skip your INPUT rules. Learn why, plus rootless mode, capability dropping, socket risks and container limits on a VPS.
You configured a strict firewall on your VPS, allowed only SSH and HTTPS, then started a container with -p 8080:80. Now port 8080 answers from the public internet. Nothing is broken — this is exactly how Docker is designed to work, and it is the single most common way a hardened VPS quietly becomes an exposed one.
This article covers what Docker actually does to your host's packet filtering, how to publish ports safely, when rootless mode is worth the trade-offs, and which container hardening flags meaningfully reduce risk on a single-node VPS. It assumes you already know how to run containers and read a docker run command.
Direct answer: publish to localhost, filter in DOCKER-USER
Two rules solve 90% of the exposure problem:
- Bind published ports to a specific address, not to all interfaces:
-p 127.0.0.1:8080:80instead of-p 8080:80. Then put nginx or another reverse proxy on the host in front of it. - If you must filter Docker traffic with your host firewall, filter it in the
DOCKER-USERchain, not inINPUT. Docker guarantees that chain is evaluated before its own rules and never flushes it.
Everything else — rootless mode, capability dropping, read-only filesystems — is valuable, but it is secondary to not accidentally publishing services to the world.
Why Docker ignores your INPUT rules
When you publish a port, Docker (in its default rootful, bridge-network mode) programs two things into the kernel's packet filter:
- A DNAT rule in the
nattable'sPREROUTINGchain that rewrites the destination fromhost:8080tocontainer-ip:80. - ACCEPT rules in the
filtertable'sFORWARDchain, via Docker's ownDOCKERchain, allowing that rewritten traffic through.
Once the destination address is rewritten to the container IP, the packet is no longer destined for the host itself. It is routed, so it traverses FORWARD, not INPUT. Your carefully written input rules never see it. This applies whether you wrote your rules with iptables or with nftables — on modern Debian and Ubuntu, Docker talks to the kernel through iptables-nft, so its rules land in legacy-compatible ip filter and ip nat tables that coexist with your own inet table.
You can see the whole picture with:
sudo nft list ruleset | less
# or, in iptables terms:
sudo iptables -t nat -S | grep DOCKER
sudo iptables -S FORWARD
If you maintain a hand-written nftables baseline ruleset, understand that it governs traffic to the host, while Docker governs traffic through the host to containers. Both can be correct at the same time while your app is still world-reachable.
The correct place to filter
Add restrictions in DOCKER-USER. Example: allow container access only from a WireGuard subnet and your office IP, drop the rest.
iptables -I DOCKER-USER -i eth0 -s 10.8.0.0/24 -j RETURN
iptables -I DOCKER-USER -i eth0 -s 203.0.113.7/32 -j RETURN
iptables -A DOCKER-USER -i eth0 -j DROP
Order matters: RETURN hands the packet back to Docker's normal chains, DROP kills it. Note also that -s here is matched after DNAT, so match on source, interface and destination port of the container, not on the published host port.
Persist these rules the same way you persist the rest of your firewall, and remember that a docker network prune or daemon restart will not remove DOCKER-USER — that is the point of the chain.
IPv6 is a separate trap
By default, Docker's bridge networks are IPv4-only, so an IPv6-reachable VPS with a published port often has the service exposed on v4 but not v6. If you enable IPv6 for Docker, also enable ip6tables so the same filtering logic applies:
{
"ipv6": true,
"fixed-cidr-v6": "fd00:dead:beef::/64",
"ip6tables": true,
"userland-proxy": false
}
Put that in /etc/docker/daemon.json and restart the daemon. Without ip6tables, you can end up with IPv6 traffic reaching containers with no equivalent DOCKER-USER filtering.
Also verify what is actually listening
sudo ss -tulpn | grep -v 127.0.0.1
Anything bound to 0.0.0.0 or :: from docker-proxy or a container process is publicly reachable unless something upstream blocks it. Run this after every deployment change; it catches mistakes faster than reading Compose files.
The Docker socket is root access
/var/run/docker.sock is equivalent to unrestricted root on the host. Anyone who can talk to it can start a container with --privileged, mount / and rewrite /etc/shadow. Two consequences:
Adding a user to the docker group grants that user root. That is fine for your own admin account, but it is not a privilege reduction — do not treat it as one, and do not add service accounts to it.
Mounting the socket into a container hands that container root on the host. This is common with management UIs, CI runners and auto-update tools. If a workload genuinely needs the API, put a filtering socket proxy in front of it that exposes only the endpoints required (for example read-only container listing) and keep the proxy on an internal network with no published ports. Read-only mounting of the socket does not help: the API is not read-only just because the file descriptor is.
Rootless Docker: what you gain and what you give up
Rootless mode runs the daemon and containers as an unprivileged user inside a user namespace. A container escape lands the attacker as your unprivileged user, not root. That is a real improvement, and for single-app VPS deployments the cost is usually acceptable.
Trade-offs you should know before switching:
- Privileged ports. You cannot bind below 1024 unless you lower
net.ipv4.ip_unprivileged_port_startor grant the rootlesskit binaryCAP_NET_BIND_SERVICE. In practice, run containers on high ports and terminate TLS with a host reverse proxy. - Resource limits need cgroup v2 plus systemd delegation. On current Debian/Ubuntu with systemd this works, but on older or unusual setups
--memoryand--cpusmay be silently ineffective. Verify withdocker info— it lists missing cgroup capabilities under warnings. - Networking is slower and different. Traffic passes through a userspace network stack (slirp4netns or the newer pasta backend). Source IPs of inbound connections may appear as the internal gateway address unless the port driver preserves them, which breaks IP-based rate limiting and logging.
- No host-level firewall integration. Rootless Docker does not write iptables rules, which removes the bypass problem but also means published ports are just normal listening sockets — so your regular host firewall applies again. That is a plus.
- Some storage drivers and features are unavailable, and
--privileged, host networking semantics and certain mounts behave differently.
Install it as the target user with dockerd-rootless-setuptool.sh install, then enable lingering (loginctl enable-linger youruser) so the daemon survives logout.
If you need the rootful daemon but want some of the benefit, userns-remap in daemon.json maps container root to an unprivileged host UID range. It is less complete than rootless mode and breaks some volume-permission assumptions, but it is a meaningful step up from container-root-equals-host-root.
Per-container hardening that is worth the effort
Defaults are permissive. These flags cost little and remove whole classes of attack:
docker run -d --name app \
-p 127.0.0.1:8080:8080 \
--user 10001:10001 \
--cap-drop ALL \
--security-opt no-new-privileges \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--pids-limit 200 \
--memory 512m --memory-swap 512m \
--cpus 1.0 \
--restart unless-stopped \
myimage@sha256:...
What each does:
--userwith a non-zero UID means a process breaking out of the app still is not container root. Prefer images that declareUSERin the Dockerfile.--cap-drop ALLremoves the default capability set. Most web apps need nothing back; a service that binds port 80 inside the container needs--cap-add NET_BIND_SERVICE.--no-new-privilegesblocks setuid escalation inside the container.--read-onlyplus explicittmpfsmounts stops attackers from persisting payloads in the container filesystem and makes tampering obvious.--pids-limitcontains fork bombs; memory and CPU limits stop one container from taking the whole box down. On small instances, combine this with the host-side approach described in low-RAM VPS tuning — an unbounded container is one of the fastest ways to trigger the OOM killer on a 1–2 GB VPS.
Avoid --privileged entirely, and avoid -v /:/host style mounts. If a tool insists on either, treat that as a design smell and isolate it on a separate machine.
Secrets and environment variables
Environment variables passed with -e are visible in docker inspect, in the container's /proc/1/environ, and often in logs and crash reports. For anything sensitive, mount a file with restrictive permissions or use Compose secrets, which exposes the value at /run/secrets/<name>. Keep the source file outside the build context so it never ends up baked into an image layer.
Logging and disk
The default json-file log driver has no size cap. A chatty container can fill the disk and take down every service on the VPS. Set global defaults:
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
If you are deliberately minimizing what your server retains, container logs are a place people forget — the same reasoning applies as for nginx and journald log minimization. Decide retention intentionally rather than letting it default to "forever until the disk fills".
Images and updates
Pin images by digest for anything you care about; a moving :latest tag means your rebuild is not reproducible and a compromised upstream tag lands silently. Prefer small, well-maintained bases — fewer packages means fewer CVEs and a smaller attack surface. Scan periodically with a tool like Trivy or Grype, but treat the output as a prioritization aid, not a verdict: many reported CVEs affect packages your container never executes.
Rebuild rather than patch inside running containers. apt upgrade in a container is lost on the next recreate and hides drift. Keep the host patched on its own schedule, including the kernel, because container isolation depends entirely on host kernel correctness.
Common mistakes
- Assuming a firewall protects containers. Covered above; verify with
ssfrom outside the box, ideally with an external port scan against your public IP. - Publishing database ports.
-p 5432:5432on a Postgres container is extremely common in tutorials and almost never necessary. Containers on the same user-defined Docker network reach each other by name without publishing anything. - Using the default bridge network. Legacy
docker0gives every container on it flat access to every other one, and no DNS-based service discovery. Create per-stack networks. - Treating containers as a security boundary for untrusted code. They are not. A shared kernel means a kernel vulnerability, or a misconfigured capability or mount, can lead to host compromise. For genuinely hostile workloads, use separate VMs — full-root KVM instances have real hardware-assisted isolation between tenants, which is a different class of separation than namespaces.
- Running everything as container root because a volume permission failed. Fix ownership on the volume instead;
chownonce beats permanent root. - Auto-update tools with socket access. Convenient, but they turn a supply-chain compromise into host root. Prefer a CI pipeline that pushes a new digest and restarts the service.
Honest limits
Hardening flags reduce the blast radius of an application-level compromise. They do not stop an attacker who already has your SSH key, they do not protect against a malicious image you chose to run, and they do not make a shared-kernel container equivalent to a VM. Rootless mode narrows escape impact but adds networking and cgroup caveats that can bite you in production.
Also be clear about the privacy dimension: containers do nothing to conceal what your server does at the network level. Your public IP, TLS certificates and DNS records remain as visible as before. If that is your concern, the relevant work happens elsewhere — see the guides on SSH hardening and on keeping your origin IP from leaking behind a proxy.
FAQ
Should I use Podman instead?
Podman is daemonless and rootless by default, and does not rewrite your firewall rules to publish ports — both genuine advantages on a small VPS. If your tooling is Compose-based, Podman's Compose support is workable but not identical. Docker with -p 127.0.0.1: bindings and a DOCKER-USER policy is perfectly defensible; the important thing is knowing which model you are in.
Is iptables: false in daemon.json a good fix?
No. It stops Docker from managing rules, but then you own all NAT, forwarding and masquerading yourself. Containers typically lose outbound connectivity until you write those rules correctly. Use address-scoped publishing and DOCKER-USER instead.
Do I still need a host firewall if all ports are bound to localhost? Yes. It protects host services, blocks scans against anything you forget, and gives you a single place to enforce SSH source restrictions.
How do I audit an existing host quickly?
docker ps --format '{{.Names}}\t{{.Ports}}' to find bindings on 0.0.0.0, docker inspect for Privileged, CapAdd and socket mounts, and ss -tulpn for the ground truth. If anything looks like it has already been abused, follow a structured compromise detection and rebuild process rather than patching in place.
Takeaway
Docker's port publishing operates below your host firewall by design, so the fix is architectural: bind containers to localhost, front them with a reverse proxy, and express any container-level network policy in DOCKER-USER. Then reduce privilege inside containers with --cap-drop ALL, a non-root user, read-only filesystems and hard resource limits. Rootless mode is a worthwhile upgrade for straightforward workloads, provided you accept its networking and cgroup constraints. And keep the mental model honest — containers are an isolation improvement, not a boundary you would bet a hostile tenant on.
