Docker Networking Deep Dive: How My Air Hockey Server's Containers Actually Talk to Each Other

Introduction
Early on, while dockerizing the air hockey backend, Redis was running in its own container but the API server was still running locally on my machine, so localhost:6379 worked fine. Same machine, same network stack, no boundaries in the way.
That stopped working the moment I dockerized the API server too. Same connection string, same Redis instance, and suddenly nothing connected, no error that pointed anywhere useful, just a refused connection.
If you haven't read the earlier post, the air hockey server is a real-time multiplayer game backend in Go — HTTP for matchmaking, UDP for gameplay — that I've been using to learn concepts by actually hitting their sharp edges instead of reading about them. The code is on GitHub if you want the full picture.
The reason wasn't a bug in either service. It was a gap in what I actually understood about what Docker does the moment two containers need to reach each other, and what Compose is quietly doing to make service-name:port work at all.
This isn't a general "here's how Docker networking works" tutorial. It's the opposite — I'll walk through the exact Dockerfile and compose file for this project and explain the plumbing behind each decision in it. If you've got a multi-container app running and aren't entirely sure why service-name:port works but localhost:port doesn't, this should close that gap.
Let's get into it.
The Setup at a Glance
Quick recap of where the project stands: the backend is a Go server split across HTTP (matchmaking) and UDP (real-time gameplay), and it needs Redis for shared state. Locally, that's two containers — api and redis — defined in one docker-compose.yml, built from this Dockerfile:
# Build stage
FROM golang:1.26-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd
# Runtime stage
FROM alpine:latest
WORKDIR /root/
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/server .
EXPOSE 8080 8050/udp
CMD ["./server"]
Two containers, one shared network, one connection string (redis://redis:6379) that shouldn't make sense if you're thinking in localhost terms — but does. That's the thread I want to pull on here: the build, then the wiring, then why the wiring works the way it does.
Two Stages, One Reason: Don't Ship the Toolchain
What's happening
golang:1.26-alpine in the build stage carries the full Go compiler, module cache, and everything else needed to turn source into a binary — easily a few hundred MB of stuff. None of it needs to exist in the image that actually runs in production. So the runtime stage starts over from plain alpine:latest and pulls in exactly one thing from the builder: the compiled binary.
COPY --from=builder /app/server .
Everything else — the Go toolchain, git, the source tree — gets left behind in the builder stage, which never ships anywhere. What's left is Alpine plus ca-certificates plus a ~10-15MB static binary.
Why the layer order matters
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
Docker caches each layer based on its inputs, and go.mod/go.sum change far less often than the actual source code. Copy the dependency files first, run go mod download, and then copy the rest of the source — and as long as your dependencies haven't changed, every code-only change skips straight past the download step and reuses the cached layer. Flip that order (source first, deps after) and every single code change invalidates the download cache, and now you're redownloading the entire module graph on every build.
Laid out as two approaches, it's clearer why the ordering matters:
Wrong: copy everything together
→ COPY . . [go.mod, go.sum, and source all in one layer]
→ RUN go mod download [cache tied to the whole source tree]
→ RUN go build [any code change invalidates this — full redownload every time]
Right: copy dependency files first
→ COPY go.mod go.sum ./ [only changes when dependencies change]
→ RUN go mod download [cache hit as long as go.mod/go.sum are unchanged]
→ COPY . . [cache miss on code changes — expected, cheap]
→ RUN go build [recompiles only]
Copying everything into one layer before running go mod download ties that layer's cache key to the entire source tree — change one line in any .go file and Docker has no way to tell that only the source changed, not the dependencies, so it redownloads everything on every single build. Splitting the copy in two fixes that: the download layer's cache key is now tied only to go.mod/go.sum, so it stays valid across every code-only change and only breaks when a dependency actually changes.
This isn't Go-specific, either. The same pattern shows up in a Node.js Dockerfile:
COPY package.json package-lock.json ./
RUN npm install
COPY . .
Same reasoning: npm install populates node_modules, which stays cached as long as package.json/package-lock.json haven't changed. Change application code and that layer gets reused as-is. Change a dependency and it's a fresh npm install, same as a fresh go mod download. Different ecosystem, identical cache mechanics — copy the thing that changes rarely before the thing that changes constantly.
Small thing to get right, expensive thing to get wrong once the dependency list grows
The detail that makes bare Alpine possible
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd
CGO_ENABLED=0 forces a fully static binary — no dynamic linking against C libraries. That's the actual reason the runtime stage can get away with plain Alpine. Without it, the binary would be linked against whatever libc the golang:1.26-alpine image has, which Alpine's runtime image has no guarantee of matching.
In short: CGO_ENABLED controls whether a Go binary is allowed to link against C libraries at all. Setting it to 0 disables that, forcing a static binary — smaller, with no external libc dependency to satisfy at runtime.
GOOS=linux is an environment variable used to target the Linux operating system during compilation. It allows you to cross-compile executable binaries for Linux from a different host machine, such as Windows or macOS, without needing a virtual machine.
A two-stage build only pays off if the runtime stage genuinely has nothing left to install. CGO_ENABLED=0 is what earns that — not the FROM alpine:latest line by itself.
Why redis://localhost:6379 Doesn't Work From Inside a Container
Initial Setup
On my machine, Redis running locally answers at localhost:6379. So the first instinct, wiring up the api container's connection string, is to just point it at localhost:6379 and move on.
Why it breaks
It doesn't connect. Not a timeout — an immediate connection refused, because nothing is listening on port 6379 inside the api container itself.
Every container gets its own network namespace, which means its own private loopback interface and its own localhost. When the api container's process says localhost, it means itself — not the host machine, and definitely not some other container sitting next to it. Redis is a separate container with its own isolated namespace. As far as api's localhost is concerned, Redis doesn't exist.
api container says: connect to localhost:6379
→ looks inside its own network namespace
→ nothing listening on 6379 in here
→ connection refused
What's actually happening
Redis needs to be addressed by something that isn't scoped to api's own namespace — which is exactly why the compose file points at a hostname instead:
environment:
- REDIS_URL=redis://redis:6379
redis here isn't a keyword Docker recognizes specially. It's the service name from the compose file, and it only resolves because of what Compose sets up behind the scenes — which is the next piece.
How redis Becomes a Real, Resolvable Hostname
When docker-compose up runs, it doesn't just start two isolated containers — it creates a user-defined bridge network for the project and attaches both services to it. Unlike Docker's default bridge network, a user-defined one comes with an embedded DNS server that Compose manages automatically.
That DNS server registers each container's service name as a hostname the moment the container starts, scoped to that network. So when api looks up redis, Docker's internal DNS resolves it to the redis container's actual IP address on the shared network — no manual IP wiring, no hardcoded addresses that break the moment a container restarts and gets reassigned.
Under the hood, each container's network interface (eth0) is one half of a veth pair, with the other half plugged into the bridge running on the host — think of the bridge as a virtual switch, and each veth pair as a cable running from a container into that switch. api's request goes out through its eth0, onto the bridge, and into redis's eth0 — never touching either container's localhost at any point. That's the layer where "container-to-container" actually happens.
depends_on Alone Isn't Enough
What I had
api:
depends_on:
redis
Why it's not enough
depends_on only guarantees one thing: the redis container has been started. It says nothing about whether Redis, the actual database process running inside that container — is ready to accept connections yet.
Those two events aren't the same moment. A container "starting" just means Docker has launched the process inside it. Redis itself still needs a second or two after that to initialize before it starts listening on port 6379. If api starts right alongside redis and tries to connect immediately, it can hit that gap, the container exists, but nothing inside it is listening yet, and the connection fails. Restart api a second later, after Redis has caught up, and the exact same setup connects fine.
That's what makes it a frustrating bug to track down: the code isn't wrong, and it's not broken every time, it's a race between two things starting up, and which one wins isn't guaranteed.
The fix
redis:
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
api:
depends_on:
redis:
condition: service_healthy
Docker runs redis-cli ping inside the redis container every 5 seconds until it gets back PONG, at which point the container is marked healthy. Pairing that with condition: service_healthy means api doesn't start until Redis has actually proven it's accepting connections, not just that the process technically exists. That's the difference between a setup that happens to work most of the time and one that's actually deterministic.
EXPOSE vs. the Port Mapping — Two Different Jobs
It's easy to read EXPOSE 8080 8050/udp in the Dockerfile and the ports: block in compose as doing the same thing. They're not.
EXPOSEin the Dockerfile is documentation. It tells anyone reading the image which ports the app listens on inside the container. It doesn't open anything to the outside world by itself.ports: - "8080:8080"in compose is what actually forwards traffic:host_port:container_port. This is what lets a request hitlocalhost:8080on my machine and land inside theapicontainer.
Worth noting: api reaching redis never depended on redis's port mapping at all. Container-to-container traffic goes over the internal bridge network regardless of what's mapped to the host. The "6379:6379" mapping on redis exists purely so I can redis-cli in from the host machine to poke around, if I deleted that line entirely, api would still connect to Redis exactly the same way.
Fixing the Hardcoded Environment Variables
What I had
The compose file was hardcoding every value directly under environment::
api:
environment:
- REDIS_URL=redis://redis:6379
- PORT=8080
- UDPPORT=8050
Why it's a problem
It works fine when there's exactly one environment to worry about. It stops working the moment there's a second one — a staging setup, a teammate's machine, a deploy target — anything that needs different values than my laptop does. Hardcoded values in the compose file mean editing (and re-committing) the file itself every time something needs to change, and it's one accidental commit away from leaking something it shouldn't.
The fix
Move the values into a .env file and load it with env_file:
services:
redis:
image: redis:alpine
container_name: air-hockey-redis
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
api:
build:
context: ./backend
dockerfile: Dockerfile
container_name: air-hockey-api
volumes:
- ./backend:/app
ports:
- "8080:8080"
- "8050:8050/udp"
env_file:
- .env
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
# .env
REDIS_URL=redis://redis:6379
PORT=8080
UDPPORT=8050
env_file injects everything in .env into the api container's environment at runtime, the same way the inline environment: block did, except now the actual values live in a file that's easy to swap per-environment and easy to .gitignore. One line added to .gitignore (.env) and the values never touch version control again.
Worth flagging the difference between this and Compose's other .env behavior: a root-level .env file is also auto-read by Compose to substitute ${VARIABLES} inside the compose file itself, which is a separate mechanism from env_file handing variables to a container's runtime environment. Same filename, two different jobs — easy to conflate, worth keeping straight.
Below is a simple example:
$ cat .env
TAG=v1.5
$ cat compose.yaml
services:
web:
image: "webapp:${TAG}"
When you run docker compose up, the web service defined in the Compose file interpolates in the image webapp:v1.5 which was set in the .env file. You can verify this with the config command, which prints your resolved application config to the terminal:
$ docker compose config
services:
web:
image: 'webapp:v1.5'
Summary
| How it looks at first | What's actually true |
|---|---|
localhost:6379 worked before dockerizing the API, so it should keep working now that api is containerized too |
Every container has its own network namespace and its own localhost, once api is inside one, that old assumption breaks |
redis in a connection string looks like a placeholder |
It's the literal service name, registered as a real DNS hostname by Compose's embedded DNS server |
depends_on guarantees Redis is ready |
It only guarantees the container started — readiness needs a healthcheck + condition: service_healthy |
EXPOSE opens a port |
It's documentation; the ports: mapping in compose is what actually forwards traffic |
| Hardcoded env vars are fine for now | They stop being fine the moment a second environment (EC2) enters the picture |
The theme underneath all five of these: almost nothing here is magic, it's just namespaces, DNS, and forwarding rules doing exactly what they're told, once you know what they've been told. Once localhost stopped meaning "the machine" and started meaning "this specific container's own bubble," the rest — DNS, health checks, port mappings — fell into place as consequences of that one fact rather than five separate things to memorize.
The code is open source if you want to dig through it: github.com/Nitin-Poojary/air-hockey



