A container started with -p 5432:5432 turns out to be reachable from the public internet even though the host's ufw firewall denies port 5432. Explain why the host firewall did not stop it, and how you would fix the exposure properly.
answer
- DNAT in PREROUTING -> packet is FORWARDed, INPUT never seen
- ufw/firewalld write INPUT rules
- fix 1: do not publish; use a shared network
- fix 2: -p 127.0.0.1:port:port + SSH tunnel
- fix 3: DOCKER-USER chain (Docker never rewrites it)
basics
~20 sufw rules live in the INPUT chain, but published container traffic is DNATed in PREROUTING and then traverses FORWARD, so INPUT is never consulted. Fix it by publishing to loopback only (-p 127.0.0.1:5432:5432), not publishing at all and using a shared network, or adding DROP rules in the DOCKER-USER chain.
solid answer
~60 sBecause publishing is not filtered where `ufw` filters. Docker DNATs the packet in the `nat` PREROUTING chain, so the destination becomes the container IP and the packet is **forwarded**, not delivered locally. `ufw`'s deny rules sit in the `INPUT` chain, which forwarded packets never traverse. The firewall is intact; it is simply on a different path. The correct fixes, best first: 1. **Do not publish it.** A database only its application needs should share a user-defined network with that app and publish nothing. 2. **Bind loopback.** For local admin access use `-p 127.0.0.1:5432:5432`, which makes the mapping unreachable off-box; reach it via an SSH tunnel. 3. **Filter in the right chain.** Docker leaves `DOCKER-USER` empty and evaluates it before its own accept rules, so a rule there — for example dropping traffic to the container subnet from anything but a trusted source — actually applies. What I would avoid: `--iptables=false`, which breaks container networking generally, and editing Docker's own chains, which the daemon rewrites. I would also audit `docker ps` for unintended 0.0.0.0 bindings.
code
bash · 7 linesdocker ps --format '{{.Names}}\t{{.Ports}}'
# safest: bind loopback only
docker run -d --name db -p 127.0.0.1:5432:5432 postgres:16
# or filter on the forward path in the chain Docker leaves alone
iptables -I DOCKER-USER -i eth0 ! -s 10.0.0.0/8 -p tcp --dport 5432 -j DROPgo deeper
Know that publishing a port can expose it beyond what the host firewall suggests, and that -p 127.0.0.1:... limits it to the machine.
Explain the PREROUTING DNAT into FORWARD versus INPUT distinction and give the loopback-binding fix.
Rank the fixes, use DOCKER-USER correctly with stateful rules, reject --iptables=false, and describe how you would audit a fleet for 0.0.0.0 bindings.
Treat it as a policy problem: publication defaults, perimeter controls as the real boundary, and guardrails in the deployment pipeline so nobody hand-publishes a data-tier port.
## Why the firewall is bypassed This surprises people because it looks like a firewall failure and is really a topology mismatch. A packet arriving at the host for a published port is first processed by the `nat` table's PREROUTING chain, where Docker's `DOCKER` chain rewrites its destination to the container's bridge address. After that rewrite the destination is no longer the host, so the routing decision classifies it as **forwarded** traffic and it traverses `FORWARD`, not `INPUT`. Tools like `ufw` and `firewalld` (in its common configurations) express user policy in `INPUT`. Forwarded traffic simply never reaches those rules. Docker, meanwhile, adds its own `FORWARD` rules that accept traffic to published ports. The result: `ufw status` shows the port denied, and the port answers from the internet anyway. This is documented Docker behaviour, not a bug, and it is one of the more common real-world causes of exposed databases, admin panels and caches on cloud hosts that have no separate cloud firewall in front. ## Fixes, in order of preference **1. Do not publish.** The strongest fix removes the mapping. Containers on a shared user-defined network reach each other on every port without publishing, so a database consumed only by an application in the same compose project needs no `ports:` entry at all. Every publication should be justified by a client outside the container world. **2. Publish to a specific interface.** `-p 127.0.0.1:5432:5432` creates the DNAT rule only for the loopback address, so nothing off-box can reach it; administrators tunnel in with `ssh -L 5432:127.0.0.1:5432 host`. Binding a private interface address works the same way for a trusted network. In Compose, write the same string in `ports:`. This is the fix to reach for when a mapping genuinely must exist for local use. **3. Filter in DOCKER-USER.** Docker inserts a jump to the `DOCKER-USER` chain at the top of `FORWARD` and never rewrites its contents, so rules there survive daemon restarts and container churn and are evaluated before Docker's accept rules. A typical policy drops traffic entering from the public interface toward the container subnet unless it comes from an allowed source. Because it is stateful filtering on the forward path, remember to allow established/related traffic and to think in terms of ingress interface plus source address rather than "the host's port". **4. Perimeter controls.** A cloud security group or an external firewall in front of the host is unaffected by any of this and is a good belt-and-braces layer. Many teams treat the security group as the real boundary and Docker's behaviour as a reason never to rely on host-local firewalls alone. ## What to avoid - `--iptables=false` on the daemon stops Docker managing rules at all, which breaks publishing, outbound masquerading and inter-network isolation. It is for people who fully own the ruleset, not a quick fix. - Adding rules inside `DOCKER` or the isolation chains: the daemon regenerates them, so your rule quietly disappears. - Assuming that because the app requires authentication, exposure is acceptable. Exposed ports invite protocol-level exploits, credential brute force and version fingerprinting; several large-scale ransomware campaigns against databases relied precisely on ports published this way. ## How to audit `docker ps --format '{{.Names}} {{.Ports}}'` lists every mapping; anything showing `0.0.0.0:` or `:::` is world-facing as far as the host's own firewall is concerned. `ss -ltnp` confirms what is bound. `iptables -t nat -L DOCKER -n` shows the DNAT rules actually installed. Then scan the host from outside — the only test that settles the argument. Newer Docker Engine releases have improved firewall integration, but the safe operating assumption remains: a published port is public unless you bound it to a specific address or filtered it in DOCKER-USER.
- Why is adding a DROP rule in the INPUT chain not enough?Published traffic is destination-NATed before routing, so its destination is the container rather than the host and it is classified as forwarded. Forwarded packets traverse FORWARD, never INPUT, so an INPUT rule is never evaluated for them. The rule must go on the forward path, which is what DOCKER-USER is for.
- Is --iptables=false a reasonable fix?Not as a general one. It stops the daemon from managing any rules, which breaks port publishing, outbound masquerading and the inter-network isolation chains, leaving you to write all of it yourself. It is only sensible when a team deliberately owns the entire firewall ruleset and has replacements for each of those functions.
The guard checks everyone entering the building's front door, but the delivery bay redirects parcels straight to a tenant without passing the desk.
saying these in an interview costs you the question
- Insisting ufw must be misconfigured rather than on the wrong chain
- Adding rules to Docker's own chains and expecting them to persist
- Reaching for --iptables=false as the fix
- Believing a strong password makes an exposed database port acceptable
- Not knowing that -p can take a host IP to bind loopback only