Networking

Difficulty

bridge — the default, isolated virtual network

docker run -d --name web nginx        # attaches to the default bridge network automatically
docker network create my-network        # or create a custom, user-defined bridge network
docker run -d --network my-network --name web nginx

Creates a private, virtual network on the host. Containers on the same bridge network can reach each other by IP. On a user-defined bridge, they can also reach each other by name, using DNS. They reach the outside world through NAT via the host. This is the right default for almost all single-host container setups.

host — no network isolation at all

docker run -d --network host nginx

The container shares the host's network namespace directly. There's no isolation, no virtual interface, and no port mapping needed. A container binding to port 80 with --network host binds to port 80 on the host itself.

This removes a layer of network translation overhead. That can matter for latency-sensitive or high-throughput workloads. But it gives up isolation entirely:

  • Two containers can't both bind the same host port.
  • A compromised container has direct access to the host's network stack.

none — no networking beyond loopback

docker run --network none myapp

The container gets no external network interface at all — only its own loopback address, 127.0.0.1. Use this for workloads that genuinely need zero network access: a batch job that only processes local files, or an extra layer of defense-in-depth.

overlay — connecting containers across multiple hosts

docker network create -d overlay my-overlay-network    # used with Docker Swarm

Extends bridge-like networking across multiple Docker hosts. Containers on different machines can talk to each other as if they were on the same local network. This is what Docker Swarm mode uses to let services span multiple nodes while still reaching each other by name.

macvlan — a container appears as a physical device

docker network create -d macvlan --subnet=192.168.1.0/24 --gateway=192.168.1.1 -o parent=eth0 my-macvlan

Gives each container its own MAC address, so it looks like a real physical device on the host's network. This bypasses Docker's usual NAT-based bridge networking. It's used for niche cases: legacy applications or network monitoring tools that expect containers to look like individually-addressable devices, not hidden behind the host's single IP.

Choosing between them, in practice

DriverTypical use case
bridge (user-defined)The default choice for nearly all single-host multi-container applications
hostPerformance-sensitive networking, or single-purpose hosts running one dominant service
noneWorkloads that should have no network access at all
overlayMulti-host Swarm deployments
macvlanLegacy applications or tooling that needs containers to appear as physical network devices

For most everyday Docker use — a web app talking to a database, a few services on one machine — a user-defined bridge network is the right choice. It's the most common choice by far, and it's not even the same as the default bridge network.

Related Resources

What happens without specifying a network

docker run -d --name web nginx
docker run -d --name api myapi:1.0

Without an explicit --network flag, both containers attach to Docker's default bridge network — visible as the docker0 interface on the host. Each gets its own private IP address on this virtual network, and can reach the internet through NAT via the host.

The critical limitation: no built-in DNS resolution by name

docker exec web ping api
# ping: api: Name or service not known

On the default bridge network, containers cannot resolve each other by container name. You'd have to look up api's current IP manually with docker inspect api, then hardcode that IP into web's configuration. This is fragile — a container's IP on the default bridge can change if it's stopped and restarted.

The fix: a user-defined bridge network

docker network create my-app-network
docker run -d --network my-app-network --name web nginx
docker run -d --network my-app-network --name api myapi:1.0
docker exec web ping api
# PING api (172.20.0.3): 56 data bytes    <- resolves correctly by name!

A user-defined bridge network, created with docker network create, includes Docker's embedded DNS server automatically. Containers on that network can resolve each other by container name, or by any --network-alias assigned. This is the single biggest reason the default bridge is discouraged for real multi-container apps.

Additional benefits of user-defined networks over the default

  • Better isolation. You can create multiple separate user-defined networks. Containers only see or reach others explicitly attached to the same network. For example, you could set up a "frontend" network and a "backend" network, with only specific containers bridging both. The shared default bridge can't do this.
  • Dynamic reconnection. A running container can be connected to or disconnected from a user-defined network on the fly, with docker network connect/disconnect, without a restart. The default bridge is more rigid about this.
  • This is exactly what Docker Compose does automatically. Every docker-compose.yml project gets its own user-defined bridge network by default. That's why Compose services can reference each other by service name out of the box, with no manual network setup.

The default bridge is really only useful for the simplest case: a single container with no inter-container communication, or a legacy setup. Anything with more than one container talking to each other should use a user-defined network instead.

What -p actually does

docker run -d -p 8080:80 nginx

This maps port 8080 on the host to port 80 inside the container. A request to http://<host-ip>:8080 gets forwarded to whatever is listening on port 80 inside the container — in this example, that's nginx's default port.

docker run -d -p 80 nginx          # publishes container port 80 to a RANDOM available host port
docker run -d -p 127.0.0.1:8080:80 nginx    # only binds to the host's loopback interface, not all interfaces

Omitting the host port lets Docker pick a random available one — check it with docker port <container>. Specifying a host IP restricts which network interface the mapping is bound to. That's useful for exposing a port only on the host itself, without making it reachable from the broader network.

The mechanism: iptables DNAT rules

When you publish a port, Docker automatically inserts iptables rules into the host's netfilter configuration. This is a DNAT (Destination NAT) rule: it rewrites the destination of incoming packets, redirecting traffic from the host's published port to the container's actual internal IP and port on its Docker network.

Incoming request: <host-ip>:8080
        │
        ▼  (iptables DNAT rule, inserted automatically by Docker)
Container's internal IP:80 (on the bridge network)

This is similar to how Kubernetes's kube-proxy uses iptables (or IPVS/eBPF) rules to route Service traffic to backing Pods. Both solve the same problem — getting external traffic to the right internal destination — with the same underlying Linux networking primitive.

