DEV Community

Cover image for Docker Compose networking: networks, services, and isolation
Javi Palacios
Javi Palacios

Posted on Originally published at fjp.es

Docker Compose networking: networks, services, and isolation

There is a very Docker Compose moment: Postgres is running, the API is running, the logs look reasonable, and the API still says it can't connect to localhost:5432.

You check docker compose ps. Postgres is Up. You check the docker-compose.yml. The port is there. You stare at the screen with that specific mix of suspicion and tiredness that only appears when a tool is technically telling the truth in the least helpful way possible.

Is Postgres down? No. Is the port wrong? Maybe, but probably not. Is localhost a trap with good branding? Exactly.

In Docker Compose, each container lives in its own little network world. Once you understand that, the rest stops feeling like dark magic with YAML.

Compose's default network

When you run docker compose up, Compose automatically creates a network for the project. If your directory is called myapp, that network is usually named something like myapp_default.

All services in the docker-compose.yml join that network by default.

services:
  api:
    image: myapp/api

  db:
    image: postgres:16-alpine
Enter fullscreen mode Exit fullscreen mode

Even though you didn't write networks: anywhere, Compose did some work for you:

docker compose up -d
docker network ls
Enter fullscreen mode Exit fullscreen mode

You'll see a network like this:

NETWORK ID     NAME            DRIVER    SCOPE
abc123def456   myapp_default   bridge    local
Enter fullscreen mode Exit fullscreen mode

Inside that network, services can find each other by service name. The API can connect to Postgres using db as the hostname.

postgresql://myapp:secret@db:5432/myapp
Enter fullscreen mode Exit fullscreen mode

Don't use the container IP. Container IPs can change when services are recreated. Using container IPs in Compose is like writing down where you parked three weeks ago on a napkin: technically useful for a few minutes.

Use service names.

localhost inside a container is not your machine

This is the main trap.

From your machine, localhost means your machine.

From inside a container, localhost means that container.

services:
  api:
    image: myapp/api
    environment:
      DATABASE_URL: postgresql://myapp:secret@localhost:5432/myapp

  db:
    image: postgres:16-alpine
Enter fullscreen mode Exit fullscreen mode

That is wrong for container-to-container communication. The API will look for Postgres inside the API container itself. Spoiler: it is not there. If it were, you'd have another problem, probably with a suspicious architecture smell.

The correct version uses the service name:

services:
  api:
    image: myapp/api
    environment:
      DATABASE_URL: postgresql://myapp:secret@db:5432/myapp

  db:
    image: postgres:16-alpine
Enter fullscreen mode Exit fullscreen mode

Mental model:

Connecting from Correct host
Your browser to the API localhost if the port is published
API to Postgres db
API to Redis redis
One container to another The service name

Don't stress if this feels counterintuitive at first. localhost has spent years looking innocent while ruining entire afternoons with a smile.

ports is not how containers talk to each other

Another classic confusion: thinking you need to publish a port so another container can connect to it.

You don't.

services:
  api:
    build: ./api
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://myapp:secret@db:5432/myapp

  db:
    image: postgres:16-alpine
Enter fullscreen mode Exit fullscreen mode

The ports: "3000:3000" line publishes the API port to your machine. It lets you open http://localhost:3000 from your browser.

But the API does not need Postgres to have ports: "5432:5432" to connect to db:5432. Both services are already on the internal Compose network.

This matters for security. If you publish Postgres like this:

services:
  db:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
Enter fullscreen mode Exit fullscreen mode

You're exposing the database to the host. In development that can be useful if you want to connect with TablePlus, DBeaver, local psql, or another external tool. But don't do it “just in case”. “Just in case” in infrastructure is a polite way of saying “I'll leave this door open and see what happens”.

If only the API needs to talk to the database, don't publish the port.

Custom networks: separate rooms

The default network is fine for small projects. All services can see each other and life goes on.

But in a more realistic application, you might have:

  • web: frontend or reverse proxy.
  • api: backend.
  • db: database.
  • redis: cache.

There is no reason for web to connect directly to db. The frontend talks to the API. The API talks to the database. The database should not be standing in the lobby waving at the entire building.

You can model that with custom networks:

services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    networks:
      - frontend

  api:
    image: myapp/api
    networks:
      - frontend
      - backend

  db:
    image: postgres:16-alpine
    networks:
      - backend

networks:
  frontend:
  backend:
Enter fullscreen mode Exit fullscreen mode

Now the rules are clear:

Service Networks Can talk to
web frontend api
api frontend, backend web, db
db backend api

web and db do not share a network, so they cannot resolve each other by name or connect directly. This does not replace authentication, firewalls, permissions, or common sense, but it reduces surface area. And reducing surface area is one of those boring things that prevents interesting incidents. Interesting incidents are overrated.

expose vs ports

Compose also has expose:

services:
  api:
    image: myapp/api
    expose:
      - "3000"
Enter fullscreen mode Exit fullscreen mode

expose documents that a service listens on a port for other services on the network, but it does not publish that port to your machine.

In practice, services on the same network can connect to a container port even if you don't write expose. That's why many teams don't use it. It can be useful as documentation inside the docker-compose.yml, but don't confuse it with security.

The important difference:

Field Publishes to host Main use
ports Yes Access from your machine or outside
expose No Document internal ports

If you want to open something from the browser, use ports. If only containers talk to each other, you usually don't need anything.

External networks

Sometimes you want several Compose projects to share a network. For example, a common reverse proxy (traefik, nginx, caddy) routing multiple local apps or services on the same server.

First create the network manually:

docker network create shared-proxy
Enter fullscreen mode Exit fullscreen mode

Then declare it as external:

services:
  app:
    image: myapp/api
    networks:
      - shared-proxy

networks:
  shared-proxy:
    external: true
Enter fullscreen mode Exit fullscreen mode

external: true means: “Compose, don't create this network; it already exists, use it.”

If the network doesn't exist, Compose fails. And that's good. Better to fail than silently create a new network and leave you wondering for half an hour why the proxy can't see your app. Docker already has enough material to confuse you without extra help.

Network aliases

By default, each service resolves by its name: api, db, redis.

Sometimes you want additional names. That's what aliases are for.

services:
  api:
    image: myapp/api
    networks:
      backend:
        aliases:
          - api-service
          - internal-api

  worker:
    image: myapp/worker
    networks:
      - backend

networks:
  backend:
Enter fullscreen mode Exit fullscreen mode

From worker, these hostnames point to the api service:

api
api-service
internal-api
Enter fullscreen mode Exit fullscreen mode

Use this carefully. An alias can help when a legacy application expects a specific hostname. But if you start adding three names per service “for convenience”, you'll end up with a tiny home-made DNS system inside YAML. Like having five nicknames for the same person in a group chat: funny until someone new tries to understand the conversation.

Complete example

A reasonable stack with reverse proxy, API, database, and cache:

services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    networks:
      - frontend

  api:
    build: ./api
    environment:
      DATABASE_URL: postgresql://myapp:secret@db:5432/myapp
      REDIS_URL: redis://redis:6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - frontend
      - backend

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp -d myapp"]
      interval: 5s
      timeout: 3s
      retries: 5
    networks:
      - backend

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 3
    networks:
      - backend

volumes:
  db-data:

networks:
  frontend:
  backend:
Enter fullscreen mode Exit fullscreen mode

Notice three details:

  • Only web publishes a port to the host.
  • api talks to db and redis using service names.
  • db and redis are not on frontend, so web cannot talk to them directly.

This does not turn your app into Fort Knox. But you're no longer putting every service in the same room with a sticky note that says “be nice”.

Practical challenge

Take the docker-compose.yml from the previous tutorial and split it into two networks: frontend and backend. The API should be on both. Postgres and Redis should only be on backend. If you have a web or proxy service, put it only on frontend and publish only its port.

Then verify that your internal URLs use service names (db, redis, api) and not localhost. If you find a localhost inside an environment variable used between containers, don't look at it fondly: change it.


You now have the right mental map: Compose creates a default network, services discover each other by name, ports is for reaching your machine, and custom networks let you isolate what should not talk directly. In the next tutorial we'll close the Compose module with best practices: environment-specific files, secrets, resource limits, logging, and the small details that separate a useful docker-compose.yml from one that looks written during a power outage.

Never stop coding!

Top comments (0)