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
docker0 (Virtual Bridge)
172.17.0.1
Host Mode (Direct)
Container C
Bypasses Bridge
Shares Host Network
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,
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:
localhostinside 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
ufworiptablesrules don't protect published container ports. Binding to127.0.0.1does.
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.



