In lesson 03-05 you used Docker networks as a black box: you created a network, connected containers and the names resolved. It worked, but you did not know why. This lesson opens that box: Linux bridges, veth pairs, iptables rules, a DNS server hidden at 127.0.0.11, and drivers that give a container its own IP on your physical LAN.

Everything that follows is demonstrated against the running Aurora Libros platform. The host commands assume Linux; if you use Docker Desktop, they run inside the virtual machine (lesson 05-07).

Contents

  1. Anatomy of a bridge network: Linux bridges
  2. veth pairs and how to match the two ends
  3. A packet's journey, step by step
  4. iptables: the chains Docker creates
  5. Outbound NAT and publishing DNAT
  6. Bypassing the host firewall (security warning)
  7. DOCKER-USER: taking back control
  8. The embedded DNS at 127.0.0.11
  9. Addressing: subnets, ranges, fixed IPs and MTU
  10. The host and none drivers in depth
  11. macvlan: an IP of your own on the physical LAN
  12. ipvlan in L2 and L3 mode
  13. Comparison table of the six drivers
  14. IPv6 in Docker
  15. Real diagnostics: tcpdump and nsenter

  1. Anatomy of a bridge network: Linux bridges

A Docker bridge network is a software bridge in the Linux kernel: a virtual Ethernet switch. The default network uses the docker0 bridge; every user-defined network creates one called br-<first 12 characters of the network ID>.

docker network ls --filter driver=bridge --format "{{.ID}}\t{{.Name}}"
ip -brief link show type bridge
9f3a1c7d5e2b   aurora-libros_frontend
c81be4f0a7d9   aurora-libros_backend
docker0          UP  02:42:8e:1c:44:a1 <BROADCAST,MULTICAST,UP,LOWER_UP>
br-9f3a1c7d5e2b  UP  02:42:3b:9c:10:7e <BROADCAST,MULTICAST,UP,LOWER_UP>
br-c81be4f0a7d9  UP  02:42:af:22:d5:03 <BROADCAST,MULTICAST,UP,LOWER_UP>

The mapping is direct and easy to verify: the twelve characters after br- are the beginning of the network ID. The bridge has its own IP address on the host, and that address is the gateway for the containers on that network:

ip -brief addr show br-9f3a1c7d5e2b
docker network inspect aurora-libros_frontend --format '{{(index .IPAM.Config 0).Subnet}} gw={{(index .IPAM.Config 0).Gateway}}'
br-9f3a1c7d5e2b UP 172.21.0.1/16
172.21.0.0/16 gw=172.21.0.1

And bridge link show master br-9f3a1c7d5e2b lists the interfaces plugged into that bridge: one for every container attached to the network.

  1. veth pairs and how to match the two ends

A veth (virtual Ethernet) is a pair of interfaces joined by a virtual cable: whatever goes in one end comes out the other. Docker creates one per container-network connection: one end stays on the host and is plugged into the bridge; the other is moved inside the container and renamed eth0. The pairing is not obvious, because the names are random; the key is the interface index, since each end points at the other's index through iflink.

docker compose exec aurora-api cat /sys/class/net/eth0/iflink   # -> 7
ip link show | grep '^7:'                                       # which interface is number 7?
7: veth3a1f9c2@if6: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master br-9f3a1c7d5e2b

There is your pair: eth0 in aurora-api ↔ veth3a1f9c2 on the host. The @if6 notation in the name itself confirms the symmetry: the host end points at index 6, which is the container's eth0 inside its own network namespace.

Once you have identified the pair, you can measure a specific container's traffic from the host without entering it:

ethtool -S veth3a1f9c2 | head -5
NIC statistics:
     peer_ifindex: 6
     rx_packets: 18422
     tx_packets: 21095

Look at peer_ifindex: it is the most direct way to confirm the pairing. And watch out for the reversed direction: what the container sends shows up as rx on the host's veth, because the packet enters the host through that end.

  1. A packet's journey, step by step

