Networking · lesson 7 of 8
Beginner
4Intermediate
4Also part of Networking
11- + Linux Networking: Routes, Interfaces, and Bind Addresses from Linux
- + Bash Networking from Bash
- + Docker Networking from Docker
- + FastAPI HTTP Methods and Status Codes from FastAPI
- + FastAPI Headers and Cookies from FastAPI
- + Cloud Networking Basics: What's the Same Everywhere from Multi-Cloud
- + VPC and Networking Basics from AWS
- + GCP VPC Networking Basics from Google Cloud
- + Azure Virtual Network Basics from Azure
- + The TLS Handshake, Run Locally with openssl s_server from Cryptography
- + Load Balancing: Round Robin vs Least Connections from System Design
Troubleshooting a Broken Network, Layer by Layer
Debug a Docker Compose app with three planted faults, a DNS typo, the wrong network and the wrong port, using one tool per layer until curl returns 200.
By SkillByExample · Updated
When “the app can’t reach the API”, the fault could be in any layer, and each layer’s tool only sees its
own layer. The method that works is a ladder: check name resolution, then the route, then the port, then
the application, and stop at the first rung that fails. This lesson plants three faults in a small Docker
Compose app and climbs the ladder until the request returns 200. Fixing each fault reveals the next one,
the way it usually happens in real life. Tested with Docker Engine 29.8.2, Docker Compose 5.5.1, Python
3.12.15 (python:3.12-slim), Alpine 3.22.6 and curl 8.14.1.
The broken app
The API is a tiny health endpoint on port 8080:
# app.py
from http.server import BaseHTTPRequestHandler, HTTPServer
class Health(BaseHTTPRequestHandler):
def do_GET(self):
ok = self.path == "/health"
body = b'{"status": "ok"}\n' if ok else b'{"error": "not found"}\n'
self.send_response(200 if ok else 404)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
HTTPServer(("0.0.0.0", 8080), Health).serve_forever()
Its image, in api/Dockerfile, adds ss. The client image, in client/Dockerfile, is Alpine with curl:
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends iproute2 \
&& rm -rf /var/lib/apt/lists/*
COPY app.py /app.py
CMD ["python", "/app.py"]
FROM alpine:3.22
RUN apk add --no-cache curl
The Compose file contains all three faults. Try to spot them before reading on:
# compose.yaml
name: netdebug
services:
api:
build: ./api
networks: [back]
client:
build: ./client
command: sleep infinity
environment:
API_URL: http://apii:8000/health
networks: [front]
networks:
front:
internal: true
back:
internal: true
docker compose up -d --build
The symptom
$ docker compose exec client sh -c 'curl -sS "$API_URL"'; echo "exit=$?"
curl: (6) Could not resolve host: apii
exit=6
curl’s exit code names the rung. 6 is “couldn’t resolve the host”, so you start at DNS. (7 would be
“couldn’t connect”, and 28 would be a timeout.)
Rung 1: does the name resolve?
$ docker compose exec client getent hosts apii; echo "exit=$?"
exit=2
$ docker compose exec client getent hosts client; echo "exit=$?"
172.22.0.2 client client
exit=0
$ docker compose config --services
api
client
getent hosts asks the same system resolver curl uses. It finds nothing for apii, but it does resolve
the client’s own name, so the resolver itself works. The name is what’s wrong. Compose names each
service’s DNS entry after the service, and the service is called api. Fault 1 is a typo. Change
apii to api in API_URL, and recreate the client:
$ docker compose up -d
$ docker compose exec client sh -c 'curl -sS "$API_URL"'; echo "exit=$?"
curl: (6) Could not resolve host: api
exit=6
$ docker compose exec client getent hosts api; echo "exit=$?"
exit=2
The spelling is right now, and it still doesn’t resolve. Don’t stop at the first thing you fixed: the ladder says to stay on this rung until it passes.
Rung 2: is there a path?
Docker’s DNS only answers for containers that share a network with the one asking. So check which networks each container is on:
$ docker inspect -f "{{.Name}} {{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{$v.IPAddress}}{{end}}" netdebug-api-1 netdebug-client-1
/netdebug-api-1 netdebug_back 172.21.0.2
/netdebug-client-1 netdebug_front 172.22.0.2
$ docker compose exec client ip route
172.22.0.0/16 dev eth0 scope link src 172.22.0.2
$ docker compose exec client sh -c 'curl -sSv http://172.21.0.2:8000/health 2>&1 | grep -E "Trying|connect to|^curl:"'
* Trying 172.21.0.2:8000...
* Failed to connect to 172.21.0.2 port 8000 after 0 ms: Could not connect to server
curl: (7) Failed to connect to 172.21.0.2 port 8000 after 0 ms: Could not connect to server
The two containers are on different networks, back and front, in different subnets. The client’s
route table knows only its own 172.22.0.0/16, and as an internal network it has no default route, so
even the API’s IP address fails in 0 ms. No packet could be sent. The missing DNS answer was a symptom of
this rung, not of the name. Fault 2 is the wrong network. Attach the API to both networks, so it
stays on back for whatever else lives there:
$ docker compose up -d
$ docker compose exec client getent hosts api; echo "exit=$?"
172.22.0.3 api api
exit=0
The name now resolves, to the API’s new address on front.
Rung 3: does anything accept the connection?
$ docker compose exec client sh -c 'curl -sSv "$API_URL" 2>&1 | grep -E "Trying|connect to|^curl:|^< HTTP|status"'
* Trying 172.22.0.3:8000...
* connect to 172.22.0.3 port 8000 from 172.22.0.2 port 41282 failed: Connection refused
* Failed to connect to api port 8000 after 0 ms: Could not connect to server
curl: (7) Failed to connect to api port 8000 after 0 ms: Could not connect to server
Exit code 7 again, with the same vague final message as the route failure. Recent curl versions summarise
both as “Could not connect to server”. The -v line underneath has the real error: Connection refused.
As the sockets lesson showed, a refusal means the host is up and
reachable, so the problem is the port. Ask the API what it’s actually listening on:
$ docker compose exec api ss -tlnp
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0 5 0.0.0.0:8080 0.0.0.0:* users:(("python",pid=1,fd=3))
LISTEN 0 4096 127.0.0.11:33157 0.0.0.0:*
Python is listening on 8080 on every interface. The client was calling 8000. (The second line is Docker’s embedded DNS resolver, which every container on a user-defined network has.) Fault 3 is the wrong port. Here’s the fixed file:
# compose.yaml
name: netdebug
services:
api:
build: ./api
networks: [back, front]
client:
build: ./client
command: sleep infinity
environment:
API_URL: http://api:8080/health
networks: [front]
networks:
front:
internal: true
back:
internal: true
Rung 4: does the application answer correctly?
$ docker compose up -d
$ docker compose exec client sh -c 'curl -sS -w "HTTP %{http_code}\n" "$API_URL"'
{"status": "ok"}
HTTP 200
200 with the expected body. Each fault was only visible once the one below it was fixed, and each one
was found by a different tool: getent for names, docker inspect and ip route for paths, ss for
ports, and curl’s status code for the application. Guessing in a different order would have meant, for
example, staring at port numbers while the name still didn’t resolve. More on how Docker networks
isolate containers in Docker networking, and on route tables in
Linux networking basics. When you’re done, run docker compose down.
Practice
- A fourth fault, at the top. Change the path in
API_URLto/healthz. Which rung fails, and where do you look to prove it?
Solution
$ docker compose exec client sh -c 'curl -sS -w "HTTP %{http_code}\n" "$API_URL"'
{"error": "not found"}
HTTP 404
$ docker compose logs api
api-1 | 172.22.0.2 - - [13/Oct/2026 19:36:53] "GET /health HTTP/1.1" 200 -
api-1 | 172.22.0.2 - - [13/Oct/2026 19:37:04] "GET /healthz HTTP/1.1" 404 -
The application rung. A 404 is an HTTP answer, so DNS, the route and the port all worked. The server’s
access log shows the exact path it received: the earlier /health got 200, and /healthz got 404. Both
came from 172.22.0.2, the client’s address on front. No network tool would have found this, and none was needed.
- Turn the ladder into a script. Write
ladder.shthat checks DNS, then the port, then the status code, and prints the first rung that fails. Run it against the broken file, each partial fix, and the final file.
Solution
# ladder.sh
host=$(echo "$API_URL" | sed -E 's#^[a-z]+://([^:/]+).*#\1#')
port=$(echo "$API_URL" | sed -E 's#^[a-z]+://[^:/]+:([0-9]+).*#\1#')
getent hosts "$host" > /dev/null || { echo "DNS: $host does not resolve"; exit 1; }
nc -z -w 2 "$host" "$port" || { echo "PORT: nothing accepts $host:$port"; exit 2; }
code=$(curl -s -o /dev/null -w '%{http_code}' "$API_URL")
[ "$code" = 200 ] || { echo "APP: $API_URL returned $code"; exit 3; }
echo "OK: $API_URL returned 200"
-T lets the script be piped into the container’s shell on standard input. Each block below is the
result with a different version of compose.yaml: the original, with the typo fixed, with the network
fixed, fully fixed, and with the /healthz path from task 1.
$ docker compose exec -T client sh < ladder.sh; echo "exit=$?"
DNS: apii does not resolve
exit=1
$ docker compose exec -T client sh < ladder.sh; echo "exit=$?"
DNS: api does not resolve
exit=1
$ docker compose exec -T client sh < ladder.sh; echo "exit=$?"
PORT: nothing accepts api:8000
exit=2
$ docker compose exec -T client sh < ladder.sh; echo "exit=$?"
OK: http://api:8080/health returned 200
exit=0
$ docker compose exec -T client sh < ladder.sh; echo "exit=$?"
APP: http://api:8080/healthz returned 404
exit=3
On Docker, the wrong network shows up at the DNS rung, because the embedded resolver only answers for
shared networks, so a script can’t tell it apart from a typo. That’s why you still need the
docker inspect step. A distinct exit code per rung lets a health check or CI job report which layer
broke, not just that something did.
- The firewall version. With everything fixed, drop port 8080 inside the API’s network namespace,
using the
drop.nftidea from the firewall lesson. Predict curl’s exit code and whatladder.shprints, then check. Thefw-labimage from that lesson can join the API container’s namespace with--network container:netdebug-api-1.
Solution
# drop8080.nft
table inet lab
delete table inet lab
table inet lab {
chain input {
type filter hook input priority 0; policy accept;
tcp dport 8080 counter drop
}
}
$ docker run --rm --network container:netdebug-api-1 --cap-add NET_ADMIN -v "$PWD":/work fw-lab nft -f drop8080.nft
$ docker compose exec client sh -c 'time curl -sS --connect-timeout 3 "$API_URL"; echo "exit=$?"'
curl: (28) Connection timed out after 3003 milliseconds
Command exited with non-zero status 28
real 0m 3.00s
user 0m 0.00s
sys 0m 0.00s
exit=28
$ docker compose exec -T client sh < ladder.sh; echo "exit=$?"
PORT: nothing accepts api:8080
exit=2
DNS and the route still pass, and the port rung fails again, but as silence, not a refusal. curl waits
out its connect timeout and exits 28 instead of 7. ladder.sh prints the same PORT message it gave
for the wrong port, because nc -z only reports failure. To tell the two apart, time the check: a
refusal fails in milliseconds, and a drop takes the whole timeout. The rule lives in the API container’s
namespace, so docker compose down removes it along with the container.