Skip to main content
intermediatePart 1

Your firewall is lying to you: hardening Docker networks for multi-agent systems

· 15 min read
Rafael Fernandes
NLP Engineer & Tech Writer at WiLine
Share:
+
0/3
🎯 Skill path0/3 earned
Hardening self-hosted AI infra

You start with one agent. Then it needs a database. Then you add a second agent, a messaging bridge, an observability stack. Six months later a single WEC Instance is running five compose projects, twenty-something containers, and nobody remembers which ports are open to the world.

That's not a hypothetical — that's the box this tutorial was written on. So instead of theorizing, we probed it: can containers reach each other across stacks? Can they reach the databases? Is the firewall actually protecting anything?

Three of the answers surprised me. One of them was a database sitting there with no password. And the firewall — the firewall was lying.

The box we're auditing

Five independent compose projects, deployed over months, each with its own network (host, none and bridge are Docker's built-ins, plus one leftover from a service that isn't running):

docker network ls
Output
NETWORK ID NAME DRIVER SCOPE
655850b24125 bridge bridge local
05e5d49e7e9d evolution-api_default bridge local
e8bd75ecbb0a host host local
ce697baac173 langfuse_default bridge local
ead3e0822ce6 nlp-evaluation-service_nlp-net bridge local
5d0ab32d14d7 none null local
9a6727150f2b openclaw_default bridge local
ec77b98f457b rag-service_default bridge local
5f88cabf97f0 remark42_default bridge local

And here's what's published to the world:

docker ps --format '{{.Names}}\t{{.Ports}}' | grep '0.0.0.0'
Output
remark42-remark42-1 0.0.0.0:8082->8080/tcp, [::]:8082->8080/tcp
evolution-api-evolution-api-1 0.0.0.0:8080->8080/tcp, [::]:8080->8080/tcp
langfuse-langfuse-web-1 0.0.0.0:3001->3000/tcp, [::]:3001->3000/tcp
langfuse-minio-1 0.0.0.0:9090->9000/tcp, [::]:9090->9000/tcp, 127.0.0.1:9091->9001/tcp

The exposure audit — nine networks listed, four services bound to every interface Figure 1. The start of any audit. Nine networks: three Docker built-ins (bridge, host, none), five compose projects, and one leftover. Four services published on 0.0.0.0 — every interface, including the public one.

Four services listening on every interface. Note what's not in that list: the Postgres, ClickHouse and Redis behind Langfuse. Those were published to 127.0.0.1 instead — someone made a good decision there. Hold that thought.

Test 1 — Do separate Docker networks actually isolate? (yes, and most blogs are wrong)

The classic worry: OpenClaw gets compromised, and the attacker walks into Langfuse's database next door. Let's find out instead of guessing. Grab the container IPs:

for c in langfuse-postgres-1 evolution-api-evolution-postgres-1; do
docker inspect -f '{{.Name}} {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $c
done
Output
/langfuse-postgres-1 172.19.0.5
/evolution-api-evolution-postgres-1 172.23.0.4

Now try to reach both from inside the OpenClaw container, by IP — no DNS, no shortcuts:

docker exec openclaw-openclaw-gateway-1 node -e '
const net=require("net");
[["langfuse-postgres","172.19.0.5",5432],["evolution-postgres","172.23.0.4",5432]]
.forEach(([n,ip,p])=>{
const s=net.connect({host:ip,port:p,timeout:4000});
s.on("connect",()=>{console.log(`REACHED ${n}`);s.destroy()});
s.on("timeout",()=>{console.log(`blocked ${n}`);s.destroy()});
s.on("error",e=>console.log(`error ${n} ${e.code}`));
});'
Output
blocked langfuse-postgres
blocked evolution-postgres

Cross-network probes timing out from inside the OpenClaw container Figure 2. Connecting by raw IP from one stack's container to another stack's database: both time out. Docker's isolation rules are doing their job.

Blocked. Modern Docker installs DOCKER-ISOLATION-STAGE rules that drop traffic between user-defined bridge networks, and they work. If you've read that "Docker networks don't really isolate," test it on your own box before believing it — on current Docker they do.

Skill unlocked 🏅

You can verify network isolation empirically instead of trusting folklore: get the target's IP with docker inspect, then connect by IP from inside another container.

