Yasas Banuka
Docker Networking: How Containers Actually Find Each Other
Back to BlogDevOps

Docker Networking: How Containers Actually Find Each Other

June 16, 20269 min read·1,789 words
Share
Docker: From Zero to Production — Part 4 of 7
1Why Docker Exists (And Why VMs Weren't Enough)2What's Actually Happening When You Run a Container3Images, Layers, and Why Your Builds Are Slow4Docker Networking: How Containers Actually Find Each OtherCurrent
5Docker Compose: Reading the File You've Been Copy-PastingComing Soon
6Securing Your Docker Setup (Most People Skip This)Coming Soon
7From Laptop to Production: CI/CD with DockerComing Soon

Quick scenario.

You've got a Spring Boot backend talking to a Postgres database, both in Docker containers. You wrote the connection string exactly like you would on your own laptop:

jdbc:postgresql://localhost:5432/mydb

You run it. Connection refused.

You check - Postgres is definitely running. You can see it in docker ps. You can even connect to it manually. But your backend container insists it can't find it.

This single issue has cost more developer-hours than almost any other Docker problem.


Why localhost Lies to You

Here's the thing about localhost. It doesn't mean "the network." It means "myself."

When your backend container says localhost:5432, it's saying: "look for Postgres running inside me." But Postgres isn't inside the backend container. It's in a completely separate container — its own isolated little world.

Think of it like two apartments in the same building. Each apartment has its own front door, its own rooms, its own address. If you're standing in apartment 4B and you say "check my kitchen for milk" - you're checking 4B's kitchen. Not 4A's. Even though they're right next to each other, sharing the same building.

Each container is its own apartment. localhost always means "this apartment," never "the building."

So how do two containers, two separate apartments actually find each other?

That's where Docker's networking comes in. And it starts with something that looks like a regular network switch, because in a way, it is one.


The Bridge: Docker's Virtual Switch

When Docker starts up, it creates a virtual network interface on your machine called docker0. Think of it as a switch — like the physical network switch in an office that all the computers plug into.

Every container gets its own virtual network cable (technically called a veth pair). One end goes into the container, the other end plugs into this virtual switch.

Docker Network Architecture

Bridge Mode (Isolated)

Container A

[eth0]

172.17.0.2

Container B

[eth0]

172.17.0.3

veth
veth

docker0 (Virtual Bridge)

172.17.0.1

NAT / iptables

Host Mode (Direct)

Container C

Bypasses Bridge

Shares Host Network

Direct Access

Host Network Interface [eth0]

192.168.1.50

To Internet

All containers plugged into the same bridge can talk to each other over this virtual network. Each one gets its own private IP address, like 172.17.0.2, 172.17.0.3, and so on.

So in theory, your backend container could connect to Postgres using its IP address — 172.17.0.3:5432. And that would actually work.

But here's the problem. Container IPs change. Every time you restart a container, it might get a different IP. Hardcoding 172.17.0.3 into your connection string is a massive issue.

What you actually want is to say "connect to whatever container is named postgres" and have Docker figure out the IP for you.

This is where the most misunderstood part of Docker networking comes in.


The DNS Trap That Catches Almost Everyone

Docker has two completely different "default" behaviors depending on how you create your network, and one of them has no name resolution at all.

When Docker installs, it automatically creates a network simply called bridge — the default bridge network. If you run a container without specifying a network, it lands here.

On this default network, containers can talk to each other only by IP address. There is no DNS. None. If your backend tries to look up postgres by name on the default bridge network, it fails bad address 'postgres' even if the Postgres container is sitting right there on the same network.

# both containers on the DEFAULT bridge network
docker run -d --name postgres postgres:16
docker run -d --name backend myapp

docker exec backend ping postgres
# ping: bad address 'postgres'   (Fails)

But create your own network, a user-defined bridge and everything changes:

docker network create app-network

docker run -d --name postgres --network app-network postgres:16
docker run -d --name backend --network app-network myapp

docker exec backend ping postgres
# PING postgres (172.18.0.2): 56 data bytes   (Works)

Same setup. Same containers. The only difference is which network they're on. One has DNS. One doesn't.

Why does this gap exist? The default bridge network predates Docker's embedded DNS system. When Docker first shipped, the only way to connect containers by name was something called --link - a flag that created hardcoded, one-directional links between specific containers, and even leaked environment variables between them as a side effect.

When Docker introduced user-defined networks, they came with a proper embedded DNS server running quietly inside every container at the address 127.0.0.11. But to avoid breaking existing setups, the old default bridge network was left exactly as it was. No DNS, ever.

The practical takeaway: never use the default bridge network for anything with more than one container. Always create your own network or better - use Docker Compose, which creates one for you automatically. This single habit eliminates an entire category of "why can't my containers see each other" bugs before they happen.


Think About It

Here's a quick experiment you can run in under a minute.

Create any container on a custom network and peek inside at its DNS configuration:

docker network create test-net
docker run --rm --network test-net alpine cat /etc/resolv.conf

You'll see something like:

nameserver 127.0.0.11
options ndots:0

That 127.0.0.11 is Docker's embedded DNS server. It's not your router. It's not Google DNS. It's a tiny DNS resolver that Docker injects into every container on a custom network and its only job is answering one question: "what's the current IP address of the container named X?"

If you give multiple containers the same network alias, Docker's embedded DNS will return all of their IP addresses when you look up that name and clients that try multiple results (which most do) will essentially round-robin between them.

docker run -d --network test-net --network-alias workers myapp
docker run -d --network test-net --network-alias workers myapp
docker run -d --network test-net --network-alias workers myapp

docker run --rm --network test-net alpine nslookup workers
# returns THREE different IP addresses

That's primitive load balancing, built directly into DNS, with zero extra tools. No nginx, no load balancer container, nothing. Just a naming convention.


The Firewall That Doesn't Apply to Docker

Say you're running a server with ufw (Uncomplicated Firewall) - a common Linux firewall tool. You've locked things down. You've explicitly denied incoming traffic on port 5432, because you don't want Postgres reachable from the internet.

Then you run:

services:
  postgres:
    image: postgres:16
    ports:
      - "5432:5432"

Your firewall rule says deny. Docker says allow. Docker wins.

Here's why. When you publish a port with -p or ports:, Docker doesn't ask your firewall for permission. It writes its own rules directly into a part of the Linux networking stack called the NAT table and crucially, the NAT table is processed before your firewall's filter rules even get a chance to run.

Packet Flow Execution Order

Incoming Packet

Processed FIRST

Redirects port 5432 → Container

NAT Table

Docker's Rules

Filter Table

ufw / iptables

Processed SECOND

Too late. Packet already redirected.

Container App

By the time your firewall rule would normally apply, Docker has already rewritten the destination and forwarded the packet straight to the container. Your ufw deny 5432 rule never even gets a chance to fire.

This isn't a bug. It's intentional - Docker manages its own networking because containers need dynamic, programmatic firewall rules that change every time a container starts or stops. But it means the mental model "my firewall protects this server" is incomplete the moment Docker enters the picture.

The fix isn't a firewall rule. It's controlling what Docker publishes in the first place:

# Exposed to the entire internet — anyone can connect
ports:
  - "5432:5432"

# Only reachable from this machine itself — the right default for databases
ports:
  - "127.0.0.1:5432:5432"

That 127.0.0.1: prefix tells Docker to only bind the published port to the loopback interface. No NAT rule gets created for external traffic at all. This is the single most important Docker networking habit for anyone running databases, admin panels, or internal tools - bind to localhost, and let only your reverse proxy (nginx, Caddy, etc.) be reachable from outside.


What -p 8080:80 Actually Means

One more thing,

-p
8080
:
80

Host Port

Published on host machine

Container Port

Internal app listening port

These two numbers don't need to match and usually shouldn't. You could run five different nginx containers, each listening on port 80 inside their own container, but published on the host as 8081, 8082, 8083, 8084, 8085. No conflicts, because each container has its own private network namespace - its own "apartment" where port 80 means something different in each one.

Your Browser

Containers

localhost:8081

nginx container 1

Internal: :80

localhost:8082

nginx container 2

Internal: :80

localhost:8083

nginx container 3

Internal: :80

The host port is the only thing that needs to be unique. The container port is just "what port does the app inside listen on" - completely independent of how the outside world reaches it.


Bringing It All Together

Let's connect this back to where we started - your backend trying to reach Postgres.

The fix is simple once you see the whole picture:

services:
  backend:
    environment:
      DB_URL: jdbc:postgresql://postgres:5432/mydb
    networks:
      - app-network

  postgres:
    image: postgres:16
    ports:
      - "127.0.0.1:5432:5432"   # only reachable from this host
    networks:
      - app-network

networks:
  app-network:

postgres:5432 not localhost, not an IP address - works because both containers are on the same custom network, and Docker's embedded DNS at 127.0.0.11 resolves postgres to whatever IP that container currently has, every single time, automatically.

The port binding to 127.0.0.1 means even though ports: is set, nothing outside this machine can ever reach Postgres directly, no matter what your firewall thinks it's doing.

And Docker Compose does the "create a custom network" step for you automatically - which is exactly why this just works the moment you use Compose, and exactly why it silently fails the moment you try to recreate the same setup with plain docker run on the default bridge.


The Core Mental Model

Three things to carry forward:

  • localhost inside a container means "myself," never "the host" and never "another container." Every container is its own private apartment.
  • Container name resolution only works on custom networks - never on the default bridge. This single fact explains most "containers can't find each other" issues. Always create your own network, or use Compose.
  • Docker's port publishing happens at the NAT level, before your firewall rules apply. Your ufw or iptables rules don't protect published container ports. Binding to 127.0.0.1 does.

Networking in Docker is a virtual switch, a tiny DNS server, and some NAT rules, working together in a way that's actually pretty elegant once you've seen behind the curtain.


What's Next

Next up, we're opening the Docker Compose file you've probably copy-pasted a dozen times and reading it the way Docker actually reads it. depends_on, healthchecks, volumes, secrets - the patterns that quietly separate a toy setup from something you'd trust in production.


Have you ever spent way too long debugging a "connection refused" between two containers that should obviously be able to talk to each other? What was the actual cause once you found it? Drop it in the comments.

#docker#networking#devops#containers#security
Share
← All Articles
Yasas Banuka Malavige

Written by

Yasas Banuka Malavige

DevOps Engineer · Building resilient infrastructure, automating pipelines, and documenting the quiet foundations that keep production systems alive.

Docker: From Zero to Production — Part 4 of 7
1Why Docker Exists (And Why VMs Weren't Enough)2What's Actually Happening When You Run a Container3Images, Layers, and Why Your Builds Are Slow4Docker Networking: How Containers Actually Find Each OtherCurrent
5Docker Compose: Reading the File You've Been Copy-PastingComing Soon
6Securing Your Docker Setup (Most People Skip This)Coming Soon
7From Laptop to Production: CI/CD with DockerComing Soon