flowchart LR
  subgraph NSAPI["aurora-api network namespace"]
    P["node process<br/>172.21.0.5"] --> E0["eth0 (if6)"]
  end
  E0 -->|"veth cable"| V["veth3a1f9c2 (if7)"]
  subgraph HOST["Host"]
    V --> BR["br-9f3a1c7d5e2b<br/>172.21.0.1"]
    BR --> IPT["iptables: nat + filter"]
    IPT --> ETH["host eth0"]
  end
  BR -->|"same bridge:<br/>direct switching"| V2["veth5d80a3f"]
  V2 --> DB["aurora-db 172.22.0.3"]
  ETH --> NET(("Internet"))

Two very different paths coexist here:

Destination Route Cost
Another container on the same network eth0 → veth → bridge → veth → eth0 Kernel switching, no NAT
Another network or the Internet eth0 → veth → bridge → routed → iptables (MASQUERADE) → host eth0 NAT and connection tracking
From outside towards a published port host eth0 → iptables (DNAT) → bridge → veth → eth0 Destination NAT

The fact that aurora-api ↔ aurora-db traffic is pure switching explains why its performance is practically that of the host network: there is no address translation on that path.

  1. iptables: the chains Docker creates

On startup, dockerd installs a set of chains of its own. Knowing them by name saves you hours of debugging:

Chain Table Purpose
DOCKER nat and filter DNAT rules for published ports and their forwarding permission
DOCKER-USER filter Empty and reserved for you; evaluated before DOCKER
DOCKER-ISOLATION-STAGE-1 filter Detects traffic leaving one bridge for another
DOCKER-ISOLATION-STAGE-2 filter Drops it: this is what isolates networks from each other
DOCKER-INGRESS nat and filter Only with Swarm (lesson 06-03)
sudo iptables -t filter -L DOCKER-ISOLATION-STAGE-2 -n --line-numbers
num  target     prot opt source               destination
1    DROP       all  --  0.0.0.0/0            0.0.0.0/0
2    RETURN     all  --  0.0.0.0/0            0.0.0.0/0

There it is, in two lines: the isolation you verified back in lesson 04-04 when aurora-web could not reach aurora-db. A packet that leaves one bridge and tries to enter another lands on that DROP.

  1. Outbound NAT and publishing DNAT

sudo iptables -t nat -L -n
Chain PREROUTING (policy ACCEPT)
DOCKER      all  --  0.0.0.0/0        0.0.0.0/0     ADDRTYPE match dst-type LOCAL
Chain POSTROUTING (policy ACCEPT)
MASQUERADE  all  --  172.21.0.0/16    0.0.0.0/0
MASQUERADE  all  --  172.22.0.0/16    0.0.0.0/0
MASQUERADE  tcp  --  172.21.0.2       172.21.0.2    tcp dpt:80
Chain DOCKER (2 references)
DNAT        tcp  --  0.0.0.0/0        0.0.0.0/0     tcp dpt:8080 to:172.21.0.2:80

Three rules, three mechanisms:

  • Subnet MASQUERADE: anything leaving 172.21.0.0/16 for the outside world is rewritten with the host's IP. That is why a container can download from the Internet without having a public IP.
  • Publishing DNAT: the -p 8080:80 on aurora-web becomes this rule. Any packet arriving at port 8080 on any local address is redirected to 172.21.0.2:80.
  • MASQUERADE with identical source and destination: so-called hairpin NAT, which lets a container reach itself through the host's published port.

Check the correspondence with the actual publication:

sudo iptables -t nat -S DOCKER | grep 8080
-A DOCKER ! -i br-9f3a1c7d5e2b -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.21.0.2:80

If you publish while restricting the interface —"127.0.0.1:8080:80"— the rule changes and adds -d 127.0.0.1. That one-word difference is what separates "reachable from my laptop" from "reachable from the entire office network".

  1. Bypassing the host firewall (security warning)

This is the most important point in the lesson, and the one that causes the nastiest surprises in production.

When you publish a port, Docker inserts its DNAT into PREROUTING, which is evaluated before the rules in the INPUT chain where ufw or firewalld put theirs. The redirected traffic goes through FORWARD, not through INPUT. The result: your firewall never sees that traffic and never blocks it.

