NovuSpark
All articles
ContainerizationJanuary 30, 2026 · NovuSpark Team

Docker Networking Explained

This is the third post in our Docker fundamentals series, building on images and containers and production Dockerfiles.

A question almost everyone new to Docker eventually asks in some form: "why can't my container connect to localhost:5432 when Postgres is clearly running on my machine?" The answer is that a container has its own network namespace — its own localhost, entirely separate from the host's. Understanding Docker's network drivers is what turns that from a confusing surprise into an expected, predictable behavior.

The default: bridge networking

docker network ls
NETWORK ID     NAME      DRIVER    SCOPE
a1b2c3d4e5f6   bridge    bridge    local

Every container, unless told otherwise, attaches to bridge — a private virtual network Docker creates on the host. Containers on this network get their own internal IP addresses and can reach each other by IP, but not by container name, and the host's own localhost is not the same thing as any container's localhost.

docker run -d --name web -p 8080:80 nginx

-p 8080:80 publishes the container's port 80 to the host's port 8080 — this is specifically what makes localhost:8080 on your actual machine reach the container. Without a published port, a container on the default bridge network is reachable from other containers on that network, but not from the host machine directly.

default bridgewebdbweb must use db's raw IP —no name resolutionuser-defined bridgewebdbDNSweb connects to literal hostname "db"resolved automatically, survives recreation
Fig. 1 — the default bridge has no name resolution; a user-defined bridge resolves container names automatically

User-defined bridge networks: the practically important upgrade

docker network create app-network
 
docker run -d --name db --network app-network postgres:16
docker run -d --name web --network app-network -p 8080:80 my-app

Containers on a user-defined bridge network get something the default bridge doesn't provide: automatic DNS resolution by container name. From inside the web container, db resolves directly to the database container's current IP address:

docker exec web ping db
PING db (172.20.0.2): 56 data bytes
64 bytes from 172.20.0.2: seq=0 ttl=64 time=0.089 ms

This is the detail that makes multi-container applications actually pleasant to configure — an application's database connection string can simply say db as the hostname, and it keeps working correctly even if the database container is removed and recreated with a new internal IP address, because DNS resolution happens by name, dynamically, every time.

This is also the practical reason to always create a dedicated network for a multi-container application, rather than relying on the default bridge: name-based resolution, and isolation from unrelated containers that happen to also be running on the same host.

Isolating groups of containers from each other

A dedicated network isn't just about naming convenience — it's also a real isolation boundary. Two applications running on the same Docker host, each on their own user-defined network, cannot reach each other at all unless explicitly connected:

docker network create frontend-net
docker network create backend-net
 
docker run -d --name api --network backend-net my-api
docker run -d --name db --network backend-net postgres:16
docker run -d --name web --network frontend-net -p 8080:80 my-web
 
# connect web to backend-net too, so it can reach the API,
# without exposing the database to the frontend network at all
docker network connect backend-net web

docker network connect attaches an already-running container to an additional network without recreating it — here, web can now reach api (both on backend-net), but the database stays reachable only from containers explicitly placed on backend-net, not from anything on frontend-net. This is a genuinely useful pattern for keeping a database off any network a public-facing container is directly attached to, even before any firewall or security-group rule enters the picture.

Host networking: removing the isolation entirely

docker run -d --network host nginx

With --network host, a container shares the host's network namespace directly — no isolation, no published ports, no virtual bridge. The container's port 80 is the host's port 80. This trades away Docker's networking isolation entirely, in exchange for eliminating the small amount of overhead the bridge network adds — a trade only worth making for genuinely performance-sensitive services where that overhead has actually been measured and matters, not as a default convenience.

Container-to-container communication in practice

# A preview of Compose syntax — covered fully in the next post in this series
services:
  web:
    build: .
    ports:
      - "8080:80"
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: example
# Inside the web container's application code
DATABASE_URL = "postgresql://postgres:example@db:5432/mydb"