Why unpublished ports still work between containers

docker network create my-network
docker run -d --network my-network --name api myapi:1.0    # port 3000, NOT published to the host
docker run -d --network my-network --name web mywebapp:1.0

api's port 3000 isn't reachable from the host machine or the internet, since no -p flag was used. But web, on the same Docker network, can still reach api:3000 directly. Containers on the same network can always reach each other's container-internal ports, no publishing needed. Port publishing (-p) is specifically about making a container's port reachable from outside the Docker network — from the host, or from the internet if the host is internet-facing. It's not required for container-to-container communication within the same network.

Publishing all exposed ports at once

docker run -P myapp        # capital -P: publish every port listed in the Dockerfile's EXPOSE
                             #             instructions, each to a random host port

EXPOSE alone is just documentation. -P (capital, distinct from lowercase -p) actually turns each EXPOSEd port into a real, published mapping, each to a random host port. It reads the image's own declared EXPOSE list instead of requiring you to specify each mapping manually.

An internal-only service — a database only ever accessed by other containers on the same network — should typically not publish a port at all, to reduce its exposed attack surface. Only the actual entry-point service usually needs one reaching the host or internet.

The embedded DNS server

docker network create my-network
docker run -d --network my-network --name db postgres:16
docker run -d --network my-network --name api myapi:1.0
docker exec api cat /etc/resolv.conf
# nameserver 127.0.0.11

Every container on a user-defined network automatically has its DNS resolver pointed at 127.0.0.11 — Docker's own embedded DNS server, running as part of the Docker daemon. Application code inside api can just connect to db by name:

// Inside the "api" container
const client = new Client({ host: 'db', port: 5432 });   // resolves via Docker's embedded DNS

Why this survives container restarts, even with a changing IP

docker restart db
docker inspect db --format='{{.NetworkSettings.Networks.my_network.IPAddress}}'
# a possibly DIFFERENT IP than before the restart

api connects to db by name, not by hardcoded IP, so a restart is transparent to it. The next DNS lookup for db just returns whatever db's current IP is. The application code never needs to know the IP changed.

This is the same problem Kubernetes Services and CoreDNS solve at cluster scale: ephemeral container IPs shouldn't be hardcoded into configuration, so a name-based DNS layer resolves to the current IP instead. The idea is identical — Docker just applies it to one host instead of a whole cluster.

Network aliases — additional names for the same container

docker run -d --network my-network --network-alias database --name db postgres:16
const client = new Client({ host: 'database' });   // resolves to the "db" container, via its alias

A container can have one or more extra aliases beyond its own name. This is useful when you want your app config to use a generic, stable name like database, instead of a specific container name that might change between deployments or environments.

Compose does all of this automatically

services:
  db:
    image: postgres:16
  api:
    image: myapi:1.0
    environment:
      - DB_HOST=db     # "db" resolves automatically -- Compose creates a user-defined
                         #  network and names containers after their service name

Docker Compose services reference each other by service name with zero manual network setup. Compose automatically creates a user-defined bridge network for the whole project and names each container after its service. The DNS resolution above just works out of the box.

What this DNS mechanism doesn't do

Docker's embedded DNS only resolves names within the same user-defined network. A container on a different Docker network can't see this DNS namespace at all.

There's also no automatic load balancing built in. If multiple containers share a name or alias, or you need traffic balanced across several instances of a service, that's something Compose, Swarm, or Kubernetes provides — not the base DNS mechanism.

Hardcoding a container's IP instead of its name brings back exactly the fragility this mechanism exists to remove. Flag it in code review on sight.

This question builds on the earlier default-bridge question. Here's a fuller comparison, focused on what user-defined networks add.

Capability comparison

Default bridgeUser-defined bridge
Containers reachable by IPYesYes
Containers reachable by name (DNS)NoYes
Can create multiple isolated networksNo (one shared default)Yes (create as many as needed)
Dynamically connect/disconnect a running containerLimitedYes, freely
Used automatically by Docker ComposeNoYes (Compose creates one per project)

Isolation and segmentation via multiple networks

docker network create frontend-net
docker network create backend-net

docker run -d --network frontend-net --name web nginx
docker run -d --network backend-net --name db postgres:16
docker run -d --network frontend-net --name api myapi:1.0
docker network connect backend-net api    # api bridges BOTH networks

web has no path to reach db at all — they're on different networks with no shared connectivity. Only api, deliberately connected to both frontend-net and backend-net, can bridge between them. This is a real, deliberate isolation tool: you decide which containers can even see which others, instead of everything sharing one flat network like the default bridge does.

Dynamic network membership

docker network connect my-network already-running-container
docker network disconnect my-network already-running-container

A container can join or leave a user-defined network while it's still running, with no need to stop and recreate it. This is useful for reconfiguring connectivity on the fly — for example, temporarily attaching a debugging container to a production network segment for a one-off check, then detaching it afterward. The default bridge doesn't offer this flexibility.

Why Compose's automatic behavior reinforces this as standard practice

services:
  web:
    image: nginx
  api:
    image: myapi:1.0
docker compose up
# Creates a network named "<project-name>_default" automatically,
# and attaches both "web" and "api" to it -- giving them DNS-based
# discovery of each other by service name with ZERO explicit
# network configuration required.

Compose is by far the most common way people run multi-container Docker setups locally. It does this automatically, by default, for every project — a strong sign the ecosystem has settled on "always use a user-defined network, never the default bridge" as the correct baseline.