sudo ufw status | head -4
docker run -d --name exposed -p 5000:80 nginx:alpine
curl -s -o /dev/null -w '%{http_code}\n' http://<host-IP>:5000/
Status: active
22/tcp                     ALLOW       Anywhere
200

The firewall says only port 22 is open. Port 5000 answers anyway from any machine on the network. Entire PostgreSQL and Redis servers have ended up on the Internet in exactly this way.

The three defenses, from simplest to most complete:

Defense How Scope
Publish on localhost only "127.0.0.1:5432:5432" Prevents remote access at the root. Always the first option
Do not publish at all Use internal networks and docker compose exec What aurora-db has been doing since lesson 04-04
Rules in DOCKER-USER Filter forwarded traffic Fine-grained control by source

Security warning. Port exposure and firewall rules on a server must be reviewed with the security or compliance officer in your organization. What is shown here explains the mechanism; the actual policy —what gets exposed, to whom, and with which compensating controls— is not decided single-handedly by whoever writes the compose.yaml.

  1. DOCKER-USER: taking back control

Docker never touches DOCKER-USER and evaluates it before its own rules. It is the place designed for your policies:

# Allow the corporate network only; drop everything else
sudo iptables -I DOCKER-USER -i eth0 -j DROP
sudo iptables -I DOCKER-USER -i eth0 -s 10.0.0.0/8 -j RETURN
sudo iptables -L DOCKER-USER -n --line-numbers
num  target     prot opt source               destination
1    RETURN     all  --  10.0.0.0/8           0.0.0.0/0
2    DROP       all  --  0.0.0.0/0            0.0.0.0/0

Two essential warnings. First: order matters, -I inserts at the top, so the permissive rule must be inserted last in order to end up first. Second: these rules do not survive a reboot; you have to persist them with iptables-persistent, nftables or your distribution's tool. And if you are thinking of disabling Docker's iptables handling altogether ("iptables": false in /etc/docker/daemon.json), accept that you lose outbound Internet access for containers and port publishing until you write every rule yourself: it is rarely worth it.

  1. The embedded DNS at 127.0.0.11

On a user-defined network, service names resolve because Docker runs a per-container DNS server listening on 127.0.0.11:53.

docker compose exec aurora-api cat /etc/resolv.conf
docker compose exec aurora-api getent hosts aurora-db
nameserver 127.0.0.11
options ndots:0
172.22.0.3   aurora-db

That loopback address does not exist on any visible interface. The trick: inside the container's namespace there is a DNAT rule (-A DOCKER_OUTPUT -d 127.0.0.11/32 -p tcp --dport 53 -j DNAT --to-destination 127.0.0.11:38765) that redirects the traffic to a high port where a daemon process is listening.

The resolution algorithm, in order:

  1. Is it a service name or alias on a network we share? → the daemon answers with the container's IP.
  2. Is it a container on the same network, by name or container_name? → same thing.
  3. If not, it forwards to the host's DNS servers (or to those in the service's dns: setting).

Points worth being clear about:

  • Resolution is scoped to shared networks. aurora-web does not resolve aurora-db because they share no network: that is not a DNS failure, it is the design.
  • With --scale, one name returns several addresses in rotating order: rudimentary client-side load balancing. Check it with docker compose up -d --scale aurora-api=3 followed by getent hosts aurora-api.
  • options ndots:0 prevents search suffixes from being appended and saves failed lookups.
  • Many libraries cache the resolution. If a container is recreated and changes IP, an application that resolved once may end up talking to a dead IP. That is a real reason to restart clients after recreating a service.

  1. Addressing: subnets, ranges, fixed IPs and MTU

By default Docker picks subnets from 172.17.0.0/16 onwards, which clashes with many corporate VPNs. You can pin them down:

docker network create --driver bridge \
  --subnet 10.80.0.0/24 --gateway 10.80.0.1 --ip-range 10.80.0.128/25 \
  --aux-address "reserved-nas=10.80.0.20" \
  --opt com.docker.network.driver.mtu=1450 \
  --opt com.docker.network.bridge.name=br-aurora \
  aurora-fixed
