Skip to content
Sanyam Jain
Go back

Docker Networking Explained

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:

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:

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:

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:

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?

Share this post: