Most of us meet Docker through one command.
docker run -p 3000:3000 my-app
The container starts. The app opens in the browser. Nothing feels unusual.
Then the app needs Postgres. Then Redis. Then Nginx. Then the API moves into a container and starts failing with something like:
ECONNREFUSED 127.0.0.1:5432
The same database URL worked from the laptop. Inside the container, it points somewhere else.
Table of contents
Open Table of contents
The Problem Docker Is Solving
A container is isolated from the host. It has its own filesystem, process view, and usually its own network view.
But an application rarely runs as one process.
A backend service may need:
- Postgres for data
- Redis for caching
- Nginx as an entry point
- another internal API
- outbound access to the internet
- access to a mock server running on the laptop
Docker has to keep containers isolated while still giving them controlled ways to talk.
The result is private container networking with explicit entry points from the host.
Private Network Space
Every normal Docker container gets its own network namespace.
Inside that namespace, the container has its own interfaces, IP address, routing table, DNS config, and localhost.
localhost inside a container means that container.
It does not mean the laptop.
It does not mean another container.
Docker connects these namespaces with virtual Ethernet pairs and a Linux bridge. One end of the virtual cable goes inside the container. The other end stays on the host and connects to Docker’s bridge.
container eth0
|
virtual ethernet pair
|
Docker bridge on host
|
host network
The container sees a normal network interface. The host sees a virtual interface connected to Docker’s network.
Bridge Networks
Bridge networks are Docker’s default networking mechanism for single-host containers.
On many Linux setups, Docker creates a bridge interface called docker0. Containers attached to it get private IP addresses from a range like:
172.17.0.0/16
One container might get 172.17.0.2. Another might get 172.17.0.3.
They can talk through the bridge. Machines outside Docker cannot directly reach them unless a port is published.
Published Ports
When you run:
docker run -p 8080:80 nginx
the mapping is:
host port 8080 -> container port 80
So this request:
http://localhost:8080
reaches the host first. Docker forwards it into the container on port 80.
The container does not own port 8080 on the laptop. The host owns it.
The second command fails:
docker run -p 8080:80 nginx
docker run -p 8080:80 nginx
Both containers are trying to claim host port 8080.
The container ports can be the same. The host ports must be different.
Internal Traffic
Published ports are for traffic entering from outside the Docker network.
Containers on the same Docker network can talk internally using the target service’s container port.
An API container can reach Postgres on 5432 even when Postgres is not published to the host.
The app can reach the database, but the database is not exposed to everything running on the laptop or outside the server.
Default Bridge vs User-Defined Bridge
Docker ships with a default bridge network, but project containers are easier to manage on a user-defined bridge.
docker network create app-network
Then attach related containers to it:
docker run -d \
--name postgres \
--network app-network \
-e POSTGRES_PASSWORD=secret \
postgres:16
docker run -d \
--name api \
--network app-network \
-p 3000:3000 \
my-api
Now the API can reach Postgres with:
postgres:5432
Do not use:
localhost:5432
or a hardcoded container IP like:
172.17.0.2:5432
Use the container name.
User-defined bridge networks give containers DNS by name, cleaner isolation, and a network boundary that belongs to the project. Hardcoded IPs make deployments fragile because container IPs can change.
The Localhost Trap
Here is the bug from the opening example.
The setup:
API container
Postgres container
The API uses:
postgres://user:password@localhost:5432/app
Inside the API container, localhost points back to the API container itself.
The API is effectively checking whether Postgres is running inside the API container.
Postgres is in a different container, so the URL should be:
postgres://user:password@postgres:5432/app
where postgres is the container name or Compose service name on the same Docker network.
A useful rule:
- laptop to container:
localhost:published_port - container to container:
service_name:container_port - container to itself:
localhost
Docker Compose Networking
Docker Compose creates a private network for the project automatically.
services:
api:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://postgres:secret@db:5432/app
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
Compose puts both services on the same network. The service name db becomes a DNS name, so the API connects to:
db:5432
The database port does not need to be published unless a tool on the host needs direct access to it.
A production-like Compose setup usually publishes only the edge service:
services:
nginx:
image: nginx
ports:
- "80:80"
api:
build: ./api
db:
image: postgres:16
Nginx is public. Nginx calls the API internally using the API service name. The API calls the database internally. The database stays private.
Binding To 0.0.0.0
Sometimes the port mapping is correct and the app is still unreachable.
The app may be listening only on 127.0.0.1 inside the container.
server listening on 127.0.0.1:3000
That accepts connections from inside the container only.
For containerized apps, the process usually needs to listen on:
0.0.0.0:3000
That means the process accepts traffic on all network interfaces inside the container.
Outbound Traffic And Host Access
Containers can usually make outbound calls by default.
When an API container calls:
https://api.github.com
the traffic goes through the Docker bridge, then through host NAT, then to the internet.
The outside service sees traffic from the host or the host’s network, not from the container’s private IP.
Inbound traffic is stricter. Something outside Docker cannot enter a private container unless a port is published or traffic is routed through another layer.
For development, a container may also need to call something running directly on the laptop: a mock server, local database, debugger, or another app not running in Docker.
Inside the container, localhost still points to the container, so the container needs a host address.
On Docker Desktop, that address is usually:
host.docker.internal
On Linux, it may need to be added explicitly:
docker run \
--add-host=host.docker.internal:host-gateway \
my-app
Then the container can call:
http://host.docker.internal:8080
In local development, this lets a container reach tools running on the laptop. In production, dependencies are usually better represented as services instead of hidden processes on the host.
Other Network Modes
Bridge networking covers most local development and single-host apps, but Docker has other modes:
host: the container shares the host’s network stack. It avoids Docker port forwarding, but removes network isolation and can create port conflicts.none: the container gets no external network access. Useful for isolated jobs.overlay: connects containers across multiple Docker hosts, commonly used with Docker Swarm.macvlan: gives containers addresses on the physical network. Useful in special network setups.
Most backend apps start with user-defined bridge networks locally. In production, the orchestrator usually provides the networking model: Kubernetes Services, ECS service discovery, Docker Swarm overlay networks, or something similar.
Debugging Docker Networking
When a connection fails, inspect the network.
Check networks:
docker network ls
docker network inspect app-network
Enter the container:
docker exec -it api sh
Check DNS:
getent hosts db
Check if the port is reachable:
nc -vz db 5432
For HTTP services:
curl http://api:3000/health
Check published ports from the host:
docker ps
Docker shows mappings like:
0.0.0.0:8080->80/tcp
That means host port 8080 forwards to container port 80.
You can follow this debugging checklist:
- where is the client running?
- where is the server running?
- are both containers on the same Docker network?
- is the server listening on
0.0.0.0or only127.0.0.1? - is the traffic internal, host-to-container, or container-to-host?
Final Thoughts
Docker networking gets easier when every connection has a clear source and destination.
Each container has its own localhost.
Containers talk to other containers through service names on shared networks.
The host talks to containers through published ports.
Containers talk back to the host through a host gateway such as host.docker.internal.
For real deployments, expose the edge service and keep databases, caches, workers, and internal APIs private unless they truly need to be public.
The design question stays the same:
Who should be able to talk to whom?