Option What it does
--subnet The network's full range
--gateway The bridge's IP on the host
--ip-range The subset Docker assigns automatically; the rest is left free for fixed IPs
--aux-address Addresses Docker must not assign (real machines inside the range)
mtu Maximum frame size; lowering it avoids fragmentation behind VPNs or tunnels
bridge.name A readable bridge name instead of br-<id>

Assigning a fixed IP requires the network to have its own subnet: docker run -d --network aurora-fixed --ip 10.80.0.10 nginx:alpine. In Compose you declare it with ipv4_address inside networks. Even so, the good practice is still to rely on DNS names rather than IPs: fixed IPs are reserved for cases where something external (a firewall rule, a license, a legacy appliance) demands a stable address.

The default MTU is set in /etc/docker/daemon.json with "mtu": 1450. The typical symptom of a badly tuned MTU is cruel: connections are established and small requests work, but large responses hang.

  1. The host and none drivers in depth

host removes the network namespace: the container uses the host's stack directly.

docker run -d --name api-host --network host auroralibros/aurora-api:1.2.0
docker exec api-host ip -brief addr | head -2 && ss -tlnp | grep 3000
lo     UNKNOWN  127.0.0.1/8
eth0   UP       192.168.1.42/24
LISTEN 0  511  *:3000  users:(("node",pid=48122,fd=21))

The process listens directly on the host's port 3000. What you gain and what you lose:

You gain You lose
No NAT: lower latency and more throughput on workloads with many small packets Network isolation: none at all
Access to real interfaces (useful for monitoring, VPN, DHCP) Service-name resolution: it does not exist
No practical limit on published ports Port collisions between containers
Multicast and protocols that NAT breaks -p is ignored with a warning

Legitimate cases: monitoring agents (node-exporter, lesson 05-06), very high performance load balancers and diagnostic tools. For aurora-api it would be a mistake: it would lose isolation without gaining anything noticeable.

none leaves the container with nothing but lo: docker run --rm --network none alpine:3 ping -c1 -W1 8.8.8.8 answers Network is unreachable. It is the right choice for pure computation processes or for handling files of dubious origin: no network, no exfiltration.

  1. macvlan: an IP of your own on the physical LAN

macvlan gives the container its own MAC address on the physical network. To the rest of the office, the container is just another machine: it gets an IP from the real range and needs neither NAT nor port publishing.

# 1) Find out the real physical interface and subnet
ip -brief addr show eth0

# 2) Create the macvlan network on top of that interface
docker network create -d macvlan \
  --subnet 192.168.1.0/24 \
  --gateway 192.168.1.1 \
  --ip-range 192.168.1.240/28 \
  -o parent=eth0 \
  office-lan

# 3) A container with a LAN IP
docker run -d --name web-lan --network office-lan --ip 192.168.1.241 nginx:alpine
docker exec web-lan ip -brief addr show eth0
eth0   UP   192.168.1.241/24

From another machine in the office, http://192.168.1.241/ answers without a single port having been published.

The three traps, in order of frequency:

  1. The host cannot talk to its own macvlan containers. This is not a bug: the kernel prevents traffic between the parent interface and its macvlan child interfaces. The fix is to create an extra macvlan interface on the host and route towards it:
sudo ip link add macvlan-host link eth0 type macvlan mode bridge
sudo ip addr add 192.168.1.250/32 dev macvlan-host && sudo ip link set macvlan-host up
sudo ip route add 192.168.1.240/28 dev macvlan-host
  1. Promiscuous mode. The switch or the hypervisor must accept several MACs per port. On most cloud services (and on many Wi-Fi networks) it is forbidden, and macvlan simply does not work.
  2. Collision with DHCP. Docker does not speak DHCP: reserve an --ip-range outside the DHCP server's scope or you will end up with two machines sharing an IP.

  1. ipvlan in L2 and L3 mode

ipvlan solves the promiscuous mode problem: all containers share the parent interface's MAC and are told apart by IP.

# L2: same broadcast domain as the host, no MAC of its own
docker network create -d ipvlan --subnet 192.168.1.0/24 --gateway 192.168.1.1 \
  -o parent=eth0 -o ipvlan_mode=l2 lan-l2
# L3: the host routes; no broadcast, independent subnet
docker network create -d ipvlan --subnet 10.90.1.0/24 \
  -o parent=eth0 -o ipvlan_mode=l3 lan-l3
Aspect macvlan ipvlan L2 ipvlan L3
MAC One per container Shared with the parent Shared
Promiscuous mode Required No No
Broadcast and DHCP Yes Yes No
Routing Via the LAN gateway Via the LAN gateway Done by the host
External routes Automatic Automatic Must be added on the router

L3 mode is the cleanest option in data centers with controlled routing, but it requires somebody to teach the router how to reach 10.90.1.0/24. If you skip that, the containers get out and nothing comes back.

  1. Comparison table of the six drivers

Driver Isolation Performance Internal DNS Port publishing Typical use case
bridge (user-defined) High Good (NAT on the way out) Yes -p with DNAT 95% of cases, Aurora Libros included
default bridge Medium Same No (only --link, deprecated) -p Compatibility; avoid it
host None The best No Not applicable Monitoring agents, high performance
none Total Not applicable No No Processes with no network
macvlan Medium (flat LAN) Very good, no NAT Yes, on the Docker network Unnecessary Integrating with LAN machines or legacy systems
ipvlan Medium Very good Yes Unnecessary Like macvlan where promiscuous mode is forbidden

The overlay driver, for connecting containers across several hosts, is covered in lesson 06-03 along with Swarm's routing mesh.

  1. IPv6 in Docker

Since version 27, IPv6 is enabled per network without touching the daemon:

docker network create --ipv6 --subnet 2001:db8:aur::/64 aurora-v6
docker run --rm --network aurora-v6 alpine:3 ip -6 -brief addr show eth0
# eth0  UP  2001:db8:aur::2/64  fe80::42:acff:fe14:2/64

In Compose, enable_ipv6: true on the network is enough. Three warnings: use ULA addresses (fd00::/8) if you do not have a delegated public prefix; enable "ip6tables": true in /etc/docker/daemon.json so that NAT and publishing work; and check that your firewall rules exist for IPv6 too, because an IPv4-only policy leaves an open door that nobody is watching.

  1. Real diagnostics: tcpdump and nsenter

The nicolaka/netshoot image with --network container: (lesson 03-04) puts you inside the container's network namespace with a full toolbox:

docker run --rm -it --network container:aurora-libros-aurora-api-1 nicolaka/netshoot \
  sh -c 'ip -brief addr; ip route; ss -tlnp'
eth0   UP   172.21.0.5/16
eth1   UP   172.22.0.4/16
default via 172.21.0.1 dev eth0
LISTEN 0 511 *:3000

Two interfaces, because aurora-api sits on both frontend and backend; the default route leaves via frontend, since backend is internal and has no gateway.

Capturing the real traffic between the API and the database:

docker run --rm --net container:aurora-libros-aurora-api-1 nicolaka/netshoot \
  timeout 10 tcpdump -n -i any 'host 172.22.0.3 and port 5432' -c 6
IP 172.22.0.4.51442 > 172.22.0.3.5432: Flags [S], seq 2451
IP 172.22.0.3.5432 > 172.22.0.4.51442: Flags [S.], seq 8891, ack 2452
IP 172.22.0.4.51442 > 172.22.0.3.5432: Flags [P.], length 96
IP 172.22.0.3.5432 > 172.22.0.4.51442: Flags [P.], length 1284

The addresses belong to the backend network and there is no translation whatsoever: direct communication through the bridge, exactly as section 3 predicted. From the host, without entering any container, the equivalent capture is sudo tcpdump -n -i veth3a1f9c2 -c 5 'tcp port 5432'.

nsenter is the lowest-level route: it enters a process's network namespace using the host's tools.

pid=$(docker inspect --format '{{.State.Pid}}' aurora-libros-aurora-api-1)
sudo nsenter -t "$pid" -n ip -brief addr
sudo nsenter -t "$pid" -n ss -tnp state established
eth0   UP   172.21.0.5/16
eth1   UP   172.22.0.4/16
ESTAB  0  0  172.22.0.4:51442  172.22.0.3:5432