So cross-stack isolation is fine. Which means the real risk is somewhere else.

Test 2 — Inside a network, everything is wide open

Each compose project puts all its services on one network. That's the default, and it means the application container can reach every backing service — which is intended. The question is what that access actually looks like. From Evolution API's bridge container:

docker exec evolution-api-bridge-1 python3 -c '
import socket
for name, host, port in [("postgres","evolution-postgres",5432),
("redis","evolution-redis",6379),
("api","evolution-api",8080)]:
s=socket.socket(); s.settimeout(4)
try:
s.connect((host,port)); print(f"REACHED {name}:{port}")
except Exception as e:
print(f"blocked {name}:{port} -- {type(e).__name__}")
finally: s.close()'
Output
REACHED postgres:5432
REACHED redis:6379
REACHED api:8080

Reachable is expected. Unauthenticated is not. Check whether those services actually ask for credentials — first Redis:

docker exec evolution-api-bridge-1 python3 -c '
import socket
s=socket.socket(); s.settimeout(5); s.connect(("evolution-redis",6379))
s.sendall(b"INFO server\r\n")
print(s.recv(200).decode(errors="replace")[:120])'
Output
$625
# Server
redis_version:7.4.9
redis_git_sha1:00000000
redis_git_dirty:0
redis_build_id:b61b4eb609520881
redis_

Redis answering INFO with no AUTH Figure 3. The finding: INFO server answered with no credentials (output truncated at 120 chars by the probe). Any container on this network can read or wipe the session store.

That's a INFO command answered with no AUTH — the Redis holding this deployment's session state has no password. Anything that gets code execution in any container on that network can read it, write to it, or FLUSHALL it.

Postgres, in the same stack, behaves correctly:

docker exec evolution-api-bridge-1 python3 -c '
import socket,struct
s=socket.socket(); s.settimeout(5); s.connect(("evolution-postgres",5432))
msg=b"user\x00postgres\x00database\x00postgres\x00\x00"
s.sendall(struct.pack("!ii",len(msg)+8,196608)+msg)
print(s.recv(64))'
Output
b'R\x00\x00\x00\x17\x00\x00\x00\nSCRAM-SHA-256\x00\x00'

It demands SCRAM-SHA-256. Same box, same network, two backing services — one asks for a password, the other doesn't. Nobody decided that; it's just what the images default to when you don't set a password.

The lesson: "internal network" is not a security boundary. It's a convenience. Treat every service as if the attacker is already on that network, because if one container falls, they are.

Test 3 — The firewall that isn't

Now the big one. Turn on UFW and deny everything inbound except SSH:

sudo ufw allow 22/tcp # ALWAYS first, or you lock yourself out
sudo ufw default deny incoming
sudo ufw --force enable
sudo ufw status verbose
Output
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip

To Action From
-- ------ ----
60000:61000/udp ALLOW IN Anywhere
22/tcp ALLOW IN Anywhere
60000:61000/udp (v6) ALLOW IN Anywhere (v6)
22/tcp (v6) ALLOW IN Anywhere (v6)

Deny incoming — only SSH and mosh's UDP range allowed. Now, from a different machine, open the Langfuse UI and the Evolution API:

From a laptop, not the server
http://<instance-ip>:3001 -> Langfuse UI loads normally
http://<instance-ip>:8080 -> {"status":200,"message":"Welcome to the Evolution API..."}

The Evolution API answering in a browser while UFW denies all incoming traffic

Figure 4. The firewall says it denies everything inbound. The Evolution API on :8080 answers anyway — status 200, straight from a browser on another machine.

Both wide open, through a firewall configured to block them. This is the single most common security surprise in self-hosted Docker, and it isn't a bug.

Why: Docker gets to the packet first

Look at the order of the FORWARD chain:

sudo iptables -L FORWARD -n --line-numbers | head -8
Output
Chain FORWARD (policy DROP)
num target prot opt source destination
1 DOCKER-USER all -- 0.0.0.0/0 0.0.0.0/0
2 DOCKER-FORWARD all -- 0.0.0.0/0 0.0.0.0/0
3 ACCEPT all -- 0.0.0.0/0 0.0.0.0/0 ctstate RELATED,ESTABLISHED
4 ACCEPT all -- 0.0.0.0/0 0.0.0.0/0
5 ufw-before-logging-forward all -- 0.0.0.0/0 0.0.0.0/0
6 ufw-before-forward all -- 0.0.0.0/0 0.0.0.0/0

