Virtualization

Docker Compose Networking: Fix Service Discovery, Custom Networks, and Port Mapping (2026)

22 min read

The Back Room Tech is reader-supported. We may earn a commission when you buy through links on our site. Learn more.

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-level name: key.
  • It attaches each service to that network under its service name. A service called db is reachable as db from 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-plugin package 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
RequirementDetails
Compose versionv2 or later; the current release line is v5.x (check with docker compose version)
Docker EngineA 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
HostAny Docker host: a laptop, a mini PC, or a NAS that runs Docker works fine for a homelab
Network images usednginx: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

Windows 11 desktop with the Docker Desktop whale icon visible in the system tray and a Windows Terminal (PowerShell) window showing the output of docker compose version

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

macOS Terminal window with docker compose version printing Docker Compose version v5.6.0 and the Docker whale in the menu bar

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

Ubuntu 24.04 desktop with a terminal window showing docker compose version output and systemctl status docker reporting active (running)

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

Terminal output of docker compose up -d listing Network demo_default Created, Container demo-cache-1 Started and 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: getent exists in most glibc and Alpine images, but not in distroless or scratch images. In those cases, use the debug container approach from Step 8. If exec fails 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
cat compose.yaml output with proxy, api and cache services, their networks: lists, and the top-level frontend and internal backend networks highlighted

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

docker network ls --filter name=demo_ output listing demo_backend, demo_default and demo_frontend with the custom network names highlighted

Note: demo_default is 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 on up. docker compose down won’t remove it either, because down only 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.

docker network inspect demo_backend JSON output with the Containers section highlighted, showing demo-api-1 and demo-cache-1 with their IPv4 addresses

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

Two getent hosts commands side by side: docker compose exec api getent hosts cache returning an IP, and docker compose exec worker getent hosts cache returning nothing with exit code 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
compose.yaml with the proxy ports: "8080:80" line highlighted above api and cache services without ports, followed by wget and curl output showing the Welcome to nginx! page

The rule set to remember:

Traffic pathAddress to useNeeds ports:?
Container → container (same network)http://api:80 (service name + container port)No
Host → containerhttp://localhost:8080 (host port)Yes
Another LAN machine → containerhttp://<host-LAN-IP>:8080Yes, 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 on 127.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 docker rebuilds 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 the DOCKER-USER chain, 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 name cache is 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#53

Name: cache
Address: 172.20.0.2

docker run --rm --network demo_backend nicolaka/netshoot nslookup cache 127.0.0.11 showing Server 127.0.0.11 and a resolved address for cache

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 up fails. The error says the network is declared as external but could not be found. Run docker network create first.
  • 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 app on proxy_shared will both answer to app, and requests can land on either one. Give shared-network services unique names, or set unique aliases:.
  • 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 -p across separate files means docker compose -p myapp down with only one file loaded may treat the other file’s containers as orphans. Pair -p with a consistent set of -f flags, 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

docker compose down output removing containers demo-proxy-1, demo-worker-1, demo-api-1 and demo-cache-1, with the demo_frontend and demo_backend network removal lines highlighted

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 -v also deletes named volumes, which means data. docker network prune removes 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

SettingWhereDefaultWhat it does
networks: (top-level)root of filenone (Compose creates <project>_default as needed)Declares networks; doesn’t attach anything
networks: (service)under a servicejoins default if omittedAttaches the service; listing any network removes it from default
internal: truenetwork definitionfalseNo external gateway; good for data tiers
external: truenetwork definitionfalseUse 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 entrynoneExtra DNS names on that specific network
ports:servicenothing published[HOST_IP:]HOST_PORT:CONTAINER_PORT publishing to the host
expose:servicenoneDocuments 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 ps to confirm the target is running, not restarting.
  • Add the missing service-level networks: entry and run docker 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 see 127.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, use netstat -ano | findstr :8080 in 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

TaskCommand or syntax
List project networksdocker network ls --filter name=<project>_
Who’s on a networkdocker network inspect <net> --format '{{range .Containers}}{{.Name}} {{end}}'
Which networks a container is ondocker inspect <ctr> --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}'
Test name resolutiondocker compose exec <svc> getent hosts <other-svc>
DNS test without tools in the imagedocker run --rm --network <net> nicolaka/netshoot nslookup <svc> 127.0.0.11
Check listen addressdocker run --rm --network container:<ctr> nicolaka/netshoot ss -tlnp
Test a TCP portdocker run --rm --network <net> nicolaka/netshoot nc -zv <svc> <port>
Show a published bindingdocker compose port <svc> <container-port>
See the merged configdocker compose -f a.yaml -f b.yaml config
Publish on all interfacesports: ["8080:80"]
Publish on loopback onlyports: ["127.0.0.1:8080:80"]
Shared cross-stack networkdocker network create <net>, then external: true
Isolated data tiernetworks: { 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.

StepActionApplies To
1–2Confirm Compose v2, see the default network and 127.0.0.11All platforms
3–5Declare tiers, attach services explicitly, catch missing attachmentsAll platforms
6–7Publish only entry points; control bind address and firewallAll (firewall details per OS)
8Diagnose: membership → DNS → listenerAll platforms
9–10Share networks across stacks with external: true; merge files correctlyMulti-stack setups
11Clean up with docker compose downAll platforms

Resources