I run a handful of small sites from a Mac mini at home. Each one is a Docker container behind a Cloudflare tunnel, with no published ports, capabilities dropped, and no-new-privileges set. I thought of them as boxed in.
Then I asked a simple question: can those containers reach my network? The answer was yes, all of it.
What a container can reach by default
I started a throwaway container and tried a few things from inside it.
| Target | Result |
My router's admin page (192.168.1.1) | reachable |
| The Mac's own LAN address | reachable |
| A database port published on the Mac | open |
| The internet | reachable |
None of the usual hardening changes this. Dropping capabilities and not publishing ports limit what a container can do to the host and who can connect in. They say nothing about where a container can connect out. Docker's bridge networks NAT outbound traffic through the host, so a container can open a connection to anything the host can: the router, a NAS, a printer, every other machine in the house, and any other network the host happens to be joined to.
That matters more than it sounds. I run images I didn't write, built on dependencies I haven't read. One of my apps fetches URLs that users give it, to build link previews. The app refuses private addresses, but that is one guard in one codebase. I wanted the network itself to say no.
The plan: one rule for every container
Docker gives you a place for exactly this. Every forwarded packet passes through an iptables chain called DOCKER-USER before Docker's own rules, and Docker leaves whatever you put there alone.
The rule I wanted:
- Let replies to existing connections through.
- Let containers talk to other containers.
- Reject anything bound for a private address range.
- Let everything else (the internet) through.
On a Linux server that is four lines and you're done. I use OrbStack on macOS, where Docker runs inside a small Linux VM, and that is where it got interesting.
Five things that weren't in the documentation
1. The rules don't survive a reboot
iptables rules live in kernel memory, and OrbStack's VM boots clean. A one-off command protects you until the next reboot. On a Linux server you'd reapply them with a systemd unit. OrbStack has no boot hook I could find, so the job falls to something that is always running: a small helper container with restart: always, the VM's network namespace, and the NET_ADMIN capability. Every 15 seconds it checks the chain and rebuilds it if rules have gone missing.
It doesn't need --privileged. Host networking plus NET_ADMIN and NET_RAW is enough. NET_RAW isn't optional: iptables opens a raw socket, and NET_ADMIN alone gets you a permission error.
One check worth five seconds: from inside the helper, run iptables -S DOCKER-USER and make sure Docker's own rules show up. iptables has two backends, nf_tables and legacy. If the helper and Docker disagree, the helper writes to a ruleset Docker never reads, and everything looks fine. Mine reports nf_tables, and Docker's chains are all there.
Engine restarts are a different problem. That's #5.
2. host.docker.internal isn't a private address
Blocking 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16 took my router and LAN away from the test container. The database port on the Mac was still open.
The container wasn't using the Mac's LAN address. It was using host.docker.internal, which OrbStack resolves to 0.250.250.254. That is not in any private range, so a rule built from RFC 1918 alone leaves the host itself wide open. It needs its own line.
3. There's an IPv6 door as well
With that closed, host.orb.internal still worked. It resolves to an IPv6 address, and none of my IPv4 rules applied to it.
My containers had no working IPv6 route to the internet anyway, so the simplest safe answer was to reject every IPv6 packet leaving a Docker bridge for anywhere other than another bridge. Devices on a home network often have global IPv6 addresses, so there is no tidy "private range" to block the way there is for IPv4.
4. Blocking the host broke DNS, but only for some containers
After adding the host range, a fresh docker run couldn't resolve anything. My long-running apps were fine.
The difference is the network type. Containers on a named network use Docker's built-in resolver at 127.0.0.11. Containers on the default bridge are handed OrbStack's resolver directly, at 0.250.250.200, which sits inside the range I had just rejected. The fix is to allow port 53 to that one address, ahead of the reject rules, and nothing else.
I only found this because I ran one test on the default bridge instead of the named network I'd used for every test before it.
5. The firewall switched itself off and reported healthy
This is the one that would have hurt. Everything was working, so I restarted the Docker engine to confirm the helper would reapply the rules unattended. It did. The logs said so. The health check was green. And the rules did nothing.
OrbStack's DOCKER-USER chain ends with an ACCEPT, where stock Docker uses RETURN. When the engine starts, OrbStack inserts that ACCEPT at the top of the chain. My helper had started a moment earlier and put its rules in first, so OrbStack's ACCEPT landed above them:
-A DOCKER-USER -j ACCEPT
-A DOCKER-USER -i docker0 -j CONTAINER-EGRESS
-A DOCKER-USER -i br-+ -j CONTAINER-EGRESS
Every packet matched the first line and never reached mine. My health check asked "do the rules exist?", and they did.
The helper now checks the order as well as the presence, moves its rules back to the top when something else has got above them, and the health check fails unless they come first. Existence was the wrong thing to test. Effect was the right one.
The helper
Three files in their own folder. The script:
#!/bin/sh
set -eu
CHAIN=CONTAINER-EGRESS
INTERVAL="${INTERVAL:-15}"
# 0.250.250.0/24 is OrbStack's host.docker.internal: the Mac itself, outside every private range
BLOCK4="10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 100.64.0.0/10 0.250.250.0/24 ${EXTRA_BLOCK_CIDRS:-}"
rules4() {
echo "*filter"
echo ":$CHAIN - [0:0]"
echo "-A $CHAIN -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN"
echo "-A $CHAIN -o br-+ -j RETURN"
echo "-A $CHAIN -o docker0 -j RETURN"
# OrbStack's resolver, the nameserver of containers on the default bridge
echo "-A $CHAIN -d 0.250.250.200/32 -p udp --dport 53 -j RETURN"
echo "-A $CHAIN -d 0.250.250.200/32 -p tcp --dport 53 -j RETURN"
for cidr in $BLOCK4; do
echo "-A $CHAIN -d $cidr -j REJECT --reject-with icmp-admin-prohibited"
done
echo "COMMIT"
}
# no IPv6 range separates the local network from the internet, so all of it goes
rules6() {
echo "*filter"
echo ":$CHAIN - [0:0]"
echo "-A $CHAIN -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN"
echo "-A $CHAIN -o br-+ -j RETURN"
echo "-A $CHAIN -o docker0 -j RETURN"
echo "-A $CHAIN -j REJECT --reject-with icmp6-adm-prohibited"
echo "COMMIT"
}
ensure() {
ipt="$1"
want="$2"
have="$($ipt -S "$CHAIN" 2>/dev/null | grep -c '^-A' || true)"
if [ "$have" != "$(echo "$want" | grep -c '^-A')" ]; then
# --noflush with the chain declared replaces just this chain, in one transaction
echo "$want" | "$ipt-restore" --noflush
fi
# OrbStack puts its own ACCEPT at the top of DOCKER-USER when the engine starts,
# which can land above jumps already there and switch them off
if ! $ipt -S DOCKER-USER | grep '^-A' | awk -v c="$CHAIN" '$NF != c { other = 1 } $NF == c && other { bad = 1 } END { exit bad }'; then
$ipt -S DOCKER-USER | grep -- "-j $CHAIN" | sed 's/^-A/-D/' | while read -r rule; do
$ipt $rule
done
fi
for iface in br-+ docker0; do
$ipt -C DOCKER-USER -i "$iface" -j "$CHAIN" 2>/dev/null || $ipt -I DOCKER-USER 1 -i "$iface" -j "$CHAIN"
done
}
case "${1:-run}" in
check)
iptables -S DOCKER-USER | grep '^-A' | head -n 1 | grep -q -- "-j $CHAIN"
;;
run)
trap 'exit 0' TERM INT
while true; do
ensure iptables "$(rules4)"
ensure ip6tables "$(rules6)"
sleep "$INTERVAL" &
wait $!
done
;;
esac
A few choices worth explaining:
REJECT, not DROP. A blocked connection fails immediately instead of hanging until a timeout, which is kinder to the apps and much faster to test.
iptables-restore --noflush swaps the whole chain in one transaction, so there is never a moment where it is half built.
- Matching on
-i br-+ catches every Docker bridge, including ones created later, without listing subnets.
The image:
FROM alpine:3.22
RUN apk add --no-cache iptables
COPY firewall.sh /usr/local/bin/firewall.sh
RUN chmod +x /usr/local/bin/firewall.sh
ENTRYPOINT ["/usr/local/bin/firewall.sh"]
CMD ["run"]
And the compose file:
name: container-firewall
services:
firewall:
build: .
restart: always
# the VM's own network namespace, where Docker's chains live
network_mode: host
cap_drop: [ALL]
cap_add: [NET_ADMIN, NET_RAW]
security_opt: ["no-new-privileges:true"]
environment:
EXTRA_BLOCK_CIDRS: ${EXTRA_BLOCK_CIDRS:-}
healthcheck:
test: ["CMD", "/usr/local/bin/firewall.sh", "check"]
interval: 30s
timeout: 5s
retries: 2
One more leak: DNS
With connections blocked, containers could still look up every hostname on my network. Container DNS goes through the Mac's resolver, so a container sees whatever names the Mac sees, and whoever runs that resolver sees every lookup the containers make.
The fix is to give the Docker engine fixed public resolvers. In OrbStack that is ~/.orbstack/config/docker.json:
{
"dns": ["1.1.1.1", "1.0.0.1"]
}
Restart the engine and containers stop asking the Mac anything. The cost is that .local names and host.docker.internal no longer resolve inside containers, which for this purpose is the point.
After
| Target | Before | After |
| Router admin page | reachable | blocked |
| The Mac's LAN address | reachable | blocked |
The Mac via host.docker.internal | open | blocked |
| The Mac via IPv6 | open | blocked |
| Local hostnames | resolve | don't resolve |
| Another container on the same network | works | works |
| The internet | works | works |
| The sites, through their tunnels | up | up |
Connections coming in still work. I can reach a published database port from the Mac, because replies to an established connection are allowed back out.
What this isn't
- It's an egress filter, not a sandbox. It limits where containers can connect. It does nothing about what they can do on the host through a mounted volume or a Docker socket.
- There's a gap at startup. After the VM restarts, containers that come up before the helper are unfiltered for up to about 15 seconds.
- It's tested on one setup. OrbStack on one Mac. Docker Desktop has its own VM and its own addresses for the host, so the chain behaviour and the
0.250.250.x range will differ. The method carries over; the specifics need rechecking.
- A container that needs a private address will break. That is the rule working. Add an explicit exception for it above the rejects.
What I'd tell myself at the start
Test from inside a container, and test the thing you care about, not a proxy for it. Every surprise here came from a check I nearly skipped: the default bridge instead of a named network, the hostname instead of the IP, the restart I ran "just to confirm". The health check that stayed green while the firewall was off was checking that rules existed. The only test that would have caught it was the one that tried to reach the router.
No comments yet — be the first.