UFW active with deny incoming, and Docker&#39;s chains ahead of UFW&#39;s in FORWARD Figure 5. The whole explanation in one screen: the firewall says deny incoming, and Docker's chains sit at positions 1-2 while UFW's start at 5.

Docker's chains sit at positions 1 and 2. UFW's don't appear until 5 and 6. And publishing a port isn't a listener on the host — it's a NAT rule:

sudo iptables -t nat -L DOCKER -n | grep -E '3001|8080'
Output
DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.23.0.3:8080
DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:3001 to:172.19.0.2:3000

The packet arrives, Docker's NAT rewrites the destination to the container, and it becomes forwarded traffic that Docker's own chain accepts — long before any UFW rule is consulted. Your UFW rules aren't ignored; they're simply never reached.

This is the one to remember

ufw deny does not protect a published Docker port. If you've been relying on UFW in front of -p 8080:8080, that port has been open the whole time.

Four fixes, in order of preference

Fix 1 — Don't publish it at all

The cleanest fix isn't a firewall rule; it's not opening the door. If a service is only consumed by other containers, it needs no ports: entry — service-to-service traffic works over the compose network by name.

If you need it reachable from the host (a debugging port, a local psql), bind it to loopback explicitly:

services:
postgres:
# 127.0.0.1 prefix = host-only, never the public interface
ports:
- "127.0.0.1:5433:5432"

That's exactly what the Langfuse stack on this box already does — its Postgres, ClickHouse and Redis are all on 127.0.0.1, which is why they never showed up in our exposure audit. The default "5433:5432" means 0.0.0.0 — every interface, including the public one.

Fix 2 — Split networks by trust zone, and mark backends internal

One network per compose project is the default, not a design. Give the backing services their own network, and put only the application in both:

docker-compose.yml
services:
app: # talks to the world AND to the database
image: your-agent
networks: [frontend, backend]

db:
image: redis:7
command: redis-server --requirepass ${REDIS_PASSWORD}
networks: [backend] # backend only — no route out

public: # e.g. a webhook receiver
image: your-public-thing
networks: [frontend] # cannot see the database at all

networks:
frontend:
backend:
internal: true # no gateway: no inbound, no outbound

Verified on the same box:

# the frontend-only container tries to reach the database
docker exec netsecdemo-public-1 python3 -c '
import socket
s=socket.socket(); s.settimeout(4)
try: s.connect(("db",6379)); print("REACHED db <- leak")
except Exception as e: print(f"blocked: {type(e).__name__}")'

# the app, which is on both networks
docker exec netsecdemo-app-1 python3 -c '
import socket
s=socket.socket(); s.settimeout(4)
try: s.connect(("db",6379)); print("REACHED db <- correct, it needs it")
except Exception as e: print(f"blocked: {type(e).__name__}")'

# and can the database itself reach the internet?
docker exec netsecdemo-db-1 timeout 6 getent hosts pypi.org >/dev/null \
&& echo "resolved external DNS" || echo "no internet egress <- internal working"
Output
blocked: gaierror
REACHED db <- correct, it needs it
no internet egress <- internal working

The internal-network demo: public blocked, app allowed, database with no egress

Figure 6. Three properties proven in one run — the public-facing container can't even resolve the database, the app can reach it, and the database has no route to the internet.

Three properties at once: the public-facing container can't even resolve the database, the app can, and the database can't phone home — which matters the day a dependency of yours turns malicious.

Fix 3 — Authenticate everything, including "internal" services

Our audit found Redis answering INFO with no credentials. One line fixes it:

db:
image: redis:7
command: redis-server --requirepass ${REDIS_PASSWORD}
Output (after)
-NOAUTH Authentication required.

