You lock down a host with a tidy firewall policy. You run a container with “just a test port.” Minutes later, a scanner in another timezone is already talking to it. You didn’t “open” anything—at least not in your head.
This is the Docker networking trap: one misread of how NAT and firewall chains interact, and your mental model stops matching the kernel’s reality. The kernel always wins. Let’s make sure it wins in your favor.
The misread: “My firewall decides what’s reachable”
The single most expensive misunderstanding in Docker networking is thinking your host firewall rules are evaluated “normally” for container-published ports.
On a Linux host using Docker’s default bridge networking, Docker installs iptables rules that rewrite traffic (DNAT) and route it toward containers. If your firewall policy assumed a simple “INPUT decides, then ACCEPT/DROP,” you can be wrong in two ways at once:
- You might be filtering the wrong chain. Published container ports are typically forwarded traffic, not local delivery. That means the decision is often in
FORWARD, notINPUT. - You might be filtering too late. NAT rewrites happen before filtering in ways that change what your rules “see.” You think you’re blocking port 8080 on the host, but after DNAT it’s “port 80 to 172.17.0.2,” and your rule doesn’t match anymore.
Docker’s job is to make containers reachable. Your job is to decide from where. If you don’t explicitly assert that boundary in the right place, Docker will happily do the reaching part for the entire internet.
Dry truth: the default posture of “publish a port” is “make it reachable.” The default posture of “run on a cloud VM” is “assume someone is scanning you.” Combine those and you get a ticket nobody enjoys.
The real packet path: conntrack, NAT, filter, and where Docker hooks in
A minimal mental model that doesn’t lie to you
When a packet arrives on a Linux host, the kernel doesn’t ask your firewall politely whether it’s okay to exist. It classifies the packet, consults conntrack, runs it through NAT hooks, then filtering hooks, then routes it. The order matters.
For a typical inbound TCP connection to a published Docker port on the host (say, 203.0.113.10:443), the important stops are:
- PREROUTING (nat): Docker’s DNAT rule can rewrite destination from the host’s IP:port to the container IP:port.
- Routing decision: After DNAT, the kernel may decide this traffic is not destined for the host itself but should be forwarded to a bridge interface (
docker0). - FORWARD (filter): This is where many host firewalls forget to look. Docker adds accept rules for established flows and for published ports.
- POSTROUTING (nat): For outbound traffic from containers, Docker typically applies SNAT/MASQUERADE so replies look like they came from the host.
The important Docker chains (iptables backend)
On systems using the iptables backend, Docker commonly creates and uses these chains:
DOCKER(innatandfilter): holds DNAT and some filter rules for container networks.DOCKER-USER(infilter): your supported insertion point. Docker jumps here early so you can enforce your policy.DOCKER-ISOLATION-STAGE-1/2: used to isolate Docker networks from each other.
Why UFW/firewalld “rules” can look correct and still lose
Many host-level firewall tools are wrappers. They generate iptables/nftables rules in certain chains with certain priorities. If they focus on INPUT but your container traffic is being forwarded, your “deny” never gets a vote.
On top of that, Docker may insert rules ahead of your distribution-managed rules, depending on how your firewall is built. The packet will match the first acceptable rule and never reach your carefully curated drop.
One paraphrased idea from Werner Vogels (Amazon CTO): “Everything fails all the time; design and operate like failure is normal.” Apply that to security too: assume misconfiguration is normal, and build guardrails.
Interesting facts and historical context
- Linux netfilter predates Docker by a decade. iptables became mainstream in the early 2000s, built on netfilter hooks in the kernel.
- Conntrack is state, not magic. Connection tracking enables “ESTABLISHED,RELATED” rules that make firewalls usable, but it also means one allowed packet can create a long-lived permitted flow.
- Docker’s default bridge is a classic Linux bridge. It’s not a special “Docker switch”; it’s the same primitive used by VMs and network namespaces for years.
- “Publish” means “bind on all interfaces” unless told otherwise.
-p 8080:80typically binds to0.0.0.0(and often::) unless you specify an IP. - Docker introduced DOCKER-USER as a concession to reality. People needed a stable place to enforce policy that survives Docker restarts and doesn’t get rewritten.
- nftables didn’t instantly replace iptables in practice. Many distros moved to nftables under the hood, but tooling, expectations, and Docker integration lagged for years.
- Hairpin NAT is older than your current incident. The “host talks to itself via public IP” problem exists in many NAT devices; Docker can trigger similar weirdness on a single box.
- Swarm ingress networking uses its own plumbing. The routing mesh can publish ports across nodes, which surprises teams expecting “only the node with the task is exposed.”
- Cloud security groups are not a substitute for host policy. They’re a layer, not a guarantee—misapplied rules or later changes can still expose you.
How “exposed” actually happens (four common modes)
Mode 1: You published a port and forgot it binds to the world
docker run -p 8080:80 … is convenient. It’s also explicit exposure. If you meant “only localhost,” you need to say so: -p 127.0.0.1:8080:80.
This is the “I didn’t think it would be public” bug. Docker thought it would. Docker was right.
Mode 2: Your firewall filters INPUT, but Docker traffic goes through FORWARD
If the packet is DNAT’d to a container IP, it’s no longer destined for the host. That pushes it into forwarding logic. If your security posture ignores FORWARD, you’ve left a side door open—wide.
Mode 3: You rely on UFW/firewalld defaults that don’t account for Docker’s chains
Some firewall managers set default forward policies to ACCEPT, or they don’t manage Docker’s chains at all. You can end up with a firewall that looks restrictive for the host, while containers are on a separate fast lane.
Mode 4: You optimized for performance and accidentally removed a choke point
Disabling conntrack, changing bridge-nf-call-iptables settings, switching iptables backends, or turning on “fast path” features can all change which rules run and when. This is where “it worked in staging” goes to die.
Joke #1: NAT is like a corporate org chart—everything routes through it, and nobody admits they own it.
Practical tasks: commands, outputs, and the decision you make
These are not “run this because a blog said so” commands. Each one tells you something concrete. Each has a decision attached.
Task 1: List published ports and their bind addresses
cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
NAMES IMAGE PORTS
web nginx:1.25 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
metrics prom/prometheus 127.0.0.1:9090->9090/tcp
db postgres:16 5432/tcp
What it means: web is reachable on all IPv4 and IPv6 interfaces. metrics is localhost-only. db isn’t published at all.
Decision: If it shouldn’t be internet-reachable, stop and re-run with an explicit bind IP or remove the publish. Don’t “fix it in the firewall later” unless you also control DOCKER-USER.
Task 2: Confirm what’s actually listening on the host
cr0x@server:~$ sudo ss -lntp | sed -n '1,8p'
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:(("docker-proxy",pid=2211,fd=4))
LISTEN 0 4096 127.0.0.1:9090 0.0.0.0:* users:(("docker-proxy",pid=2332,fd=4))
LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1023,fd=3))
What it means: Docker is exposing 8080 on all interfaces. Even if iptables rules exist, a listening socket is step one of reachability.
Decision: If you see 0.0.0.0 or :: and you didn’t mean it, fix the publish flags or the service definition first.
Task 3: Inspect docker-proxy usage (and don’t assume it’s gone)
cr0x@server:~$ ps -ef | grep -E 'docker-proxy|dockerd' | head
root 1190 1 0 08:11 ? 00:00:12 /usr/bin/dockerd -H fd://
root 2211 1190 0 09:02 ? 00:00:00 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080 -container-ip 172.17.0.2 -container-port 80
root 2332 1190 0 09:03 ? 00:00:00 /usr/bin/docker-proxy -proto tcp -host-ip 127.0.0.1 -host-port 9090 -container-ip 172.17.0.3 -container-port 9090
What it means: Some setups still use userland proxying for published ports. That can change how packets traverse the host firewall and can affect logging.
Decision: If you’re troubleshooting “why did my INPUT rule not match,” note whether traffic is being proxied locally versus forwarded to a container.
Task 4: See Docker’s iptables rules in nat table (where the DNAT happens)
cr0x@server:~$ sudo iptables -t nat -S | sed -n '1,60p'
-P PREROUTING ACCEPT
-P INPUT ACCEPT
-P OUTPUT ACCEPT
-P POSTROUTING ACCEPT
-N DOCKER
-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A DOCKER -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80
What it means: Any packet destined to local addresses on port 8080 can be DNAT’d to the container. That includes public IPs on the host.
Decision: If you need to restrict sources, do it in DOCKER-USER (filter) or adjust publishing/binding; don’t fight DNAT with late INPUT rules.
Task 5: Check filter table ordering, especially DOCKER-USER
cr0x@server:~$ sudo iptables -S FORWARD
-P FORWARD DROP
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -o docker0 -j DOCKER
-A FORWARD -i docker0 ! -o docker0 -j ACCEPT
-A FORWARD -i docker0 -o docker0 -j ACCEPT
What it means: Docker is jumping into DOCKER-USER early. That’s good: you have a choke point. Default FORWARD policy is DROP here, which is also good.
Decision: Put your allowlists/denylists in DOCKER-USER. If your system doesn’t have this jump, you need to fix that policy model immediately.
Task 6: Inspect DOCKER-USER chain (your enforcement point)
cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -j RETURN
What it means: No policy is enforced for container-forwarded traffic. Everything passes through unchanged.
Decision: Add explicit rules: deny by default and allow only what should be reachable.
Task 7: Add a “default deny inbound to containers” rule safely
cr0x@server:~$ sudo iptables -I DOCKER-USER 1 -i eth0 -o docker0 -m conntrack --ctstate NEW -j DROP
cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -i eth0 -o docker0 -m conntrack --ctstate NEW -j DROP
-A DOCKER-USER -j RETURN
What it means: New inbound connections arriving on eth0 and being forwarded to containers via docker0 will be dropped. Existing established flows remain allowed via other rules.
Decision: This is your emergency brake. After applying it, selectively allow required published ports from required sources.
Task 8: Allow a specific published service from a specific source range
cr0x@server:~$ sudo iptables -I DOCKER-USER 1 -p tcp -s 198.51.100.0/24 -d 172.17.0.2 --dport 80 -j ACCEPT
cr0x@server:~$ sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -p tcp -s 198.51.100.0/24 -d 172.17.0.2 --dport 80 -j ACCEPT
-A DOCKER-USER -i eth0 -o docker0 -m conntrack --ctstate NEW -j DROP
-A DOCKER-USER -j RETURN
What it means: You’re permitting only the intended source range to reach that container port, ahead of the default drop.
Decision: Prefer allowlisting by source and destination where possible. If you can’t, at least restrict by interface and port.
Task 9: Verify from the host’s perspective what route and interface forwarding uses
cr0x@server:~$ ip route get 172.17.0.2
172.17.0.2 dev docker0 src 172.17.0.1 uid 1000
cache
What it means: Traffic to the container IP goes via docker0. That validates your interface matches in firewall rules.
Decision: If you’re using custom networks (e.g., br-*), update rules to match the correct egress interface(s).
Task 10: Check bridge netfilter sysctls that change whether bridged traffic hits iptables
cr0x@server:~$ sudo sysctl net.bridge.bridge-nf-call-iptables net.bridge.bridge-nf-call-ip6tables
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
What it means: Bridged traffic will traverse iptables rules. If these are 0, some filtering expectations break, and you can end up with “firewall rules don’t work” confusion.
Decision: Keep these enabled unless you deeply understand the trade-offs and have an alternate enforcement mechanism.
Task 11: Determine whether iptables is using nft backend (it matters for debugging)
cr0x@server:~$ sudo iptables -V
iptables v1.8.9 (nf_tables)
What it means: iptables commands are manipulating nftables under the hood. Rule ordering and coexistence with native nft rules can be surprising.
Decision: When troubleshooting, use nft list ruleset too, not just iptables output.
Task 12: Inspect nftables ruleset for Docker interaction
cr0x@server:~$ sudo nft list ruleset | sed -n '1,80p'
table inet filter {
chain forward {
type filter hook forward priority 0; policy drop;
jump DOCKER-USER
jump DOCKER-ISOLATION-STAGE-1
ct state related,established accept
}
chain DOCKER-USER {
iif "eth0" oif "docker0" ct state new drop
return
}
}
table ip nat {
chain PREROUTING {
type nat hook prerouting priority -100; policy accept;
fib daddr type local jump DOCKER
}
chain DOCKER {
tcp dport 8080 dnat to 172.17.0.2:80
}
}
What it means: You can see the same logical structure in nftables terms: NAT in PREROUTING, filtering in forward, and your DOCKER-USER policy.
Decision: If your distro uses nftables natively, consider managing Docker policy in nft directly for consistency—but ensure Docker still keeps its chains stable.
Task 13: Confirm whether a port is reachable from an external vantage point
cr0x@server:~$ nc -vz -w 2 203.0.113.10 8080
Connection to 203.0.113.10 8080 port [tcp/*] succeeded!
What it means: It’s reachable. No amount of “I thought the firewall…” changes that.
Decision: If this should not be reachable, stop traffic at DOCKER-USER or remove the publish and retest. Then check IPv6 separately.
Task 14: Check IPv6 exposure explicitly
cr0x@server:~$ sudo ss -lnt | grep ':8080 '
LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:*
LISTEN 0 4096 [::]:8080 [::]:*
What it means: You’re listening on IPv6 too. If your security group or firewall only considered IPv4, you may have accidental IPv6 exposure.
Decision: Either secure IPv6 equivalently or deliberately disable it for the service. “We don’t use IPv6” is not a control.
Task 15: Trace packet traversal with counters (did your rule even match?)
cr0x@server:~$ sudo iptables -L DOCKER-USER -v -n
Chain DOCKER-USER (1 references)
pkts bytes target prot opt in out source destination
12 720 ACCEPT tcp -- * * 198.51.100.0/24 172.17.0.2 tcp dpt:80
55 3300 DROP all -- eth0 docker0 0.0.0.0/0 0.0.0.0/0 ctstate NEW
890 53400 RETURN all -- * * 0.0.0.0/0 0.0.0.0/0
What it means: Counters show the story: allow rule matched 12 packets; drop rule is actively blocking new attempts.
Decision: If counters don’t move, you’re looking at the wrong chain or wrong interface. Stop guessing; follow the counters.
Task 16: Verify Docker network(s) and the bridge interface names
cr0x@server:~$ docker network ls
NETWORK ID NAME DRIVER SCOPE
c2f6b2a1c3c1 bridge bridge local
a7d1f9e2a5b7 appnet bridge local
cr0x@server:~$ ip link show | grep -E 'docker0|br-'
4: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
6: br-a7d1f9e2a5b7: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
What it means: User-defined bridge networks create br-* interfaces. If you only wrote rules for docker0, other networks may still be exposed.
Decision: Update firewall policy to cover all Docker bridge interfaces, not just the default.
Fast diagnosis playbook
If you suspect a container port is exposed (or a firewall “isn’t working”), don’t take a scenic route. Do these in order.
First: identify the exposure surface
- List published ports (
docker pswith Ports) and look for0.0.0.0/::. - Check listening sockets (
ss -lntp) to confirm what’s bound and by which process. - Test reachability from outside (or a jump host) with
ncorcurl.
Second: locate the decision point in netfilter
- Find DNAT rules in
iptables -t nat -S(or nft nat table). - Check FORWARD chain ordering and confirm
DOCKER-USERis jumped early. - Use counters (
iptables -L -v) to see what rules are actually matching.
Third: fix with the least clever change
- Prefer narrowing the bind (
-p 127.0.0.1:…) or removing-pentirely. - Enforce baseline policy in
DOCKER-USER: default drop for new inbound to Docker bridges; allowlist what must be public. - Re-test external reachability and validate counters moved the way you expect.
Joke #2: If you’re “just opening a port for five minutes,” your attackers are punctual.
Common mistakes: symptom → root cause → fix
1) “UFW says it’s blocked, but the container is still reachable”
Symptom: UFW denies 8080, but nc from the internet connects.
Root cause: Traffic is being forwarded to a container (FORWARD), while UFW rules mainly cover INPUT. Docker’s iptables rules permit forwarding.
Fix: Put policy into DOCKER-USER. Either default-drop new inbound to Docker bridges, or allowlist specific sources/ports.
2) “I blocked port 8080 on INPUT, but it still works”
Symptom: An INPUT drop rule for tcp/8080 does nothing.
Root cause: DNAT in PREROUTING changes destination to container IP:port; packet no longer matches INPUT for host delivery.
Fix: Filter in FORWARD (ideally in DOCKER-USER). Or don’t publish on 0.0.0.0 in the first place.
3) “It’s only exposed on IPv6 and nobody noticed”
Symptom: IPv4 is locked down; IPv6 scanner reports an open port.
Root cause: Docker published on ::; IPv6 firewall isn’t equivalent; security group ignores IPv6.
Fix: Add IPv6 firewall rules; bind explicitly to IPv4 localhost if intended; or disable IPv6 carefully and knowingly.
4) “After enabling nftables, Docker networking got weird”
Symptom: Published ports intermittently fail, or rules don’t appear where you expect.
Root cause: Mixed management: native nft rules plus iptables-nft translation plus Docker-generated chains. Priority and hook order differ from assumptions.
Fix: Standardize: either manage via iptables consistently (with nft backend awareness) or adopt a coherent nftables policy that still respects Docker’s chains.
5) “Custom bridge network containers are exposed even though docker0 is locked down”
Symptom: Rules referencing docker0 work, but containers on br-* are reachable.
Root cause: User-defined networks use different bridge interfaces; your rules don’t match them.
Fix: Match on oifname "br-*" (nft) or add interface rules for each bridge, or group them using ipsets/nft sets.
6) “Swarm published a port everywhere”
Symptom: A service published on one node appears reachable on all nodes.
Root cause: Swarm routing mesh (ingress) publishes the port cluster-wide; traffic is forwarded internally to an active task.
Fix: Use host mode publishing for node-local exposure, or enforce edge restrictions with firewalls/load balancers, and treat Swarm ingress as a shared exposure plane.
Three corporate mini-stories from the field
Mini-story 1: The incident caused by a wrong assumption
A mid-sized SaaS company migrated a legacy service into containers on a pair of cloud VMs. The team had a hard-earned habit: lock down everything at the host firewall, then punch holes only for the reverse proxy and SSH from the VPN.
During the migration, an engineer published a port for an internal admin UI: -p 8443:8443. The assumption was old-school and reasonable: “Host firewall blocks it unless we open it.” They didn’t open it.
Two days later, the security team flagged outbound traffic to an unfamiliar IP range. The admin UI wasn’t “hacked” in a Hollywood sense; it was simply reachable. The UI had basic auth, but it also had an endpoint that triggered expensive background jobs. An internet rando found it and treated it like a free crypto heater.
The post-incident argument was predictable. The app team blamed the firewall. The infra team blamed the app team for publishing a port. Both were half right, which is how you get repeat incidents.
The actual fix was boring: enforce an explicit default-drop for inbound NEW connections to Docker bridge interfaces in DOCKER-USER, and require services to bind to 127.0.0.1 unless there’s a ticket proving they need external access. The next admin UI got published to localhost and then routed through the reverse proxy with proper authentication and audit logs.
Mini-story 2: The optimization that backfired
A trading-adjacent platform chased latency. Someone noticed packet processing overhead and proposed “simplifying firewalling” by relying more on upstream security groups and less on host rules. In the same change window, they flipped a few kernel sysctls and cleaned up what they considered “redundant” iptables chains.
Performance did improve—by enough to make graphs look good in a meeting. Then a seemingly unrelated issue appeared: a containerized service was reachable from a network segment that wasn’t supposed to talk to it. Not the public internet, but a broad internal subnet with too many laptops and too much curiosity.
The root cause wasn’t one change; it was the combination. Removing the host-level forward filtering and changing bridge netfilter behavior meant some traffic paths stopped hitting the intended enforcement points. The upstream security group still “looked right,” but inside the VPC the blast radius widened.
They had optimized out a layer of defense and accidentally optimized in a lateral movement path. The fix wasn’t to undo performance work entirely. It was to reintroduce enforcement at DOCKER-USER with a narrow ruleset and minimal matches, and to measure latency impact honestly. It was small. The risk reduction was not.
Mini-story 3: The boring practice that saved the day
A healthcare-adjacent company (lots of compliance, lots of audits, lots of people who like checkboxes) had a rule: any container port published to non-localhost must be justified, documented, and tested from an external vantage point as part of the change.
It wasn’t glamorous. Engineers grumbled. But the pipeline included a simple stage: deploy to a canary host, run a small suite of external connectivity checks, and fail the change if any “should-be-private” ports were reachable.
One Friday, a service owner tried to “temporarily” publish a debug port to all interfaces so a vendor could test something. The change request mentioned it, but the risk assessment was hand-wavy. The pipeline caught it because the port was reachable from a non-approved source network in the test environment.
The fix was not a hero move. It was a small config change: publish to 127.0.0.1 and access it through a short-lived SSH tunnel from a controlled bastion. The vendor still got their test. The port never became a surprise internet endpoint. Everyone went home on time, which is the real KPI.
Checklists / step-by-step plan
Baseline hardening checklist (single Docker host)
- Inventory exposure: list published ports (
docker ps) and listening sockets (ss -lntp). - Decide intent per port: public, VPN-only, internal-only, or localhost-only.
- Make binding explicit: use
-p 127.0.0.1:HOST:CONTAINERfor localhost-only; specify an internal interface IP if needed. - Enforce default policy in DOCKER-USER: drop new inbound forwarded connections by default.
- Allowlist intentionally: add narrow accepts before the drop—by source range, destination container IP/port, and interface where possible.
- Validate IPv6: check listeners and firewall parity; don’t assume it’s off.
- Persist rules: ensure your DOCKER-USER rules survive reboots (system-specific: iptables-persistent, nftables config, etc.).
- Retest from outside: verify reachability aligns with intent.
Step-by-step: convert a “public publish” into “private behind reverse proxy”
- Change publish from public to localhost:
- From
-p 8080:80to-p 127.0.0.1:8080:80.
- From
- Front it with a reverse proxy on the host or a dedicated edge container that is the only internet-facing port.
- Enforce DOCKER-USER baseline drop for new inbound to bridge interfaces, so “someone republished a port” doesn’t become instant exposure.
- Add auth, rate limits, and logs at the reverse proxy layer; containerized services should not individually reinvent perimeter security.
- Run external validation with
nc/curlfrom a non-approved network and confirm it fails.
Step-by-step: emergency containment when you suspect exposure
- Apply an emergency drop in
DOCKER-USERfor new inbound to docker bridges (interface-scoped) to stop the bleeding. - Confirm external reachability stops via a test from outside.
- Identify the published ports and remove or restrict them at the Docker run/compose level.
- Replace the emergency drop with allowlisted rules so required services remain reachable.
- Audit IPv6 exposure and mirror the containment there too.
FAQ
1) Is “EXPOSE” in a Dockerfile the same as publishing a port?
No. EXPOSE is metadata. Publishing happens with -p/--publish or compose ports:. Only publishing creates host reachability.
2) Why does my INPUT chain not see traffic to published container ports?
Because after DNAT, the traffic is routed to a container IP and becomes forwarded traffic. That’s typically filtered in FORWARD, not INPUT.
3) Where should I put my host-level policy for Docker traffic?
In DOCKER-USER. It’s designed as the stable insertion point that Docker won’t rewrite on restart.
4) If I bind to 127.0.0.1, am I safe?
You’re safer. Localhost binding prevents external network access at the socket level. Still, consider insider threats, local compromise, and whether another process might proxy it outward.
5) Does Docker always use docker-proxy for published ports?
No. Behavior varies by Docker version, kernel features, and configuration. Don’t assume one packet path—confirm with ss and process listings.
6) How does IPv6 change the story?
It adds a second exposure plane. You can have “secure IPv4” and “open IPv6” at the same time. Validate listeners and firewall rules for both.
7) I’m using nftables. Should I stop using iptables commands?
If your system runs iptables with the nft backend, iptables commands still work but can obscure the final rule ordering. For deep debugging, inspect nft list ruleset.
8) What about rootless Docker—does it avoid these firewall pitfalls?
Rootless mode changes networking and port publishing mechanics, and it can reduce the blast radius of daemon-level privileges. It doesn’t remove the need to think clearly about what’s reachable and from where.
9) Does Docker Compose change any of this?
No fundamental change. Compose is a nicer interface for the same primitives. If compose says ports: - "8080:80", you’re publishing to the world unless you specify an IP.
10) I only trust cloud security groups. Can I ignore host firewalling?
You can, until a security group is misapplied, copied from another environment, or changed under pressure. Defense in depth is not a slogan; it’s what keeps “one bad day” from becoming “one bad quarter.”
Next steps you can do today
- Audit every host: run
docker psandss -lntp. Write down what’s bound to0.0.0.0and::. - Establish a default container inbound policy: implement a baseline drop for new inbound forwarded connections in
DOCKER-USER, then allowlist explicitly. - Make publishing intentional: require explicit bind IPs in compose/service definitions; default to localhost and route through an edge proxy.
- Test like an attacker: validate reachability from outside your network segment, including IPv6.
- Operationalize it: persist rules, add CI/CD checks for unintended published ports, and treat firewall/Docker changes as production changes with rollback.
If you remember one thing: Docker isn’t “bypassing” your firewall out of malice. It’s using the kernel exactly as designed. Your job is to put policy in the place the kernel actually consults.