This is the technique that saves the day when a container is distroless and does not even have sh (lesson 05-03): you need nothing inside, because you use the host's binaries against its namespace. Exactly why this works is explained in lesson 05-07.

Common Mistakes and Tips

Believing that the host firewall protects published ports. It does not: Docker's DNAT is evaluated first. Publish on 127.0.0.1 or filter in DOCKER-USER.

Adding rules to INPUT to block a container. The traffic goes through FORWARD. The right chain is DOCKER-USER.

Writing rules inside the DOCKER chain. Docker rewrites it on startup or whenever a port is published, and your rules vanish.

Relying on container IPs. They change when containers are recreated. Use service names and reserve fixed IPs for genuine external requirements.

Using --network host "to make it faster". You lose isolation and name resolution, and on an HTTP API the difference is usually negligible. Measure first.

Setting up macvlan without checking promiscuous mode, or forgetting to lower the MTU behind a VPN: in the first case everything looks properly configured and nothing answers; in the second, connections open but hang when transferring large amounts of data.

Tip: faced with a networking problem, always follow the same order: does the name resolve? (getent hosts), is there a route? (ip route), is anybody listening? (ss -tlnp inside the destination), does the packet arrive? (tcpdump). Each step rules out an entire layer.

Exercises

Exercise 1. With the platform running, identify the veth pair for aurora-db, prove that its host end is plugged into the backend network's bridge, and measure the traffic that has passed through it. Explain why rx on the host corresponds to what the container sends.

Exercise 2. Demonstrate the firewall bypass: publish a container on a port, check that ufw does not declare it open and that it answers from another machine anyway; then block it properly with DOCKER-USER and verify that it still works from localhost.

Exercise 3. Use tcpdump to capture a real query between aurora-api and aurora-db triggered by curl /books, and use docker exec against Redis to prove why the second request generates no traffic towards the database.

Solutions

Solution 1.

idx=$(docker compose exec -T aurora-db cat /sys/class/net/eth0/iflink | tr -d '\r')
veth=$(ip -o link | awk -v i="$idx:" '$1==i {sub(/@.*/,"",$2); print $2}')
net=$(docker network inspect aurora-libros_backend --format '{{.Id}}' | cut -c1-12)
echo "veth=$veth  expected bridge=br-$net"
ip -o link show "$veth" | grep -o 'master [^ ]*'
ethtool -S "$veth" | grep -E 'peer_ifindex|rx_packets|tx_packets'
veth=veth5d80a3f  expected bridge=br-c81be4f0a7d9
master br-c81be4f0a7d9
     peer_ifindex: 10
     rx_packets: 3921
     tx_packets: 4877

The master matches exactly the bridge derived from the backend network's ID: that veth is aurora-db's cable to that network and to no other. And peer_ifindex: 10 points at the index of the eth0 inside the container.

About the reversed direction: the statistics are read from the host's point of view. When aurora-db answers a query, the packet leaves through its eth0, travels along the virtual cable and enters the host through veth5d80a3f; for the host that is reception, hence rx. Confusing this leads to inverted diagnoses: if you see rx spiking on a database's veth, it does not mean a lot is being sent to it, it means it is returning a lot, and there is probably a missing LIMIT in some query.

Solution 2.

docker run -d --name exposed -p 5000:80 nginx:alpine
sudo ufw status | grep -c 5000            # 0: ufw knows nothing about that port
curl -s -o /dev/null -w 'host: %{http_code}\n' http://127.0.0.1:5000/
# from another machine: curl -s -o /dev/null -w 'remote: %{http_code}\n' http://192.168.1.42:5000/
sudo iptables -I DOCKER-USER -i eth0 -p tcp --dport 5000 -j DROP
curl -s -o /dev/null -w 'host after the rule: %{http_code}\n' http://127.0.0.1:5000/
# from another machine: curl -m 3 http://192.168.1.42:5000/
sudo iptables -D DOCKER-USER -i eth0 -p tcp --dport 5000 -j DROP && docker rm -f exposed
0
host: 200
remote: 200
host after the rule: 200
curl: (28) Connection timed out after 3001 milliseconds

