You spin up a container. It runs fine. Then you spin up a second one — and they can’t talk. No pings. No database connections. Just silence.

I’ve hit that wall more times than I care to admit. The first time it happened, I was trying to connect a Python app container to a PostgreSQL container. Everything looked right — the database was running, the credentials were correct — but the app just timed out. I spent an hour debugging connection strings before realizing: the containers weren’t on the same network.
Docker networking isn’t complicated once you understand the mental model. But the official docs throw you into the deep end with overlay networks, ingress routing, and Swarm mode before you’ve even grasped the basics. Let’s fix that.
The Default Setup: What Docker Gives You for Free
When Docker starts on your machine, it creates three networks automatically:
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
a1b2c3d4e5f6 bridge bridge local
g7h8i9j0k1l2 host host local
m3n4o5p6q7r8 none null local
The bridge network is the default. Every container you run without specifying a network lands here. Docker assigns each container an internal IP (usually in the 172.17.0.0/16 range) and they can communicate with each other — but only by IP address, not by container name.
That last part trips people up. Two containers on the default bridge can ping each other by IP, but DNS resolution (using container names like my-postgres-db) doesn’t work. You need a user-defined bridge network for that.
Custom Bridge Networks: The Real Way to Connect Containers
Create a custom bridge network and DNS just works. Container names become hostnames.
$ docker network create my-app-net
Now run two containers on this network:
$ docker run -d --name db --network my-app-net -e POSTGRES_PASSWORD=secret postgres:16
$ docker run -it --name app --network my-app-net alpine ping db
The Alpine container resolves db to the PostgreSQL container’s IP automatically. No hardcoded IPs, no manual hosts file entries. Docker’s embedded DNS server handles it.
Here’s a quick way to inspect what’s happening under the hood:
$ docker network inspect my-app-net
You’ll see every container attached, their IPs, and the subnet configuration. This command has saved me countless headaches when debugging why two services won’t connect — it’s the first thing I run when something goes quiet.
Port Mapping: Letting the Outside World In
Internal container-to-container communication is one thing. But what about traffic from your browser, your API client, or another machine on your network? That’s where port mapping comes in.
$ docker run -d --name web --network my-app-net -p 8080:80 nginx:alpine
This maps port 8080 on your host machine to port 80 inside the container. Now http://localhost:8080 reaches Nginx. The -p flag accepts host_port:container_port — you can also bind to a specific interface with 127.0.0.1:8080:80 if you don’t want the service exposed on all network interfaces.
One thing I learned the hard way: if you forget the port mapping on a container that’s already running, you can’t add it. You have to stop, remove, and recreate the container. Docker Compose makes this less painful (we’ll get to that).
Docker Compose Networking: No Port Mapping Headaches
Docker Compose automatically creates a network for your services. Every service can reach every other service by its service name — no explicit network configuration needed.
# docker-compose.yml
version: '3.8'
services:
api:
image: my-python-api:latest
ports:
- "3000:3000"
environment:
DB_HOST: db
DB_NAME: myapp
db:
image: postgres:16
environment:
POSTGRES_DB: myapp
POSTGRES_PASSWORD: secret
The API container connects to the database using the hostname db — Compose handles the DNS. This is why I mentioned Docker Compose for local development earlier: it eliminates the networking guesswork completely.
But Compose networks are isolated by default. Two separate Compose projects can’t talk to each other unless you explicitly attach them to the same external network:
# docker-compose.yml (project A)
networks:
shared:
external: true
name: shared-network
This is useful when you have microservices in different repos that still need to communicate during local development.
Network Drivers: Bridge, Host, and When to Use Each
Docker ships with several network drivers. For local development, you’ll mostly deal with three:
| Driver | Behavior | Use Case |
|---|---|---|
| bridge | Isolated network with NAT | Default for standalone containers |
| host | Container shares host network stack | Maximum performance, no port mapping needed |
| none | No networking at all | Security isolation or batch jobs |
The host driver is interesting. It removes network isolation entirely — the container uses the host’s interfaces directly. This is faster (no NAT overhead) but less secure. It’s useful for high-throughput services or when you’re running a tool that needs to listen on many ports without mapping each one.
$ docker run --network host nginx
# Nginx is now directly accessible on port 80 of the host
Most of the time, stick with bridge networks. Host networking is a performance optimization you reach for when profiling shows NAT overhead is actually your bottleneck.
Troubleshooting: The Three Commands I Use Daily
When containers won’t talk, here’s my debugging checklist:
1. Is the container actually on the network you think it is?
$ docker inspect <container> | grep -A 10 NetworkSettings
Look for the Networks section — it lists every network the container is attached to.
2. Can the containers reach each other at all?
$ docker exec <container> ping <other-container-name>
If DNS resolution works but ping doesn’t, it’s a firewall or application-level issue. If DNS fails, check that they’re on the same user-defined bridge network.
3. Is the port actually listening inside the container?
$ docker exec <container> netstat -tlnp
# or if netstat isn't available:
$ docker exec <container> ss -tlnp
If the service is listening on 127.0.0.1 instead of 0.0.0.0, it won’t accept connections from other containers. This is a surprisingly common issue — some frameworks default to localhost-only binding for security, which breaks container networking.
Speaking of debugging, if you’re hitting HTTP APIs from containers, knowing your way around curl inside a container saves a lot of guesswork.
When to Reach for Something Bigger
Docker’s built-in networking works beautifully for development and single-host deployments. But once you’re orchestrating containers across multiple machines, you hit its limits. That’s where Kubernetes enters the picture — but for most small teams, it’s overkill. I wrote about why I stopped recommending Kubernetes to small teams — the short version is that Docker Compose plus a solid CI/CD pipeline handles 90% of use cases without the operational complexity.
And if you’re exposing containerized services to the internet, you’ll want a reverse proxy in front. Setting up Nginx as a reverse proxy is the natural next step after you’ve got your containers talking to each other.
The Bottom Line
Docker networking clicked for me when I stopped thinking about it as a network administrator’s problem and started thinking about it as a developer’s tool. User-defined bridge networks give you DNS-based service discovery for free. Docker Compose networks eliminate the boilerplate. The host driver is there when you need raw speed.
Most of the problems I see come from two things: using the default bridge instead of a custom one (no DNS), or binding services to localhost instead of all interfaces (no external connectivity). Fix those two, and Docker networking stops being a mystery.
Now go build something that talks to itself.