Blog
3 min read

Docker Compose Networking: How Containers Talk to Each Other

How services in a Compose project find each other by name, why localhost doesn't work between containers, ports vs expose, custom and external networks, reaching the host from a container, connecting two Compose projects, and the security gotcha of published ports.

Most Docker Compose networking confusion comes from one question: "what address do I use to reach the other container?" Here's the model, then the common cases. (Docker vs Docker Compose)

The default network

When you run docker compose up, Compose creates a network named <project>_default and attaches every service to it. On that network:

  • each service is reachable by its service name as a hostname,
  • Docker's embedded DNS resolves the name to the container's IP,
  • containers talk to each other on the container's port, not a published one.
services:
  web:
    build: .
    environment:
      DATABASE_URL: postgresql://app:secret@db:5432/app   # "db", port 5432
      REDIS_URL: redis://cache:6379
  db:
    image: postgres:18
  cache:
    image: redis:8

web reaches Postgres at db:5432 and Redis at cache:6379. (Postgres connection strings)

Why localhost doesn't work

Inside a container, localhost means that container itself. If web connects to localhost:5432, it's looking for Postgres inside the web container — and gets ECONNREFUSED. Use the service name. (ECONNREFUSED explained)

ports vs expose

db:
  image: postgres:18
  ports:
    - "5432:5432"     # publish to the HOST
  • ports publishes a container port on the host machine, so things outside Docker (your laptop's tools, the internet) can reach it.
  • Containers on the same network don't need ports to talk to each other.
  • expose is documentation only.

So your database usually needs no ports at all in production. Only the service the outside world talks to (or a reverse proxy) should publish.

The firewall gotcha

Published ports are added to iptables by Docker ahead of UFW's rules, so ports: "5432:5432" can expose your database to the internet even when UFW says the port is closed. Bind to localhost if you need host access:

ports:
  - "127.0.0.1:5432:5432"

(Container networking internals, UFW firewall basics)

Reaching the host from a container

Your container needs something running directly on your machine (a local API, Ollama):

  • Docker Desktop (Mac/Windows): use host.docker.internal.
  • Linux: add it yourself:
web:
  extra_hosts:
    - "host.docker.internal:host-gateway"

The host service must listen on an address the container can reach (not only 127.0.0.1, unless you use host networking).

Custom networks: separating tiers

services:
  proxy:
    image: caddy:2
    ports: ["80:80", "443:443"]
    networks: [frontend]
  web:
    build: .
    networks: [frontend, backend]
  db:
    image: postgres:18
    networks: [backend]

networks:
  frontend:
  backend:

The proxy can reach web, web can reach db, but the proxy can't reach db. A small step towards least privilege. (Principle of least privilege)

Connecting two Compose projects

Services in different projects are on different networks. Create a shared one:

docker network create shared
# in each project
services:
  api:
    networks: [default, shared]
networks:
  shared:
    external: true

Now api in one project can reach a service in the other by name.

Aliases and multiple names

db:
  networks:
    backend:
      aliases: [postgres, database]

Debugging

docker compose exec web getent hosts db       # does the name resolve?
docker compose exec web nc -zv db 5432        # is the port reachable?
docker network inspect myproject_default      # who's on the network

(Minimal images may lack these tools; a throwaway docker run --rm -it --network myproject_default nicolaka/netshoot gives you all of them.)

Startup order

depends_on starts containers in order but doesn't wait for "ready". Use a healthcheck:

web:
  depends_on:
    db:
      condition: service_healthy
db:
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U app"]
    interval: 5s
    retries: 10

(Health check endpoints)


EasySpawn runs your app's services on one server with networking set up so databases stay private and only your web app is published behind HTTPS. See how it works or join the waitlist.

Related: Docker Compose for Local Development · Container Networking Internals · Docker vs Docker Compose · Docker "Port Is Already Allocated"

Keep reading