That's the real output from this box, after applying the fix — the finding from Test 2 is closed. If you set a password on a Redis that something already uses, remember to update the client's connection string too (redis://:<password>@host:6379/0), or you'll trade an open database for a broken one.

Do this even for services that aren't published. Defense in depth means the second layer holds when the first one fails.

Fix 4 — When you must publish, filter in DOCKER-USER

Sometimes a port genuinely has to be public-facing but restricted by source. The chain to use is DOCKER-USER — Docker guarantees it runs before its own accept rules, and never overwrites it:

# only 203.0.113.0/24 may open new connections to the container's port 3000
sudo iptables -I DOCKER-USER 1 -p tcp --dport 3000 \
-m conntrack --ctstate NEW ! -s 203.0.113.0/24 -j DROP

Tested live on the Langfuse port, from a browser on another machine:

Before the rule
http://<instance-ip>:3001 -> Langfuse UI loads
After the rule
ERR_CONNECTION_TIMED_OUT

The same URL timing out after the DOCKER-USER rule

Figure 7. Same URL, same firewall, one rule in the right chain — and the port is finally closed. Note Chrome's own suggestion: "Checking the proxy and the firewall." This time the firewall really is the answer.

Meanwhile the Evolution API on :8080, which we deliberately left alone, kept answering. The rule is surgical, and it works exactly where UFW couldn't.

Make it survive a reboot

iptables -I is not persistent. Use iptables-persistent (netfilter-persistent save), or put the rule in a small systemd unit that runs after docker.service. A firewall rule that disappears on reboot is worse than none, because you'll think you're protected.

Skill unlocked 🏅

You can audit and lock down a multi-stack Docker host: prove what's reachable, bind or split what shouldn't be, authenticate the backends, and filter published ports in the one chain Docker respects.

The audit, as a checklist

Run these four commands on any box you own. They take a minute and tell you more than any architecture diagram:

# 1. What's exposed to the world?
docker ps --format '{{.Names}}\t{{.Ports}}' | grep '0.0.0.0'

# 2. Are your firewall rules even in the path? (Docker chains before ufw = they aren't)
sudo iptables -L FORWARD -n --line-numbers | head -8

# 3. What can a compromised container reach on its own network?
docker exec <container> python3 -c "import socket;s=socket.socket();s.settimeout(3);s.connect(('<svc>',<port>));print('reached')"

# 4. Does that backing service ask for a password?
docker exec <container> python3 -c "
import socket;s=socket.socket();s.settimeout(4);s.connect(('redis',6379))
s.sendall(b'INFO server\r\n');print(s.recv(60))"

What we found, and what it cost to fix

FindingSeverityFix
Cross-network isolation works✅ nonenothing — Docker already does this
Every service on one network, mutually reachable⚠️ mediumsplit by trust zone, internal: true
Redis answering with no AUTH🔴 highone line: --requirepass
ufw deny not applied to published ports🔴 highbind to 127.0.0.1, or DOCKER-USER rule
Backing DBs on 127.0.0.1 (Langfuse)✅ nonealready correct — copy this pattern

None of the fixes took more than a line of YAML. The expensive part was knowing which line — and the only way to know is to probe your own box instead of trusting the diagram in your head.

Troubleshooting

I added a DOCKER-USER rule and nothing changed

Two usual causes. First, --dport must be the container's port, not the published one — the DNAT already rewrote the destination by the time DOCKER-USER sees the packet (in our case 3000, not 3001). Second, existing connections aren't affected: the --ctstate NEW match only touches new ones, so an already-open browser tab keeps working.

I enabled UFW and lost my SSH session

sudo ufw allow 22/tcp before ufw enable, always. If you're already locked out, most providers give you a serial/VNC console — use it to run ufw disable.

internal: true broke my container's package install

That's it working. An internal network has no gateway, so no egress at all — no pip install, no DNS, no calling an external API. Backing services shouldn't need any of that; if a container does, it belongs on the frontend network too.

Services can't find each other after I split the networks

Compose DNS only resolves names within a shared network. If app and db are on different networks with nothing in common, db won't resolve. The app has to be a member of both, as in Fix 2.

Finished this tutorial?
Mark it complete to earn Lock down Docker networks on your skill path.

What's next

Network hardening is the layer that keeps a compromised container from becoming a compromised host. The next layers, in the order I'd do them: run containers as non-root (user: in compose) so a breakout starts with fewer privileges, and put an identity provider in front of the ports you must expose — a private mesh with NetBird plus authentication is the natural pairing with everything above.

Further reading

Comments & questions

Hit an error, spotted a typo, or have a question? Leave a note below.