Docker Compose Networking Explained
Two containers on the same host could reach each other, and neither one was supposed to. Both had landed on Docker Compose's default bridge network — a network that doesn't ask permission before connecting things.

Two containers on the same host could reach each other over the network, and neither one was supposed to be able to. Nobody had written a firewall rule allowing it — it just worked, because both containers happened to land on Docker Compose's default bridge network, and that network doesn't ask permission before connecting things.
TL;DR
- Every
docker compose upcreates its own bridge network automatically; every service in that file joins it and can reach every other service by name. - Two separate compose projects are isolated from each other by default — the danger is inside one project, not between them.
internal: trueremoves outbound internet access from a network entirely — the right call for a database.external: truelets two independent compose stacks share one network on purpose, without either one destroying it ondocker compose down.- This site's homelab gives every stack its own internal network, plus one shared external network that only the reverse proxy joins.
KEY-STAT: 1 — Shared external network is all it takes to let a single reverse proxy front every isolated stack on this homelab, without flattening them onto one network
The default: one bridge network per compose project
Run docker compose up with no networks: block at all, and Compose creates a bridge network named after the project directory and puts every service in that file onto it. Containers reach each other by service name — Docker's embedded DNS resolves vaultwarden to that container's internal IP without anyone hardcoding an address. Two separate docker compose projects on the same host get two separate default networks and cannot see each other unless you explicitly connect them. That isolation is doing more work than most people realize; the danger isn't between stacks, it's inside one stack where every service can reach every other service whether it needs to or not.
Why that default breaks down with a reverse proxy
A reverse proxy like Caddy or Traefik needs to reach containers that live in other compose projects — Jellyfin's stack, Vaultwarden's stack, n8n's stack, each defined in its own file as covered in the reverse proxy comparison. Putting everything on one flat network so the proxy can reach it all defeats the isolation Compose gives you for free — now Vaultwarden and n8n can talk to each other too, for no reason anyone chose deliberately.
This site's real topology: one internal network per stack, one shared network for the proxy
The actual layout on this homelab, first built following the homelab beginner's guide: each stack — media, credentials, automation — gets its own private network that never leaves that compose file. Caddy joins a second, separate network that every stack also joins, and only that network. Nothing in the media stack can reach anything in the credentials stack; both can reach Caddy, and Caddy can reach both.
The proxy stack's compose file declares the shared network as external and joins only that:
networks:
proxy_net:
external: true
services:
caddy:
image: caddy:2
restart: unless-stopped
networks:
- proxy_net
ports:
- "80:80"
- "443:443"
The Vaultwarden stack's compose file then joins that same shared network, plus its own private one:
networks:
proxy_net:
external: true
vw_internal:
driver: bridge
services:
vaultwarden:
image: vaultwarden/server:latest
restart: unless-stopped
networks:
- proxy_net
- vw_internal
Vaultwarden joins both networks: proxy_net so Caddy can reach it, and its own vw_internal network for anything else in that stack (a backup sidecar, for example) that has no business being reachable from the media or automation stacks. The n8n and Jellyfin stacks follow the identical pattern, each swapping in its own internal network name.
Tip
Name the shared network something specific like proxy_net, not internet or web — a name collision between two unrelated compose projects both trying to create a network with the same generic name is a real, confusing failure mode.
Service-name DNS: how containers find each other
Within a shared network, Compose's embedded DNS resolves a service name to that container's current IP — no static IPs, no /etc/hosts editing, and it survives container restarts even though the underlying IP can change. This only works for containers on the same network; a container on vw_internal cannot resolve or reach a service that only exists on proxy_net's peer stacks. That distinction matters when debugging: curl: could not resolve host means the two containers don't share a network at all, while connection refused means they do share a network but the target service isn't listening or isn't up — check its restart status first, as covered in the restart policies guide, before assuming it's a networking bug.
internal: true — the network that can't reach the internet
Marking a network internal: true removes its outbound route entirely — containers on it can talk to each other, but nothing on that network can reach the public internet, and nothing outside the host can reach in. That's the right setting for a database container that has no legitimate reason to phone home:
networks:
db_net:
driver: bridge
internal: true
services:
postgres:
image: postgres:17
restart: unless-stopped
networks:
- db_net
Warning
internal: true blocks outbound traffic completely, including the image's own update checks or telemetry calls some software makes on startup. If a container silently fails to start after adding this, check its logs for a blocked outbound connection before assuming it's a Compose bug.
external: true — joining infrastructure Compose doesn't own
external: true tells Compose "this network already exists, use it, and never create or destroy it as part of this project's lifecycle." Create it once, by hand, before either stack starts:
docker network create proxy_net
From then on, both the proxy's compose file and every app stack's compose file reference proxy_net as external: true. Running docker compose down on any one of them leaves the shared network untouched — only a stack-specific internal network gets torn down with its stack.
Decision table: which network setup for which situation
| Situation | Setup | Why |
|---|---|---|
| A single, self-contained stack with no reverse proxy | Default network, no networks: block needed |
Nothing to isolate from — the default already isolates you from other projects |
| A database or backend service with no reason to reach the internet | Dedicated network with internal: true |
Removes an entire class of exfiltration and unwanted-update risk |
| Multiple independent stacks fronted by one reverse proxy | One external: true shared network, plus a private network per stack |
Proxy reaches everything; stacks can't reach each other |
| Two stacks that legitimately need to talk (e.g. an app and its dedicated cache) | A second, purpose-named internal network joined by only those two services | Narrower than a whole-homelab shared network, still isolated from everything else |
Debugging: is this a network problem or a restart problem?
Before chasing a networking issue, confirm the target container is actually running: docker compose ps — an Exited status means no restart policy brought it back, which is a restart-policy problem, not a networking one. If it shows Up, confirm both containers share a network with docker network inspect <network_name> and check the Containers list. Then test reachability directly from inside one container:
docker compose exec caddy ping -c 2 vaultwarden
A successful ping with resolved DNS means the network layer is fine and the problem is one level up — likely a port or app-level misconfiguration, not Compose networking.
The bottom line
Let Compose's default per-project network handle single-stack setups, reach for internal: true the moment a service has no business talking to the internet, and use one deliberately shared external: true network — never a fully flattened one — the moment a reverse proxy needs to front more than one stack.
Frequently asked questions
Do containers in the same docker compose file share a network by default?
Yes. Compose automatically creates one bridge network per project and joins every service in that file to it, letting them reach each other by service name without any extra configuration.
What does internal: true do in a Docker Compose network?
It removes the network's outbound route entirely. Containers on that network can still reach each other, but none of them can reach the public internet, and nothing outside the host can reach in.
How do you connect two separate docker compose stacks to the same network?
Create the network once by hand with docker network create <name>, then reference it as external: true in both compose files. Neither project will create or destroy that network as part of its own lifecycle.
Why can't my reverse proxy reach a container in another compose project?
Almost always because the two projects don't share a network. Each docker compose up creates its own isolated default network, so a proxy in one project cannot resolve or reach a service in another project unless both explicitly join a common external: true network.
More from Self-Hosting & Privacy

The power blipped, the host rebooted, and half the homelab stack came back up on its own — the other half just sat there, exited. Every container had a restart: line; they weren't all the same line.

I ran docker compose down -v to force a clean restart, out of muscle memory from a different project where that flag was harmless. It wasn't harmless here — it deleted a named volume along with the containers.

Gitea and Forgejo run on well under 1GB of RAM; GitLab CE documents a 4GB minimum. This comparison covers real resource footprint, CI runner maturity (Gitea/Forgejo Actions vs GitLab CI), and GitHub migration friction for homelab hardware.
Stay in the loop
Get the latest articles delivered to your inbox. No spam, unsubscribe anytime.
Docker Compose Volumes and Backups Explained
I ran docker compose down -v to force a clean restart, out of muscle memory from a different project where that flag was harmless. It wasn't harmless here — it deleted a named volume along with the containers.
Continue Reading