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 — and, on a user-defined bridge specifically, by container name via DNS (see the DNS question) — and reach the outside world via NAT through the host. This is the right default for the overwhelming majority of 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 — no isolation, no virtual interface, no port mapping needed (a container binding to port 80 with --network host is genuinely binding to port 80 on the host itself, directly). This eliminates a layer of network translation overhead, which occasionally matters for latency-sensitive or high-throughput networking use cases. But it sacrifices the isolation benefit entirely. Port conflicts between containers become a real, direct concern — two containers can't both bind the same host port — and 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 internal loopback (127.0.0.1). Useful for workloads that genuinely need zero network access, either for isolation/security reasons (a batch job processing only local files, with no legitimate reason to reach the network at all) or as 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, letting containers on different physical or virtual machines communicate as if they were on the same local network. This is specifically what Docker Swarm mode (see the production topic) 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

Assigns each container its own MAC address and effectively makes it appear as a distinct physical device directly on the host's physical network, bypassing Docker's usual NAT-based bridge networking entirely. Used for niche cases where legacy applications or network monitoring tools expect containers to look like real, individually-addressable devices on the network, rather than being hidden behind the host's single IP via NAT.

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 specifically requires containers to appear as distinct physical network devices

For the large majority of everyday Docker usage — a web application talking to a database, a handful of services communicating with each other on one machine — a user-defined bridge network (not even the default bridge network, for reasons covered in that question) is the right choice, and the most common one by a wide margin.

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 outside 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 specifically, containers cannot resolve each other by container name. You'd have to look up api's current IP address manually (docker inspect api) and hardcode that IP into web's configuration. This is fragile, since 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 explicitly via docker network create) includes Docker's embedded DNS server automatically. This lets containers on that same network resolve each other by container name, or by any --network-alias assigned. This is the single biggest practical reason the default bridge is discouraged for real multi-container applications.

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, giving you a deliberate segmentation tool — for example, a "frontend" network and a "backend" network, with only specific containers bridging both — that the single, shared default bridge doesn't provide.
  • Dynamic reconnection — a running container can be connected to or disconnected from a user-defined network on the fly (docker network connect/disconnect), without needing to restart it; the default bridge is more rigid about this.
  • This is exactly what Docker Compose does automatically. Every docker-compose.yml project automatically gets its own user-defined bridge network by default. This is precisely why Compose services can reference each other by their service name out of the box (see the Compose topic), without any manual network setup at all.

The default bridge is really only still relevant for the simplest, single-container, no-inter-container-communication case, or for historical/legacy setups. Anything with more than one container talking to each other should get 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, 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 (visible via docker port <container>). Specifying a host IP restricts which network interface the mapping is bound to — useful for only exposing a port locally 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. Specifically, this is a DNAT (Destination NAT) rule that rewrites the destination of incoming packets, redirecting traffic arriving at 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 conceptually similar to how Kubernetes's kube-proxy uses iptables (or IPVS/eBPF) rules to route Service traffic to backing Pods (see that stack's kube-proxy question). Docker's port publishing solves an analogous problem — getting external traffic to the right internal destination — using 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 at all (no -p flag was used). But web, being on the same Docker network, can still reach api:3000 directly. This is because containers on the same network can always reach each other's container-internal ports without any publishing needed. Port publishing (-p) is specifically about making a container's port reachable from outside the Docker network entirely — from the host, or from the internet if the host itself is internet-facing. It is not a requirement 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

Recall from the Dockerfile topic that EXPOSE alone is just documentation or metadata. -P (capital, distinct from lowercase -p) is what actually turns each EXPOSEd port into a real, published mapping, to a randomly chosen host port each. It reads the image's own declared EXPOSE list, rather than 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, reducing 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 attached to 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's networking implementation. Application code inside api can simply 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

Because api connects to db by name, not by hardcoded IP, this restart is transparent to api. The next DNS lookup for db simply returns whatever db's current IP is. The application code never needed to know or care that the underlying IP changed. This is precisely the same fundamental need Kubernetes Services and CoreDNS solve at cluster scale (see that stack's Service and DNS questions). The problem — ephemeral container IPs shouldn't be hardcoded into configuration — and the solution shape — a name-based DNS layer resolving to current IPs — are conceptually identical. They just operate at the scale of one Docker host versus an entire 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 be given one or more additional aliases beyond its own container name. This is useful when you want application configuration to reference a more generic, stable name (database) that isn't tied to the specific container's actual name, since that name 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

This is exactly why Docker Compose services can 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. So the DNS-based resolution described 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 completely different Docker network has no visibility into this DNS namespace at all. There's also no automatic load balancing built into this basic mechanism the way a Kubernetes Service provides. If multiple containers share the same name or alias, or you need actual load-balanced traffic distribution across multiple instances of a service, that's a capability Docker Compose, Swarm, or a real orchestrator like Kubernetes provides — not something the base DNS mechanism alone does for you. Hardcoding a container's IP instead of its name reintroduces exactly the fragility this mechanism exists to eliminate. It should be flagged in code review on sight.

This question directly builds on the earlier default-bridge question — here's a fuller comparison, focused specifically 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

This setup gives web no path to reach db directly at all (different networks, no shared connectivity) — only api, deliberately connected to both frontend-net and backend-net, can bridge between them. This is a genuine, deliberate isolation and segmentation tool. You can architect which containers are allowed to even see which others, rather than everything sharing one flat, undifferentiated network the way the single default bridge works.

Dynamic network membership

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

A container can be attached to or detached from a user-defined network while it's still running, without needing 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 diagnostic session, then detaching it afterward. It's a flexibility the default bridge doesn't offer in the same way.

Why Compose's automatic behavior reinforces this as the 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 the most common way people run multi-container Docker setups locally, by a wide margin. The fact that it does this automatically, by default, for every project, reflects just how strongly the ecosystem has converged on "always use a user-defined network, never the default bridge" as the correct baseline practice.