The rule filters by incoming interface (-i eth0), so traffic arriving through the physical interface is dropped while traffic originating on the host itself —which therefore does not come in through eth0— still gets through. That nuance is what makes the rule usable: it blocks the outside world without breaking local debugging.

The underlying lesson: ufw reported a system with a single open port and it was false. Publishing a port on a server with a public IP is equivalent to opening it to the Internet, unless you bind the publication to 127.0.0.1 or filter explicitly. That is exactly why aurora-db has published nothing since lesson 04-04.

Solution 3.

docker compose exec aurora-cache redis-cli FLUSHALL
docker run --rm --net container:aurora-libros-aurora-api-1 nicolaka/netshoot \
  timeout 12 tcpdump -n -i any 'host 172.22.0.3 and port 5432' &
sleep 1
curl -s http://localhost:8080/api/books | head -c 58; echo    # first: cache is empty
curl -s http://localhost:8080/api/books | head -c 58; echo    # second: warm cache
docker compose exec aurora-cache redis-cli KEYS 'books:*'
wait
{"source":"db","total":9,"books":[{"id":1,"title":"El
{"source":"cache","total":9,"books":[{"id":1,"title":
1) "books:all"
IP 172.22.0.4.51488 > 172.22.0.3.5432: Flags [P.], length 112
IP 172.22.0.3.5432 > 172.22.0.4.51488: Flags [P.], length 1284
2 packets captured

The first request returns source: db and the capture shows the conversation with PostgreSQL on port 5432, with untranslated addresses from the backend network. The second returns source: cache and adds not a single packet to the capture: the cache-aside pattern finds the books:all key in Redis and never even opens a query.

Two useful conclusions. The first, architectural: tcpdump has just turned something that until now was a JSON field you had to trust into an observable fact. The second, diagnostic: if one day /books always answered source: db, this very capture would tell you whether the problem is in writing the key to Redis or in reading it, without touching a line of code.

Conclusion

Docker networking has stopped being magic. You know that a bridge network is a Linux bridge —docker0 or br-<id>— with one veth end per container plugged into it, and you know how to match the two ends with iflink and ethtool -S to measure a specific container's traffic from the host. You know a packet's two possible paths: pure switching between containers on the same network, and MASQUERADE when it heads outside. And you have read the real rules: the DOCKER chain with its DNAT for every -p, and DOCKER-ISOLATION-STAGE-2 with the two-line DROP that explains all the network isolation you have been using since module 3.

You also take away a warning worth more than many configurations: publishing a port bypasses the host firewall, because the DNAT is evaluated before INPUT; the defense is to publish on 127.0.0.1, not to publish at all, or to write rules in DOCKER-USER, the only chain Docker respects. You know how the embedded DNS at 127.0.0.11 really resolves a name and why resolution is scoped to shared networks; you control addressing (--subnet, --ip-range, --aux-address, mtu) so as to coexist with VPNs and real machines; you know the six drivers in depth, including macvlan with its promiscuous-mode trap and ipvlan in L2 and L3; and you diagnose with netshoot, ss, tcpdump on the real traffic between aurora-api and aurora-db, and nsenter when there is not even an sh inside the container.

The next layer to open is the data layer. In lesson 05-02 you will look at storage in depth: what a storage driver is and how overlay2 works internally with its lowerdir, upperdir and merged; how much copy-on-write really costs —measured— and why that forces a database to use volumes; volume drivers and their options, including NFS and volumes shared between stacks; and a backup strategy for Aurora Libros with tested restores, because a backup that has never been restored is not a backup.

Docker: From Beginner to Advanced

Module 1: Introduction to Docker

Module 2: Working with Docker Images

Module 3: Docker Containers

Module 4: Docker Compose

Module 5: Advanced Docker Concepts

Module 6: Docker in Production

Module 7: Docker Ecosystem and Tools

© Copyright 2026. All rights reserved