Docker Compose Networking Deep Dive: Bridging, Host, and Overlay Networks Explained
We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.
Docker networking is one of those topics that most people muddle through by copying Compose files from the internet, and it works — until it doesn't. I've spent years debugging mysterious "connection refused" errors, accidentally exposing services to the host network, and wondering why two containers on the same machine couldn't talk to each other. In this tutorial I'll walk through all three major Docker network modes — bridge, host, and overlay — with real Compose snippets, concrete use cases, and the gotchas that burned me so they don't burn you.
Why Docker Networking Matters for Self-Hosters
When you run a single container, networking feels trivial. When you run fifteen containers — a reverse proxy, a database, a photo library, a password manager, and a monitoring stack — network isolation becomes critical for both security and sanity. Docker's network model lets you decide exactly which containers can see each other, which ports reach the host, and how services spread across multiple machines communicate.
There are four built-in Docker network drivers: bridge, host, none, and overlay. For almost every homelab and self-hosted scenario, you'll use bridge most of the time, dip into host occasionally for performance-sensitive workloads, and reach for overlay only when you're spanning multiple Docker hosts. Let's go through each one properly.
Bridge Networks: The Default and the Right Choice 90% of the Time
A bridge network creates a virtual switch inside your Linux host. Containers attached to the same bridge can reach each other by service name (DNS resolution is built in), and they're isolated from containers on other bridges. When you run docker compose up without specifying any networks, Docker creates a default bridge called <project>_default automatically. That's fine for quick testing, but I always define networks explicitly — it gives you control over which services are grouped together.
Here's a real-world example: a Vaultwarden password manager stack with a dedicated backend network so the database is never reachable from anything outside this Compose project:
cat > docker-compose.yml << 'EOF'
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
environment:
WEBSOCKET_ENABLED: "true"
SIGNUPS_ALLOWED: "false"
volumes:
- vaultwarden_data:/data
ports:
- "127.0.0.1:8080:80"
- "127.0.0.1:3012:3012"
networks:
- frontend
- backend
db:
image: postgres:16-alpine
container_name: vaultwarden_db
restart: unless-stopped
environment:
POSTGRES_DB: vaultwarden
POSTGRES_USER: vwuser
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
volumes:
- db_data:/var/lib/postgresql/data
secrets:
- db_password
networks:
- backend # db is NOT on frontend — it cannot be reached from outside
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # no outbound internet from this network
volumes:
vaultwarden_data:
db_data:
secrets:
db_password:
file: ./secrets/db_password.txt
EOF
Notice two things. First, the db service is only on the backend network, so it has no path to the internet and no path to any other Compose project. Second, I'm binding the published ports to 127.0.0.1 rather than 0.0.0.0 — this means only a reverse proxy running on the same host can reach port 8080. If you bind to 0.0.0.0, that port is open on every network interface including your public IP. That's a mistake I see constantly.
internal: true flag on a bridge network cuts off outbound internet access for containers on that network. If your database container needs to pull extensions or connect to an external service at startup, this will silently fail. Reserve internal: true for networks where you're certain the container needs zero external connectivity.Custom Bridge Networks vs. the Default Bridge
Docker's legacy default bridge (the one named bridge in docker network ls) does not support automatic DNS resolution between containers. The named bridges that Compose creates do. This is why running a container with docker run --network bridge and then trying to reach another container by name fails, while the same setup in a Compose file works perfectly. Always use named networks in your Compose files.
You can also inspect what's going on at any time:
# List all networks on the host
docker network ls
# Inspect a specific network and see which containers are attached
docker network inspect vaultwarden_backend
# Check a container's network configuration
docker inspect vaultwarden --format '{{json .NetworkSettings.Networks}}' | jq
# Test DNS resolution from inside a container
docker exec -it vaultwarden nslookup db
# Test connectivity (install curl first if needed)
docker exec -it vaultwarden curl -v http://db:5432
That nslookup db trick is invaluable when you're debugging why container A can't reach container B. If DNS resolves but the connection is refused, you have a port or application issue. If DNS fails entirely, the containers aren't on a shared network.
Host Network Mode: When Bridge Overhead Actually Matters
With host networking, the container shares the host's network namespace directly. There's no NAT, no virtual interface, no port mapping — the container binds directly to the host's IP. This gives you the best possible network performance, which matters for things like Ollama serving LLM inference (lots of data moving quickly) or a high-throughput metrics scraper.
The tradeoff is that you lose isolation. Any port the container opens is immediately open on the host. I use host networking for exactly two scenarios in my homelab: running AdGuard Home (which needs to bind to port 53 on the host's real interface) and running latency-sensitive workloads where even a few milliseconds of NAT overhead matters.
cat > docker-compose-adguard.yml << 'EOF'
services:
adguardhome:
image: adguard/adguardhome:latest
container_name: adguardhome
restart: unless-stopped
network_mode: host # must bind to port 53 on the real interface
volumes:
- adguard_work:/opt/adguardhome/work
- adguard_conf:/opt/adguardhome/conf
cap_add:
- NET_BIND_SERVICE # allow binding to ports below 1024
EOF
network_mode: host, the ports: key in your Compose file is completely ignored — Docker won't warn you about this, it will just silently have no effect. Remove any ports: entries from host-networked services to avoid confusion.Host mode is also Linux-only. On Docker Desktop for Mac or Windows, host networking doesn't actually give you the host's network — it gives you the VM's network. This is a common source of confusion when testing locally on a Mac and deploying to a Linux VPS.
Overlay Networks: Multi-Host Networking with Docker Swarm
Overlay networks are for when you have multiple Docker hosts and you want containers on different machines to communicate as if they were on the same local network. This requires Docker Swarm mode to be initialized — overlay networks are a Swarm primitive. If you're running a single VPS or a single homelab box, you don't need this.
That said, if you're scaling out to two or three Hetzner VPS nodes — which is surprisingly affordable — overlay networks are the cleanest way to connect workloads across them without setting up a full WireGuard mesh manually. Here's how to set one up:
# On your first (manager) node
docker swarm init --advertise-addr <MANAGER_IP>
# Copy the join token output, then on each worker node run:
# docker swarm join --token <TOKEN> <MANAGER_IP>:2377
# Create an attachable overlay network (attachable lets standalone containers use it too)
docker network create \
--driver overlay \
--attachable \
--subnet 10.20.0.0/16 \
homelab-overlay
# Deploy a stack using this overlay network
cat > stack-monitoring.yml << 'EOF'
services:
prometheus:
image: prom/prometheus:latest
networks:
- homelab-overlay
deploy:
placement:
constraints:
- node.role == manager
node-exporter:
image: prom/node-exporter:latest
networks:
- homelab-overlay
deploy:
mode: global # runs on every node in the swarm
networks:
homelab-overlay:
external: true
name: homelab-overlay
EOF
docker stack deploy -c stack-monitoring.yml monitoring
The --attachable flag is essential if you want to run docker run --network homelab-overlay or attach individual Compose services to the overlay. Without it, only Swarm-managed services can join the network.
Connecting Services Across Separate Compose Projects
One pattern I use constantly is running Traefik as its own Compose project and having all other services join Traefik's network. This avoids putting everything in one monolithic Compose file. The key is marking the shared network as external in the consumer project:
# In your traefik docker-compose.yml, create the shared network:
networks:
traefik_public:
driver: bridge
name: traefik_public # explicit name so other projects can reference it
# In any other project that needs reverse proxy:
networks:
traefik_public:
external: true # tells Compose not to create it, just attach to existing
services:
myapp:
image: myapp:latest
networks:
- traefik_public
- internal
labels:
- "traefik.enable=true"
- "traefik.http.routers.myapp.rule=Host(`myapp.example.com`)"
This pattern scales beautifully — I have Traefik running permanently, and I can spin individual service stacks up and down without touching the reverse proxy config. Traefik discovers the containers automatically through the Docker socket.
Network Security Fundamentals
A few rules I apply to every Compose project: never publish ports directly to 0.0.0.0 if a reverse proxy is handling external access — use 127.0.0.1:PORT:PORT instead. Use internal: true for database and cache networks. Give every network a meaningful name with an explicit name: key so the auto-generated project-prefix names don't make docker network ls output unreadable. And run docker network prune periodically to clean up orphaned networks from old projects.
If you want to test your network isolation, docker exec -it <container> ping <other-container> is a quick sanity check, though you may need to apt-get install -y iputils-ping in Alpine-based images first.
Deploying This in Production: VPS Recommendations
All of these networking patterns work identically on a local homelab server and on a cloud VPS. I've been running multi-Compose setups on Hetzner and DigitalOcean Droplets for years. DigitalOcean's Droplets are particularly good for this kind of work because their private networking feature gives you a second interface for inter-node communication without any overlay overhead — useful if you go the multi-host route.
Build and deploy apps from code to production in just a few clicks with DigitalOcean
Get dependable uptime with DigitalOcean's 99.99% SLA and predictable pricing on Droplets.
Putting It All Together
Here's my decision framework when I'm architecting a new Compose project: start with bridge networks and create at least a frontend (services that talk to the reverse proxy) and backend (databases, caches, internal APIs). Use internal: true on backend networks. Bind published ports to 127.0.0.1. Only reach for host mode when you have a genuine reason — port 53, raw socket access, or a demonstrable performance requirement. Only reach for overlay when you're actually spanning multiple Docker hosts.
The next step from here is pairing this network layout with Traefik or Caddy as your reverse proxy — that's where the traefik_public external network pattern really shines. Check out my Traefik setup tutorial for the full reverse proxy config that slots into this networking model, and if you're moving workloads to a VPS, DigitalOcean gives you a clean Ubuntu or Debian environment where everything in this guide works exactly as written.
Discussion