Most Docker Compose networking problems have one of three causes. The two containers don’t share a network, the name doesn’t resolve through Docker’s embedded DNS, or the target process isn’t listening where you think it is. Check them in that order and you’ll find the fault in a few minutes instead of restarting containers and hoping.
So why can’t your containers find each other by name? Compose service names resolve only between containers that share at least one network. Once a service has its own networks: list, it joins only those networks. It no longer joins the default one. And ports: has nothing to do with container-to-container traffic. Below is the mechanism behind each rule, plus copy-pasteable commands to prove which one is biting you.
What Docker Compose Networking Actually Does
Docker Compose is the CLI that runs multi-container apps from a single compose.yaml. For networking, it does three things when you run docker compose up:
- It creates a project-scoped, user-defined bridge network called
<project>_default. The project name defaults to the directory name. You can override it with-p,COMPOSE_PROJECT_NAME, or a top-levelname:key. - It attaches each service to that network under its service name. A service called
dbis reachable asdbfrom any other container on the same network (Docker docs: Networking in Compose, retrieved 2026-10-07). - It publishes host ports only for services that have a
ports:entry. Everything else stays reachable only from inside Docker.
Name resolution works because of Docker’s embedded DNS server at 127.0.0.11. On user-defined networks, the Docker daemon writes nameserver 127.0.0.11 into each container’s /etc/resolv.conf. That resolver answers queries for service names, container names, and aliases. It only answers for containers on networks the asking container shares. Names it doesn’t own get forwarded to the host’s upstream DNS.
Docker’s legacy default bridge network (the one plain docker run uses) has no name-based discovery. That’s why the same two containers can talk under Compose but not when you start them by hand.
Prerequisites
- Docker Desktop on Windows or macOS, or Docker Engine plus the
docker-compose-pluginpackage on Linux (Ubuntu 22.04/24.04 or Debian 12 assumed for Linux commands) - An existing Compose stack you understand, or willingness to use the three-service demo below
- Shell access: PowerShell 7 or Windows Terminal on Windows, Terminal on macOS, bash over SSH on Linux
- Optional: a second machine on the same LAN to test external port access
| Requirement | Details |
|---|---|
| Compose version | v2 or later; the current release line is v5.x (check with docker compose version) |
| Docker Engine | A current release (the 29.x line as of October 2026, per the Docker Engine release notes); older engines run these examples but may format output differently |
| Host | Any Docker host: a laptop, a mini PC, or a NAS that runs Docker works fine for a homelab |
| Network images used | nginx:1.27-alpine, redis:7-alpine, nicolaka/netshoot (debug toolbox) |
Environment note: The commands and expected output below follow the documented behavior of the docker compose plugin (Compose v2 and later). Your container IPs, subnet ranges, and output formatting will differ. Check your own versions with docker compose version and docker version before you compare output line by line.
Step-by-Step Guide
Step 1: Confirm You’re Running Compose v2 or Later on Your Platform
Every command here uses docker compose (with a space). Legacy docker-compose v1 named projects and containers differently (demo_api_1 instead of demo-api-1). That difference alone can break scripts that filter by name.
Windows (Docker Desktop)
Make sure Docker Desktop is running. The whale icon in the system tray should show that the engine is running. Then open PowerShell:
docker compose version
Docker Compose version v5.x.x
macOS (Docker Desktop)
Check that the Docker whale icon in the menu bar shows the engine is running, then open Terminal:
docker compose version
Docker Compose version v5.x.x
Linux (Docker Engine + Compose plugin)
On Ubuntu or Debian with Docker’s official apt repository:
docker compose version
# If "docker: 'compose' is not a docker command", install the plugin:
sudo apt-get update && sudo apt-get install -y docker-compose-plugin
Docker Compose version v5.x.x
Expected result: A v2.x or later version string on all three platforms. Current releases report v5.x, and older v2.x builds behave the same for every example here. If you only have docker-compose (hyphenated), upgrade before you continue. The Compose releases page lists current versions.
Step 2: See the Default Network and the Embedded DNS in Action
Create a scratch project so you can watch the defaults without touching production.
mkdir compose-net-demo
cd compose-net-demo
Create compose.yaml in that directory:
# compose-net-demo/compose.yaml
name: demo # pins the project name so networks are named demo_* on every OS
services:
api:
image: nginx:1.27-alpine
cache:
image: redis:7-alpine
Bring it up:
docker compose up -d # -d runs containers detached (in the background)
[+] Running 3/3
✔ Network demo_default Created
✔ Container demo-cache-1 Started
✔ Container demo-api-1 Started
You never declared a network, but Compose created demo_default anyway. Now look inside api to see where DNS queries go:
docker compose exec api cat /etc/resolv.conf
# Generated by Docker Engine.
…
nameserver 127.0.0.11
Resolve the other service by name:
docker compose exec api getent hosts cache
172.18.0.2 cache
Expected result: nameserver 127.0.0.11 in resolv.conf, and cache resolves to an IP on the demo_default subnet. Your container got that IP by asking Docker’s embedded resolver. The resolver answered because both containers share demo_default.
Tip:
getentexists in most glibc and Alpine images, but not in distroless orscratchimages. In those cases, use the debug container approach from Step 8. Ifexecfails with “executable file not found”, the tool is missing from the image. That’s not a DNS failure, so don’t read it as one.
Step 3: Write a Multi-Tier compose.yaml With Custom Networks
Now model a realistic layout: a public-facing proxy, an api in the middle, and a cache that should never be reachable from the proxy tier. Replace compose.yaml:
# compose-net-demo/compose.yaml
name: demo
services:
proxy:
image: nginx:1.27-alpine
ports:
- "8080:80" # HOST:CONTAINER, so host port 8080 forwards to container port 80
networks:
- frontend # proxy can only see frontend members
api:
image: nginx:1.27-alpine
networks:
- frontend # reachable by proxy
- backend # can reach cache
cache:
image: redis:7-alpine
networks:
- backend # invisible to proxy
networks:
frontend: {} # Compose creates demo_frontend
backend:
internal: true # no gateway out of the host; cache can't reach the internet
Two blocks matter here, and they do different jobs:
- Top-level
networks:declares which networks exist. Declaring a network doesn’t attach anything to it. - Service-level
networks:attaches that service to specific networks. This is the only thing that decides membership (Compose file reference: networks, retrieved 2026-10-07).
Do you lose the default network when you add a top-level networks: block? Not exactly, and the distinction matters. Any service without a networks: key still joins default. Any service with a networks: key joins only the networks it lists, so it drops off default. Compose creates demo_default only if at least one service still uses it.
In practice, the moment you give one service a networks: list, you split the stack into two groups. The “explicit” and “implicit” services can’t see each other.
Step 4: Bring Up the Stack and Verify Network Membership
Apply the new file. --remove-orphans is harmless here but useful when you’ve renamed services.
docker compose up -d --remove-orphans
[+] Running 5/5
✔ Network demo_frontend Created
✔ Network demo_backend Created
✔ Container demo-cache-1 Started
✔ Container demo-api-1 Started
✔ Container demo-proxy-1 Started
List the networks:
docker network ls --filter name=demo_
NETWORK ID NAME DRIVER SCOPE
3c1f0a9e2b11 demo_backend bridge local
9a7e44d0c8f2 demo_default bridge local
b81d27f6aa03 demo_frontend bridge local
Note:
demo_defaultis a leftover from Step 2, not part of the new topology. No service in the Step 3 file uses it, and Compose doesn’t delete it onup.docker compose downwon’t remove it either, becausedownonly removes networks the current file defines or uses (Docker docs: docker compose down). Step 11 removes it by hand.
Now check who’s actually on each network. This is the most useful diagnostic of the lot:
docker network inspect demo_backend --format '{{range .Containers}}{{.Name}} {{end}}'
demo-api-1 demo-cache-1
docker network inspect demo_frontend --format '{{range .Containers}}{{.Name}} {{end}}'
demo-proxy-1 demo-api-1
For the full picture, including IPs, run docker network inspect demo_backend without --format and read the "Containers" block.
You can also check from the container’s side. This lists every network one container belongs to:
docker inspect demo-api-1 --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}'
demo_backend demo_frontend
Expected result: api appears on both networks, proxy only on frontend, and cache only on backend. Prove that the isolation works:
docker compose exec api getent hosts cache # should succeed
docker compose exec proxy getent hosts cache # should print nothing
172.20.0.2 cache
The second command prints nothing and exits non-zero. That’s the design doing its job. The proxy has no route to the cache tier, and the resolver won’t even admit the name exists.
Step 5: Reproduce (and Fix) the “Forgot to Attach It” Mistake
This is the failure behind most “it worked yesterday” tickets. Someone adds a new service, copies an image: line, and leaves out networks:. Add a worker that needs the cache:
# compose-net-demo/compose.yaml (add under services:)
worker:
image: nginx:1.27-alpine
# no networks: key, so it joins "default", not "backend"
docker compose up -d
docker compose exec worker getent hosts cache
The output is empty. Check the exit code to confirm the failure:
# Linux / macOS
echo $?
# Windows PowerShell
$LASTEXITCODE
2
Compose doesn’t warn you, because nothing is invalid. worker sits alone on demo_default, which cache never joined. Confirm it:
docker inspect demo-worker-1 --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}'
demo_default
Fix: attach the service to the network it needs, then redeploy.
worker:
image: nginx:1.27-alpine
networks:
- backend
docker compose up -d
docker compose exec worker getent hosts cache
172.20.0.4 cache
Expected result: worker now resolves cache. A good audit habit: once a stack uses custom networks, give every service an explicit networks: list. Don’t rely on the implicit default. Then a missing attachment shows up in code review instead of at 2 a.m.
Step 6: Understand ports:, and When You Don’t Need It
Do you need ports: for two containers to talk? No. ports: publishes a container port onto the host. Container-to-container traffic travels over the shared Docker network. It always uses the service name plus the container’s internal port.
From proxy, reach api on its internal port 80. Notice that api has no ports: entry at all:
docker compose exec proxy wget -qO- http://api:80
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
…
Now reach proxy from the host through its published port:
# Linux / macOS
curl http://localhost:8080
# Windows PowerShell: use curl.exe, because "curl" in Windows PowerShell 5.1 is an alias for Invoke-WebRequest
curl.exe http://localhost:8080
The rule set to remember:
| Traffic path | Address to use | Needs ports:? |
|---|---|---|
| Container → container (same network) | http://api:80 (service name + container port) | No |
| Host → container | http://localhost:8080 (host port) | Yes |
| Another LAN machine → container | http://<host-LAN-IP>:8080 | Yes, bound to a reachable interface |
| Container → service on the host (Docker Desktop) | host.docker.internal:<port> | No |
A classic mistake runs the other way. An app container gets DB_HOST=localhost and DB_PORT=5432 (or the host-published port). Inside a container, localhost is that container’s own loopback. Use the service name and the container port instead.
The security payoff: leave ports: off cache, databases, and queues. They stay unreachable from outside the Docker host without a single firewall rule. One caveat: an internal: true network has no route to other networks, and Docker drops traffic between it and the outside (Docker docs: docker network create). Don’t make it the only network of a service you want to reach from outside. Keep public entry points like proxy on a non-internal network, then test the exact path with curl.
Step 7: Control the Host Bind Address
"8080:80" is shorthand for “listen on all host interfaces.” The full form is HOST_IP:HOST_PORT:CONTAINER_PORT:
ports:
- "8080:80" # all interfaces: reachable from LAN (subject to firewall)
- "127.0.0.1:8081:80" # loopback only: reachable from this host, not the LAN
- "192.168.1.50:8082:80" # one specific host NIC only
That third form explains a lot of “works from localhost but not another machine” reports. It also explains the reverse.
- Works on localhost, fails from the LAN: the port is bound to
127.0.0.1, or a host firewall blocks inbound traffic. - Works from the LAN, fails on localhost: the port is bound to a specific LAN IP like
192.168.1.50, which doesn’t answer on127.0.0.1. Or a different host process already owns that port on loopback.
Check the actual binding Docker created:
docker compose port proxy 80
0.0.0.0:8080
The fix also depends on the platform.
Windows
Docker Desktop forwards published ports to the Windows host. LAN clients still have to get through Windows Defender Firewall. If a second machine can’t connect, check for an inbound rule that allows Docker Desktop’s backend, or a port-specific rule. Test from the other machine with curl.exe http://<windows-host-IP>:8080.
macOS
Docker Desktop runs containers inside a lightweight VM and forwards published ports to the Mac. If the macOS application firewall is on (System Settings > Network > Firewall), it may prompt for or block incoming connections to Docker’s backend process. Allow it, then retest from another machine.
Linux
On Docker Engine, published ports are wired in with iptables/nftables rules that Docker manages. Traffic to a published port is typically handled before UFW’s rules see it. So ufw deny 8080 often doesn’t block a port published as "8080:80". This one surprises people. If a service should be local-only, bind it to 127.0.0.1 in ports: rather than relying on UFW. You can check which host sockets are listening:
sudo ss -tlnp | grep 8080 # -t TCP, -l listening, -n numeric, -p owning process
Warning: Don’t flush iptables (
iptables -F) to “reset” Docker networking on a remote host. You can lock yourself out of SSH and break every running container’s connectivity. If Docker’s rules are broken,sudo systemctl restart dockerrebuilds them, but it stops and restarts containers, so treat it as a maintenance-window step, not a routine fix. To filter traffic to published ports, add your own rules to theDOCKER-USERchain, which Docker processes before its own rules (Docker docs: Docker with iptables).
If a homelab box isn’t reachable across your network at all, rule out the physical layer before you blame Compose. A flaky patch cable or a misconfigured port on a managed 2.5GbE switch fails exactly like a firewall block.
Step 8: Diagnose Any Service-Discovery Failure in Three Layers
When app can’t reach db, work through these three checks in order. Each one rules out a whole class of causes.
Layer 1: Network membership. Do the two containers share at least one network?
docker inspect demo-api-1 --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}'
docker inspect demo-cache-1 --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}'
No common network name? That’s your problem. Fix the service-level networks: list (Step 5).
Layer 2: DNS. Does the name resolve from the calling container?
docker compose exec api getent hosts cache
If they share a network but the name doesn’t resolve, check for these causes:
- A typo, or you used the container name pattern (
demo-cache-1) inconsistently. The service namecacheis the reliable one. - The target container is crashed or restarting (
docker compose ps). Stopped containers drop out of DNS. - The image has no
getent. Use a throwaway debug container on the same network instead:
docker run --rm --network demo_backend nicolaka/netshoot nslookup cache 127.0.0.11
Server: 127.0.0.11
Address: 127.0.0.11#53Name: cache
Address: 172.20.0.2
Layer 3: Is the destination actually listening on the network interface? Networking can be perfect while the app is bound only to its own loopback. Simulate it by overriding cache:
cache:
image: redis:7-alpine
command: ["redis-server", "--bind", "127.0.0.1"] # deliberately wrong for demo
networks:
- backend
docker compose up -d
docker run --rm --network demo_backend nicolaka/netshoot nc -zv cache 6379
nc: connect to cache (172.20.0.2) port 6379 (tcp) failed: Connection refused
DNS worked, because it returned an IP. The connection still got refused. Inspect the listening sockets inside the cache container’s network namespace. You don’t need to install anything in the cache image:
docker run --rm --network container:demo-cache-1 nicolaka/netshoot ss -tlnp
# --network container:<name> shares that container's network stack
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 511 127.0.0.1:6379 0.0.0.0:*
127.0.0.1:6379 confirms the cause. Remove the bad command: line, or bind to 0.0.0.0, and redeploy. A healthy result reads 0.0.0.0:6379 (or *:6379), and nc -zv reports succeeded or open.
Expected result: You can now name the failing layer: membership, DNS, or listener. That tells you exactly what to change.
Step 9: Share a Network Across Separate Compose Projects
Each project gets its own <project>_default, so two stacks started from different directories can’t see each other. The standard fix is one shared, externally managed network. A reverse proxy stack plus several app stacks is the textbook case.
Create the network once, outside any project:
docker network create proxy_shared
In the proxy stack’s compose.yaml:
# ~/stacks/proxy/compose.yaml
services:
traefik:
image: traefik:v3.1
ports:
- "80:80"
networks:
- proxy_shared
networks:
proxy_shared:
external: true # Compose attaches to it but never creates or deletes it
In each app stack:
# ~/stacks/wiki/compose.yaml
services:
wiki:
image: nginx:1.27-alpine
networks:
- proxy_shared # reachable by traefik as "wiki"
- internal # private tier for this stack only
wikidb:
image: redis:7-alpine
networks:
- internal
networks:
proxy_shared:
external: true
internal: {}
Gotchas worth knowing:
- If the external network doesn’t exist,
docker compose upfails. The error says the network is declared as external but could not be found. Rundocker network createfirst. - If the real network name differs from the key you want in YAML, set
name:explicitly. Compose then uses that exact name without a project prefix:
networks:
edge:
external: true
name: proxy_shared
- Service names on a shared network are global across every stack attached to it. Two stacks that both have a service called
apponproxy_sharedwill both answer toapp, and requests can land on either one. Give shared-network services unique names, or set uniquealiases:. - Keep databases off the shared network. Attach only the service the proxy needs to talk to.
Step 10: Avoid Multi-File Gotchas
Splitting one application across files is fine, as long as you know how project naming works.
Pattern A: Merge files into one project. Every file is combined into a single project and a single set of networks:
docker compose -f compose.yaml -f compose.monitoring.yaml up -d
# -f can be repeated; later files override/extend earlier ones
To check the merged result, including which networks each service ended up on, run:
docker compose -f compose.yaml -f compose.monitoring.yaml config
Pattern B: Separate projects. Run docker compose up -d in ./app/ and again in ./monitoring/, and you get two projects (app and monitoring). Each has its own default network. Services in one can’t resolve services in the other. Fix it with an external: true network (Step 9), or force one project name. I’d only pick the second option if you really want them managed as one unit:
docker compose -p myapp -f ./app/compose.yaml up -d
docker compose -p myapp -f ./monitoring/compose.yaml up -d
Warning: Forcing the same
-pacross separate files meansdocker compose -p myapp downwith only one file loaded may treat the other file’s containers as orphans. Pair-pwith a consistent set of-fflags, or use the external-network pattern instead.
If you keep project names in a .env file via COMPOSE_PROJECT_NAME, remember that Compose reads .env from the project directory. Running from a different working directory can quietly change the project name. Every network name changes with it.
Step 11: Clean Up
docker compose down
[+] Running 6/6
✔ Container demo-proxy-1 Removed
✔ Container demo-worker-1 Removed
✔ Container demo-api-1 Removed
✔ Container demo-cache-1 Removed
✔ Network demo_frontend Removed
✔ Network demo_backend Removed
down removes the networks the current file uses, but it skips two kinds. It leaves Step 2’s leftover demo_default, since no service in the final file uses it. It never removes external: true networks. Remove both yourself once nothing uses them:
docker network rm demo_default
docker network rm proxy_shared
Warning:
docker compose down -valso deletes named volumes, which means data.docker network pruneremoves every unused network on the host, including ones from other projects. Don’t run either on a production host unless you mean it.
Configuration Reference
| Setting | Where | Default | What it does |
|---|---|---|---|
networks: (top-level) | root of file | none (Compose creates <project>_default as needed) | Declares networks; doesn’t attach anything |
networks: (service) | under a service | joins default if omitted | Attaches the service; listing any network removes it from default |
internal: true | network definition | false | No external gateway; good for data tiers |
external: true | network definition | false | Use an existing network; Compose won’t create or delete it |
name: | network definition | <project>_<key> | Exact Docker network name, no project prefix |
aliases: | service → network entry | none | Extra DNS names on that specific network |
ports: | service | nothing published | [HOST_IP:]HOST_PORT:CONTAINER_PORT publishing to the host |
expose: | service | none | Documents internal ports; doesn’t publish to the host |
Aliases help when an app hardcodes a hostname you can’t change:
services:
cache:
image: redis:7-alpine
networks:
backend:
aliases:
- redis.internal # resolvable only by containers on backend
Tips and Troubleshooting
getent hosts <service> returns nothing
Why it happens: The calling container doesn’t share a network with the target, or the target isn’t running.
Fix:
- Compare both containers’ networks with
docker inspect <container> --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}'. - Run
docker compose psto confirm the target isrunning, notrestarting. - Add the missing service-level
networks:entry and rundocker compose up -d.
Connection refused even though the name resolves
Why it happens: The destination process listens on 127.0.0.1 inside its container, or on a different port than you’re targeting. A common version: the client uses the host-published port instead of the container port.
Fix:
- Run
docker run --rm --network container:<target-container> nicolaka/netshoot ss -tlnp. - Look for
0.0.0.0:<port>or*:<port>. If you only see127.0.0.1, change the app’s bind address. - Make sure the client targets
<service>:<container-port>.
Bind for 0.0.0.0:8080 failed: port is already allocated
Why it happens: Another container or host process already owns that host port.
Fix:
- Find the conflicting container:
docker ps --filter publish=8080. - On Linux, check host processes with
sudo ss -tlnp | grep 8080. On Windows, usenetstat -ano | findstr :8080in PowerShell. - Change the host side of the mapping (for example,
"8090:80"). The container side stays the same.
network proxy_shared declared as external, but could not be found
Why it happens: External networks must exist before up, and Compose won’t create them.
Fix: Run docker network create proxy_shared, then docker compose up -d. Also check for a name: mismatch between the YAML key and the real network name.
Published port works on the host but not from another machine
Why it happens: The port is bound to 127.0.0.1, or a host firewall blocks inbound traffic (Windows Defender Firewall, the macOS application firewall, or a cloud security group).
Fix: Run docker compose port <service> <container-port> to see the binding. Change it to 0.0.0.0 or the LAN IP if exposure is intended. Then open the firewall for that port only.
Quick Reference Cheat Sheet
| Task | Command or syntax |
|---|---|
| List project networks | docker network ls --filter name=<project>_ |
| Who’s on a network | docker network inspect <net> --format '{{range .Containers}}{{.Name}} {{end}}' |
| Which networks a container is on | docker inspect <ctr> --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' |
| Test name resolution | docker compose exec <svc> getent hosts <other-svc> |
| DNS test without tools in the image | docker run --rm --network <net> nicolaka/netshoot nslookup <svc> 127.0.0.11 |
| Check listen address | docker run --rm --network container:<ctr> nicolaka/netshoot ss -tlnp |
| Test a TCP port | docker run --rm --network <net> nicolaka/netshoot nc -zv <svc> <port> |
| Show a published binding | docker compose port <svc> <container-port> |
| See the merged config | docker compose -f a.yaml -f b.yaml config |
| Publish on all interfaces | ports: ["8080:80"] |
| Publish on loopback only | ports: ["127.0.0.1:8080:80"] |
| Shared cross-stack network | docker network create <net>, then external: true |
| Isolated data tier | networks: { backend: { internal: true } } |
Wrapping Up
You can now tell a membership problem from a DNS problem from a listener problem in about three commands. The biggest win comes from one habit: once a stack uses custom networks, give every service an explicit networks: list. Leave ports: off anything that isn’t a public entry point.
Compose’s networking is predictable once you know the rules. The catch is that it’s strict about membership and never warns you when you forget an attachment, so the diagnostics above earn their keep.
| Step | Action | Applies To |
|---|---|---|
| 1–2 | Confirm Compose v2, see the default network and 127.0.0.11 | All platforms |
| 3–5 | Declare tiers, attach services explicitly, catch missing attachments | All platforms |
| 6–7 | Publish only entry points; control bind address and firewall | All (firewall details per OS) |
| 8 | Diagnose: membership → DNS → listener | All platforms |
| 9–10 | Share networks across stacks with external: true; merge files correctly | Multi-stack setups |
| 11 | Clean up with docker compose down | All platforms |