db here is not a placeholder — it's the actual, literal hostname the application connects to, resolved automatically because Compose creates a user-defined network for every project by default and names each service's container after its key in the Compose file. This is the payoff of everything covered above, in the form it actually shows up in day-to-day work.

Inspecting a network directly

docker network inspect app-network
[
  {
    "Name": "app-network",
    "Driver": "bridge",
    "Containers": {
      "a1b2c3...": { "Name": "web", "IPv4Address": "172.20.0.3/16" },
      "d4e5f6...": { "Name": "db", "IPv4Address": "172.20.0.2/16" }
    }
  }
]

Worth knowing this exists specifically for debugging: when a connection between two containers isn't working the way you expect, docker network inspect shows you exactly which containers are actually attached to a given network and what their current IPs are — often the fastest way to confirm whether a networking problem is actually a networking problem, versus an application-level misconfiguration wearing a networking disguise.

Common pitfalls worth naming directly

  • Expecting localhost inside a container to mean the host machine. It doesn't — it means the container's own network namespace. host.docker.internal (on Docker Desktop) is the actual mechanism for a container to reach a service running on the host machine itself.
  • Forgetting that stopping a container doesn't free its published port immediately in every case — a container that failed to shut down cleanly can leave a port bound longer than expected, which reads as "the port is already in use" and confuses people into thinking something else is wrong.
  • Assuming containers on the default bridge network can resolve each other by name. They can't — DNS-by-name is specifically a user-defined network feature, one more reason to avoid the default bridge for anything beyond quick, single-container testing.

Overlay networks: multi-host container communication

Everything covered so far assumes containers running on a single Docker host. For containers spread across multiple hosts (a Docker Swarm cluster, or any multi-machine setup), an overlay network extends the same container-to-container, name-based communication model across host boundaries:

docker network create --driver overlay --attachable multi-host-net

An overlay network handles the actual packet routing between hosts transparently, so a container on host A can still reach a container on host B by service name, exactly as if both were on the same machine's bridge network. This is genuinely the same problem Kubernetes' own cluster networking (covered in our next series) solves at a larger scale — Docker's overlay networks are the more limited, Swarm-oriented version of the same underlying idea: name-based service discovery that works regardless of which physical machine a container actually lands on.

Inspecting DNS resolution directly, when it's not behaving as expected

When name-based resolution on a user-defined network doesn't seem to be working as described in this post, it's worth checking Docker's embedded DNS server directly rather than guessing:

docker exec web nslookup db
docker exec web cat /etc/resolv.conf
Server: 127.0.0.11
Address: 127.0.0.11:53

Name: db
Address: 172.20.0.2

127.0.0.11 is Docker's own embedded DNS resolver, automatically configured inside every container on a user-defined network — confirming a container is actually pointed at it (rather than, say, a stale /etc/hosts entry or a misconfigured DNS setting inherited from the host) is usually the fastest way to isolate whether a name-resolution problem is a genuine Docker networking issue or something else entirely.

What to actually remember from this post

  • Every container gets its own network namespace — its localhost is never the same as the host's localhost.
  • -p publishes a container's port to the host; without it, a container on the default bridge is reachable from other containers but not from the host directly.
  • User-defined bridge networks provide DNS resolution by container name — the default bridge does not, which is the main practical reason to always create a dedicated network per application.
  • Separate networks are a real isolation boundary, not just naming convenience — docker network connect bridges two networks deliberately, rather than defaulting every container onto one shared network.
  • docker network inspect is the fastest way to confirm whether a connectivity problem is actually networking, rather than guessing.
  • --network host removes isolation entirely — a deliberate trade for measured performance needs, not a default.

Next in the series: Docker Compose for Local Multi-Container Development, where these networking concepts become a single declarative file instead of a sequence of manual docker run and docker network commands.

Ready when you are

Want training built around your team's real work?

Tell us about your team and what you're trying to solve — we'll recommend a program that fits.