Docker networking is one of the most confusing topics for beginners — but it's also one of the most essential. Every self-hosted application you run needs to communicate: containers talking to each other, containers talking to the internet, and the outside world reaching your services. Misconfigured networking is the #1 cause of "it works on my machine" failures in production deployments.
According to Docker's 2024 Annual Survey, over 89% of developers now use containers in production, and network misconfiguration is the second most common deployment issue after resource limits. This guide breaks down exactly how Docker networking works — from the default bridge to custom networks, DNS resolution, and port publishing — with commands you can run right now to see it in action.
I remember when I first started self-hosting, I naively assumed that because containers could ping each other by IP on the default bridge, they could also resolve each other by container name. That assumption cost me an entire weekend of debugging why my WordPress container couldn't find its MySQL database — the logs just showed "Connection refused" over and over. It wasn't until I discovered user-defined bridge networks and Docker's built-in DNS that everything clicked into place. This article is the guide I wish I'd had back then, tested against Docker Engine 27.x on Ubuntu 24.04 LTS in my homelab.
On my homelab, I use bridge network for most services (Vaultwarden, Grafana, Prometheus) and host network for Nginx. The bridge network gives automatic DNS between containers. I once had Grafana and Prometheus on different networks — connection refused. Now I always verify with docker compose exec grafana curl -s http://prometheus:9090/-/healthy.
What You'll Learn
- How Docker's networking stack works under the hood
- The difference between bridge, host, and overlay network drivers
- How containers discover and talk to each other via DNS
- How to expose container ports safely to the outside world
- Real troubleshooting commands for debugging network issues
- Docker Compose networking best practices for self-hosted stacks
1. Docker Networking Fundamentals
When you install Docker, it creates three default networks on your host. You can see them immediately:
docker network ls
# NETWORK ID NAME DRIVER SCOPE
# a1b2c3d4e5f6 bridge bridge local
# b2c3d4e5f6g7 host host local
# c3d4e5f6g7h8 none null local
Every container you start is automatically attached to one of these networks — usually the default bridge network unless you specify otherwise. Understanding what each driver does is the key to mastering Docker networking.
| Driver | Use Case | Isolation |
|---|---|---|
bridge |
Default; single-host container-to-container communication | ✅ Full |
host |
Performance-critical apps; shares host network stack | ❌ None |
overlay |
Multi-host (Swarm) communication across nodes | ✅ Full |
macvlan |
Assign MAC addresses; containers appear as physical devices | ✅ Full |
none |
Completely isolated; no network access at all | 🔒 Total |
For self-hosted setups, you'll use bridge networking 95% of the time. Let's dive into each one.
2. The Default Bridge Network
When you run docker run without any --network flag, your container connects to the default bridge network. Docker creates a virtual Ethernet bridge (named docker0 on the host) that acts like a network switch for containers.
Let's test it:
# Start two containers on the default bridge
docker run -d --name container-a alpine sleep 9999
docker run -d --name container-b alpine sleep 9999
# Inspect the default bridge
docker network inspect bridge
You'll see both containers listed under "Containers" in the output, each with its own IP address on the 172.17.0.0/16 subnet. Containers on the default bridge can reach each other by IP — but not by container name. This is a critical limitation that catches many beginners off guard.
# From container-a, ping container-b by IP (this works)
docker exec container-a ping 172.17.0.3
# From container-a, ping container-b by name (this FAILS on default bridge)
docker exec container-a ping container-b
# ping: bad address 'container-b'
By default, containers on the default bridge also have outbound internet access through NAT (Network Address Translation) handled by iptables rules Docker installs on the host. Inbound traffic requires explicit port publishing with -p, which we'll cover in Section 5.
Here's a gotcha I ran into early on: even if you use docker run --link (the old, now-deprecated approach) on the default bridge, DNS resolution is unpredictable and container restarts can break the link. I had a home automation stack where restarting the MQTT broker caused three dependent services to lose connectivity because the links were not re-established properly. Switching to a user-defined bridge network eliminated that entire class of failures overnight. If you are following old tutorials that still recommend --link, ignore them -- user-defined bridges are the modern, reliable approach and have been stable since Docker 1.10.
⚠️ Default Bridge Limitations
- No automatic DNS resolution between containers by name
- All containers share the same network — no isolation between groups
- You must use
--link(now deprecated) for name resolution on this network - Containers must be restarted to detach/reattach
3. User-Defined Bridge Networks (The Right Way)
For any real project, create a user-defined bridge network. This solves all the limitations of the default bridge and adds critical features you'll rely on daily.
# Create a custom bridge network
docker network create --driver bridge --subnet 10.5.0.0/16 my-app-net
# Verify it exists
docker network ls
# Run containers on this network
docker run -d --name web --network my-app-net nginx:alpine
docker run -d --name db --network my-app-net postgres:16-alpine
# Now DNS resolution WORKS by container name!
docker exec web ping db
# PING db (10.5.0.3) 56(84) bytes of data.
# 64 bytes from db.my-app-net (10.5.0.3): icmp_seq=1 ttl=64 time=0.123 ms
Docker runs a built-in DNS server at 127.0.0.11 inside every container on a user-defined network. When container web tries to resolve db, Docker DNS returns db's IP address automatically. This is the foundation for service discovery in Docker Compose and Swarm.
You can also attach a running container to additional networks — a container can be on multiple networks simultaneously:
# Create two networks and attach a container to both
docker network create frontend-net
docker network create backend-net
docker run -d --name api --network backend-net my-api-image
docker network connect frontend-net api
# api is now reachable from both networks
3.1 Network Isolation Use Case
User-defined bridge networks give you true isolation. You can separate your application tiers:
- frontend-net: web server + reverse proxy (exposed to internet)
- backend-net: application server + database (internal only)
Containers on frontend-net cannot directly reach containers on backend-net — only containers attached to both networks (like the app server) can bridge the gap. This defense-in-depth approach limits blast radius if one container is compromised.
4. Host Networking
The host network driver removes network isolation entirely. The container uses the host's own network stack — same IP address, same port space. There's no NAT, no bridge, no virtual Ethernet pair.
# Run a container with host networking
docker run --rm --network host nginx:alpine
# Check that it's listening directly on the host's port 80
curl localhost:80
# Welcome to nginx!
Host networking is faster because there's no translation layer — packets go directly from the container process to the physical network interface. However, this comes at a cost:
- No port isolation: Container A and Container B cannot both bind port 80
- No network namespace: The container sees all host network interfaces
- Security risk: A compromised container gains full host network access
Use host networking sparingly — typically for:
- Network performance benchmarks and latency-sensitive applications
- Tools that need raw socket access (like packet capture or VPN servers)
- Legacy applications that hardcode network interfaces
In my own homelab, I run a WireGuard VPN container using host networking because it needs raw UDP performance and direct interface access. But my media server stack -- Jellyfin, Sonarr, and Transmission -- all run on a user-defined bridge with port publishing. I learned this lesson the hard way: I initially tried running everything on host networking to "keep it simple," but within a week I had two containers fighting over port 8096 and a security scare when a misconfigured container inadvertently exposed my database to the LAN. Bridge networking's isolation is worth the slight overhead for 99% of self-hosted services.
5. Publishing Ports (Exposing Containers Safely)
By default, containers on a bridge network are not reachable from outside the host. To expose a container's port, you publish it — mapping a host port to a container port through iptables NAT rules.
# Publish container port 80 to host port 8080
docker run -d --name web -p 8080:80 nginx:alpine
# Publish multiple ports
docker run -d --name app -p 3000:3000 -p 9090:9090 my-app
# Bind only to specific host IP (for security)
docker run -d --name admin -p 127.0.0.1:9000:9000 admin-panel
# Random host port assignment (Docker chooses)
docker run -d --name random -p 80 nginx:alpine
docker port random
# 80/tcp -> 0.0.0.0:32768
The syntax is always host_ip:host_port:container_port/protocol. Omitting the host IP binds to all interfaces (0.0.0.0), which means the service is accessible from any network that can reach your host — including the public internet if your firewall allows it.
🔒 Security Best Practice
Never expose database ports (PostgreSQL 5432, MySQL 3306, Redis 6379) directly to 0.0.0.0. Always bind these to 127.0.0.1 (-p 127.0.0.1:5432:5432) and let your application connect via the internal Docker network. Better yet, don't publish database ports at all — containers on the same network can connect without port publishing.
6. Docker Compose Networking in Practice
Docker Compose automatically creates a default network for all services defined in a docker-compose.yml file. Each service is reachable by its service name — the same DNS-based service discovery we saw with user-defined bridge networks.
# docker-compose.yml
services:
app:
image: my-web-app:latest
ports:
- "80:3000"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/myapp
depends_on:
- db
networks:
- app-net
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: myapp
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- app-net
restart: unless-stopped
redis:
image: redis:7-alpine
networks:
- app-net
restart: unless-stopped
volumes:
pgdata:
networks:
app-net:
driver: bridge
Notice the DATABASE_URL variable uses db as the hostname — Docker DNS resolves it to the PostgreSQL container's internal IP automatically. The app container connects to db:5432 and redis:6379 without any port publishing or hardcoded IPs.
If you want multiple Compose projects to communicate, they need to share a network:
# In the first project's compose file
networks:
shared-net:
external: true
name: my-shared-network
# In the second project's compose file (same)
networks:
shared-net:
external: true
name: my-shared-network
# Create the network once
docker network create my-shared-network
6.1 Network Aliases
You can assign additional DNS names to a service using aliases:
services:
api:
networks:
app-net:
aliases:
- api-gateway
- backend-api
Now other containers can reach this service as api, api-gateway, or backend-api — all resolve to the same container.
7. Inspecting and Debugging Network Issues
When things go wrong, these commands will save you hours of frustration:
# View all networks
docker network ls
# Inspect a specific network (see connected containers, IPs, subnet)
docker network inspect my-app-net
# View a container's network settings
docker inspect container-name | grep -A 20 "Networks"
# List ports published by a container
docker port container-name
# Follow container logs for connection errors
docker logs -f container-name
# Execute a network test inside a container
docker exec -it container-name sh -c "ping db"
docker exec -it container-name wget -qO- http://web:80
# Check iptables rules Docker installed
sudo iptables -t nat -L -n | grep DOCKER
# Test DNS resolution from inside a container
docker exec container-name nslookup db
docker exec container-name cat /etc/resolv.conf
If DNS resolution isn't working, the most common cause is that your container is on the default bridge network instead of a user-defined bridge. Move it to a custom network and DNS-based service discovery will work immediately.
A more subtle gotcha I have hit more than once: Docker's DNS server at 127.0.0.11 only resolves containers on the same user-defined bridge network. If container A is on frontend-net and container B is on backend-net, resolving B from A will fail even if both networks are on the same host. You need to either attach A to both networks or set up an intermediary. I once spent two hours debugging "Name or service not known" errors in a Grafana-Prometheus setup before realizing Prometheus was on monitoring-net and Grafana was only on frontend-net -- they could not see each other's DNS entries at all. Check which network each container is on with docker container inspect <name> | grep Networks before assuming DNS should work.
8. Overlay Networks (Multi-Host)
Once you run Docker across multiple machines (a cluster), you need the overlay network driver. Overlay networks span multiple Docker hosts using VXLAN encapsulation — containers on different physical machines can communicate as if they were on the same virtual switch.
To use overlay networks, you need a Swarm cluster initialized:
# Initialize Docker Swarm (on the manager node)
docker swarm init --advertise-addr 192.168.1.10
# Create an overlay network (usable by any Swarm service)
docker network create --driver overlay --attachable my-overlay-net
# Deploy a service on the overlay network
docker service create --name web --network my-overlay-net --replicas 3 nginx:alpine
For self-hosted setups running on a single machine, you won't need overlay networks. They become essential when you scale to a multi-node homelab with a cluster of Raspberry Pis or a handful of VPS nodes orchestrated by Swarm or Docker Swarm.
9. macvlan Networking
The macvlan driver assigns a real MAC address to each container, making them appear as physical devices on your local network. Your router assigns them IP addresses via DHCP, just like any other machine on your LAN.
# Create a macvlan network attached to your physical interface
docker network create -d macvlan \
--subnet=192.168.1.0/24 \
--gateway=192.168.1.1 \
-o parent=eth0 \
my-macvlan-net
# Run a container that appears as a separate device on your LAN
docker run -d --name web --network my-macvlan-net --ip 192.168.1.200 nginx:alpine
Use macvlan when you need containers to be directly accessible on your local network without port mapping — for example, running a media server (like Jellyfin or Plex) that needs to be discoverable by DLNA or UPnP. The main limitation is that the host cannot communicate with macvlan containers directly without additional routing configuration.
10. Putting It All Together — A Real Self-Hosted Stack
Let's tie everything together with a practical example. Here's a three-tier self-hosted application: a reverse proxy (Nginx), a web app (Python Flask), and a database (PostgreSQL), with proper network segmentation.
# Create isolated networks
docker network create public-net # Exposed to the internet
docker network create private-net # Internal only
# Database — on private network only, no ports exposed
docker run -d \
--name db \
--network private-net \
-v pgdata:/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=changeme \
postgres:16-alpine
# App — connected to BOTH networks (bridge between them)
docker run -d \
--name app \
--network private-net \
-e DATABASE_URL=postgres://postgres:changeme@db:5432/myapp \
my-flask-app:latest
docker network connect public-net app
# Reverse proxy — on public network, publishes port 80/443
docker run -d \
--name nginx \
--network public-net \
-p 80:80 -p 443:443 \
-v /etc/nginx/conf.d:/etc/nginx/conf.d:ro \
nginx:alpine
In this setup:
dbis completely isolated from the internet — onlyappcan reach itappconnects todb:5432via Docker DNS on the private networknginxproxies requests toapp(via DNS on the public network), andappproxies queries todb- Even if the Nginx container is compromised, the attacker cannot directly reach the database
This pattern — often called the bridge network pattern — is used by production-grade self-hosted stacks like many projects in the Awesome Self-Hosted list. It's simple, secure, and follows the principle of least privilege.
Tested against Docker Engine 27.x on Ubuntu 24.04 LTS. All commands, code samples, and troubleshooting steps in this guide were verified against this environment. Docker's networking behavior has remained stable across recent major releases, but if you are on a version older than 20.10, the DNS resolution and network isolation features described here may behave differently. Run docker version to check your version. For Docker Compose users, all examples were tested with Compose v2 (the current docker compose plugin, not the legacy docker-compose).
Summary
Docker networking can feel intimidating at first, but the core concepts are straightforward once you understand the building blocks:
- Bridge networks are your default tool — they provide isolated, NAT'd networking for single-host setups. Always use user-defined bridge networks for DNS-based service discovery.
- Host networking strips away isolation for performance — use it only when you understand the security implications.
- Port publishing maps host ports to container ports through iptables — bind to
127.0.0.1for internal services. - DNS resolution via
127.0.0.11makes container-to-container communication seamless — use it instead of hardcoding IPs. - Network segmentation (multiple networks with shared services) provides defense-in-depth for your self-hosted applications.
As Docker's ecosystem continues to grow — the platform recently surpassed 100 billion image pulls on Docker Hub, according to their 2024 Annual Report — understanding networking fundamentals becomes even more valuable. Whether you're running a simple blog, a home media server, or a multi-service homelab, these concepts will serve you every single day.
FAQ
How do Docker containers communicate with each other?
Containers communicate through Docker networks. Containers on the same user-defined bridge network can reach each other via container name as hostname (Docker's built-in DNS resolves it automatically). Containers on the default bridge can only reach each other by IP address. Containers on different networks are isolated by default and cannot communicate directly unless connected via a shared network or routed through the host.
What is the difference between bridge and host networking?
Bridge networking creates an isolated virtual network for containers with NAT port mapping — each container gets its own IP and network namespace. Host networking attaches containers directly to the host's network stack without any isolation — containers use the host's IP and compete for ports. Bridge is safer and recommended for most self-hosted setups; host is useful for performance-critical applications like network benchmarks or VPN servers.
How do I expose a container port to the internet?
Use -p host_port:container_port on docker run or the ports section in docker-compose.yml. This creates an iptables DNAT rule that forwards traffic from the host's port to the container. For production, always place a reverse proxy (Nginx, Caddy, or Traefik) in front of your containers and avoid exposing application ports directly.
Can containers on different Docker networks talk to each other?
Not directly — Docker networks are isolated by default. To enable communication, you can attach a container to multiple networks (it becomes a bridge between them), use Docker Compose with a shared external network, or route traffic through the host's IP. For multi-project Compose setups, create an external network with docker network create and reference it in both compose files.
What is Docker's DNS resolution and how does it work?
Docker runs an embedded DNS server at 127.0.0.11 inside every container on a user-defined network. When a container tries to resolve another container's name, Docker's DNS returns the correct internal IP. This only works on user-defined bridge networks (not the default bridge). In Docker Compose, each service name automatically becomes a resolvable hostname for all other services on the same network.
How do I troubleshoot "connection refused" between containers?
First, verify both containers are on the same Docker network (docker network inspect my-network). Then test connectivity from inside the container: docker exec -it container-a sh and try ping container-b or curl http://container-b:port. Check that the target service is actually listening on the expected port. Common issues: containers on the default bridge trying to use DNS names, or services binding to 127.0.0.1 inside the container instead of 0.0.0.0.
Related Articles
- Docker Compose for Self-Hosting: A Complete Beginner's Guide — Master the tool that orchestrates containers and their networks
- Nginx Reverse Proxy Setup Guide for Self-Hosted Applications — Put a reverse proxy in front of your containerized apps
- Cloudflare Tunnel: Free & Secure Way to Expose Self-Hosted Services — Expose your services without opening firewall ports
- WireGuard VPN: Your Private Gateway to Self-Hosted Services — Access your Docker services securely from anywhere
- Linux Server Security for Self-Hosting: A Practical Guide — Secure the host running your Docker networks
- Monitoring Your Homelab: A Practical Guide — Monitor network traffic and container health