You have the four services declared and you have mastered the CLI. What is missing is what truly turns four containers into an application: how they find each other, what is allowed to talk to what, and in what order they have to be ready.

In this lesson you assemble the definitive Aurora Libros stack: internal DNS, two segmented networks so the web front end cannot touch the database, coordinated startup with real health probes, a migration service that runs once and finishes, and the retry pattern you need even when everything above is right. By the end, the fifteen onboarding steps from lesson 01-07 will be down to two.

Contents

  1. The final architecture
  2. How services find each other: Compose's DNS
  3. Who talks to whom and over which port
  4. Segmentation into two networks
  5. Demonstration: the web front end cannot reach the database
  6. Startup order: why a bare depends_on is not enough
  7. Real health probes and condition: service_healthy
  8. A service that runs once: service_completed_successfully
  9. Why the application must retry anyway
  10. The complete, commented compose.yaml
  11. End-to-end startup
  12. The onboarding tally

  1. The final architecture

graph TB
    U["Browser<br/>localhost:8080"] --> W
    subgraph FRONTEND["frontend network (bridge)"]
        W["aurora-web<br/>nginx:alpine<br/>reverse proxy"]
        A1["aurora-api"]
    end
    subgraph BACKEND["backend network (internal: true)"]
        A2["aurora-api<br/>node:22-alpine :3000"]
        D["aurora-db<br/>postgres:16 :5432"]
        C["aurora-cache<br/>redis:7 :6379"]
        M["aurora-migrations<br/>runs once"]
    end
    W -->|"proxy /api → aurora-api:3000"| A1
    A1 -.same container.- A2
    A2 -->|"SQL :5432"| D
    A2 -->|"cache-aside :6379"| C
    M -->|"applies the schema"| D
    D --> V[("volume<br/>aurora-data")]

aurora-api is the only service that sits on both networks: it is the border. Everything coming in from outside goes through aurora-web, and everything that touches the data lives on a network with no way out.

  1. How services find each other: Compose's DNS

In lesson 03-05 you saw that a user-defined bridge network comes with an internal DNS server at 127.0.0.11. Compose leans on exactly that, with one precision worth committing to memory:

A service's host name is the service name, not the container's.

The container is called aurora-libros-aurora-db-1, but from the API you connect to aurora-db. Compose registers these names in each network's DNS:

Registered name Origin
aurora-db The service name (always)
database Any aliases you declare
aurora-libros-aurora-db-1 The container name

Aliases are extremely useful when migrating: if legacy code looks for postgres, you add aliases: [postgres] and it works without touching a single line of the application.

docker compose exec aurora-api getent hosts aurora-db aurora-cache
172.21.0.3   aurora-db
172.21.0.4   aurora-cache

One important consequence: resolution is per network. A service only resolves the names of the services it shares a network with. That is the basis of the segmentation in section 4.

  1. Who talks to whom and over which port

Here is the most repeated conceptual mistake with Compose: using the published port instead of the internal one. Publishing (ports) is a door from the host inwards; between containers it plays no part whatsoever.

Source Destination Host it uses Port Network
Browser aurora-web localhost 8080 (published)
aurora-web aurora-api aurora-api 3000 (internal) frontend
aurora-api aurora-db aurora-db 5432 (internal) backend
aurora-api aurora-cache aurora-cache 6379 (internal) backend
aurora-migrations aurora-db aurora-db 5432 (internal) backend

Only the first row uses a published port, because it is the only one that comes in from the host. The rest travels over Docker's internal network.

This is reflected in the web/nginx.conf you already wrote in module 3, where proxy_pass http://aurora-api:3000/; uses the service name and the internal port. And in the API's variables: DB_HOST=aurora-db, REDIS_HOST=aurora-cache. No localhost, no IP addresses, no published ports.

  1. Segmentation into two networks

With a single network, all four services can see each other. It works, but it means that if somebody compromises the nginx container —the most exposed one, the only one receiving traffic from outside— they get direct access to PostgreSQL's port 5432. Good practice is to segment by zone:

Network internal Services Purpose
frontend no aurora-web, aurora-api Inbound traffic and reverse proxy
backend yes aurora-api, aurora-db, aurora-cache, aurora-migrations Data, with no route to the Internet
networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true

internal: true does two things: containers on that network have no route to the outside (they cannot even ping the Internet) and nobody outside the host can reach them. If a compromised PostgreSQL dependency tried to phone home, it would have no way to.

A side effect worth knowing: a container connected only to internal networks cannot publish ports on the host. That is why aurora-db stops publishing 127.0.0.1:5432; to get at the database you will use docker compose exec, and the development environment (lesson 04-07) will expose it again by adding the service to a non-internal network.

  1. Demonstration: the web front end cannot reach the database

Segmentation is not theory: you can check it.

docker compose exec aurora-web getent hosts aurora-api
docker compose exec aurora-web getent hosts aurora-db
172.21.0.5   aurora-api

The first one resolves; the second returns nothing and exits with code 2. aurora-web and aurora-db share no network, so as far as nginx is concerned the database simply does not exist in DNS. With the network toolbox from lesson 03-04, the difference between the two networks is stark:

docker run --rm --network aurora-libros_frontend nicolaka/netshoot nc -zv -w 2 aurora-db 5432
docker run --rm --network aurora-libros_backend nicolaka/netshoot nc -zv -w 2 aurora-db 5432
docker compose exec aurora-db ping -c 1 -W 2 8.8.8.8
nc: getaddrinfo for host "aurora-db" port 5432: Name does not resolve
Connection to aurora-db (172.21.0.3) 5432 port [tcp/*] succeeded!
ping: sendto: Network is unreachable

From frontend the name does not even resolve; from backend it connects; and the database itself has no route to the Internet. That is the difference between "it works" and "it works and it also contains the damage". Full hardening arrives in lesson 05-03.

  1. Startup order: why a bare depends_on is not enough

Try what happens with no condition at all, with depends_on: [aurora-db] on the API:

docker compose down && docker compose up -d && sleep 2 && docker compose logs aurora-api --tail 3
aurora-api-1  | Error: connect ECONNREFUSED 172.21.0.3:5432
aurora-api-1  | Failed to connect to the database, exiting
aurora-api-1  | exited with code 1

Compose kept its promise: it started aurora-db first. The problem is what "first" means. There are three distinct states and only the third one is any use:

State Means Does it accept connections?
Created The container exists No
Started The process is running (short depends_on) Not yet
Ready (healthy) The health probe passes Yes

PostgreSQL takes between 3 and 10 seconds from the moment the process starts to the moment it accepts connections, and on the first startup, with init.sql still to run, considerably longer. The distinction between "started" and "ready" is what breaks half the compose.yaml files out there.

  1. Real health probes and condition: service_healthy

The solution has two halves. First: probes that tell the truth about each service.

Service test Why that check
aurora-db pg_isready -U aurora -d aurora_books PostgreSQL's official tool: it returns 0 only when it accepts connections on that database. During initialization the port is open but connections are refused
aurora-cache redis-cli ping It returns PONG and exit code 0 only if the server really answers
aurora-api wget --spider -q http://localhost:3000/health Its own endpoint, which in turn checks the database and the cache
aurora-web wget --spider -q http://localhost/ nginx is serving the page

Second half: declaring the dependency on health, not on startup.

  aurora-api:
    depends_on:
      aurora-db:
        condition: service_healthy
      aurora-cache:
        condition: service_healthy

Now Compose does not launch the API until both probes pass. If one never reaches healthy, up --wait fails with a clear message instead of leaving you a service restarting in a loop.

  1. A service that runs once: service_completed_successfully

Aurora Libros needs to apply the schema and the migrations before the API serves requests. That is not a permanent service: it is a task that runs, finishes and disappears.

  aurora-migrations:
    image: auroralibros/aurora-api:1.2.0   # same image, different command
    command: ["node", "migrate.js"]
    environment:
      DB_HOST: aurora-db
      DB_USER: aurora
      DB_PASSWORD: aurora_secret
      DB_NAME: aurora_books
    depends_on:
      aurora-db:
        condition: service_healthy
    restart: "no"          # it must NOT restart when it finishes
    networks: [backend]

And the API waits for it to finish successfully:

    depends_on:
      aurora-migrations:
        condition: service_completed_successfully

Two details people overlook: restart: "no" is essential —with an unless-stopped inherited from an anchor, the container would restart in an endless loop every time it finished— and so are the quotes, because a bare no is the boolean false in YAML. On top of that, the condition demands exit code 0: if the migration fails, the API is not even created, and that is precisely the right behavior.

  1. Why the application must retry anyway

With everything above, cold startup is solved. But not the other cases: if aurora-db restarts at three in the morning because of a disk failure, the API is already running and depends_on is not evaluated again; in an orchestrator, containers are rescheduled in any order; and a two-second network blip is not something any YAML file can fix.

depends_on solves startup; reconnecting is the application's responsibility. Add a retry with exponential backoff to api/server.js:

async function connectWithRetries(createConnection, { attempts = 8, baseMs = 500 } = {}) {
  for (let i = 1; i <= attempts; i++) {
    try {
      return await createConnection();
    } catch (err) {
      if (i === attempts) throw err;
      // 0.5 s · 1 s · 2 s · 4 s ... with jitter so retries do not synchronize
      const delayMs = Math.min(baseMs * 2 ** (i - 1), 30_000) + Math.random() * 250;
      console.warn(`Connection failed (attempt ${i}/${attempts}): ${err.message}. ` +
                   `Retrying in ${Math.round(delayMs)} ms`);
      await new Promise((r) => setTimeout(r, delayMs));
    }
  }
}

const pool = await connectWithRetries(async () => {
  const p = new Pool({ host: process.env.DB_HOST, user: process.env.DB_USER,
                       password: process.env.DB_PASSWORD, database: process.env.DB_NAME });
  await p.query('SELECT 1');   // creating the pool is not enough: you have to test it
  return p;
});

Three design decisions: exponential backoff avoids hammering a database that is still starting up, the random jitter stops ten replicas from retrying in unison, and the SELECT 1 test is what separates "I have a connection object" from "the database is answering me". The cache deserves different treatment: if Redis is down, /books must keep serving from PostgreSQL with source: db. A cache is an accelerator, never a hard dependency.

  1. The complete, commented compose.yaml

# compose.yaml — Aurora Libros S.L. — full platform
name: aurora-libros

x-common: &common
  restart: unless-stopped
  logging:
    driver: json-file
    options: { max-size: "10m", max-file: "3" }
  labels: { com.auroralibros.project: "aurora-libros" }

services:

  # --- Data: PostgreSQL with the catalog ---------------------------------
  aurora-db:
    <<: *common
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: aurora
      POSTGRES_PASSWORD: aurora_secret
      POSTGRES_DB: aurora_books
    volumes:
      - aurora-data:/var/lib/postgresql/data
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U aurora -d aurora_books"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 30s      # the first initialization runs init.sql
    stop_grace_period: 30s   # room to close the checkpoint
    deploy:
      resources: { limits: { memory: 512M, cpus: "1.0", pids: 200 }, reservations: { memory: 256M } }
    networks: [backend]      # ONLY on the internal network

  # --- Cache: Redis in cache-aside mode ----------------------------------
  aurora-cache:
    <<: *common
    image: redis:7-alpine
    command: ["redis-server", "--maxmemory", "200mb", "--maxmemory-policy", "allkeys-lru"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    deploy:
      resources: { limits: { memory: 256M, cpus: "0.5", pids: 100 } }
    networks: [backend]

  # --- Migrations: runs once and finishes --------------------------------
  aurora-migrations:
    image: auroralibros/aurora-api:1.2.0
    command: ["node", "migrate.js"]
    environment:
      DB_HOST: aurora-db
      DB_USER: aurora
      DB_PASSWORD: aurora_secret
      DB_NAME: aurora_books
    depends_on:
      aurora-db: { condition: service_healthy }
    restart: "no"            # quoted: unquoted it would be the boolean false
    networks: [backend]

  # --- API: the border between the two networks --------------------------
  aurora-api:
    <<: *common
    build:
      context: ./api
      dockerfile: Dockerfile
    image: auroralibros/aurora-api:1.2.0
    environment:
      PORT: "3000"
      DB_HOST: aurora-db       # SERVICE name
      DB_USER: aurora
      DB_PASSWORD: aurora_secret
      DB_NAME: aurora_books
      REDIS_HOST: aurora-cache
    depends_on:
      aurora-db: { condition: service_healthy }
      aurora-cache: { condition: service_healthy }
      aurora-migrations: { condition: service_completed_successfully }
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 20s
    restart: on-failure:3
    deploy:
      resources: { limits: { memory: 256M, cpus: "1.0", pids: 100 }, reservations: { memory: 128M } }
    networks: [frontend, backend]   # the only service on both

  # --- Web: static page and reverse proxy --------------------------------
  aurora-web:
    <<: *common
    image: nginx:alpine
    ports:
      - "8080:80"            # the ONLY door from the host
    volumes:
      - ./web/index.html:/usr/share/nginx/html/index.html:ro
      - ./web/nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      aurora-api: { condition: service_healthy }
    stop_signal: SIGQUIT     # nginx shuts down cleanly with SIGQUIT
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost/"]
      interval: 15s
      timeout: 3s
      retries: 3
    deploy:
      resources: { limits: { memory: 128M, cpus: "0.5", pids: 50 } }
    networks: [frontend]     # no access to the data

volumes:
  aurora-data:

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true           # no route out to the Internet, no way in from outside

A hundred and thirty commented lines that replace fifty lines of bash and, above all, express things the script could not say: who depends on whom, what being ready means, and what is allowed to talk to what.

  1. End-to-end startup

cd ~/aurora-libros
docker compose down -v          # starting from scratch, this is a test environment
docker compose up -d --build --wait
[+] Running 8/8
 ✔ Network aurora-libros_frontend             Created
 ✔ Network aurora-libros_backend              Created
 ✔ Volume "aurora-libros_aurora-data"         Created
 ✔ Container aurora-libros-aurora-db-1         Healthy
 ✔ Container aurora-libros-aurora-cache-1      Healthy
 ✔ Container aurora-libros-aurora-migrations-1 Exited
 ✔ Container aurora-libros-aurora-api-1        Healthy
 ✔ Container aurora-libros-aurora-web-1        Healthy

Read the sequence: the two networks and the volume first, then database and cache up to Healthy, then the migrations up to Exited with code 0, and only then the API and the web front end. You no longer write the order: Compose deduces it from the dependency graph.

docker compose ps --format "table {{.Service}}\t{{.Status}}"
SERVICE              STATUS
aurora-api           Up 20 seconds (healthy)
aurora-cache         Up 46 seconds (healthy)
aurora-db            Up 46 seconds (healthy)
aurora-migrations    Exited (0) 32 seconds ago
aurora-web           Up 8 seconds (healthy)

The acid test: cache-aside in action and, after that, the resilience we demanded in section 9.

curl -s http://localhost:8080/api/books | jq '{source, total: (.books|length)}'
curl -s http://localhost:8080/api/books | jq '{source, total: (.books|length)}'
docker compose stop aurora-cache
curl -s http://localhost:8080/api/books | jq '{source, total: (.books|length)}'
docker compose start aurora-cache
{ "source": "db", "total": 9 }
{ "source": "cache", "total": 9 }
{ "source": "db", "total": 9 }

First request from PostgreSQL, second from Redis: the nine titles crossing web → api → db → cache exactly as designed, and http://localhost:8080 in the browser shows the full catalog. And with the cache stopped, the API keeps serving from the database: graceful degradation, you lose speed, not service.

  1. The onboarding tally

Point in time Steps to get Aurora Libros running
Lesson 01-07, without Docker 15 manual steps: Node, PostgreSQL, Redis, nginx, users, schema...
Module 3, with docker run A 50-line script that has to be maintained by hand
Now 2 commands
git clone https://github.com/auroralibros/plataforma.git && cd plataforma
docker compose up -d --wait

That is all. Someone joining the team today has the complete platform —four services, two networks, one volume, migrations applied and health probes verified— in under a minute, and with exactly the same configuration as the rest of the team, because it is in Git.

Common Mistakes and Tips

Using the published port between containers. DB_HOST=localhost:5432 from the API finds nothing: inside the container, localhost is the container itself. Service name and internal port, always.

Trusting depends_on without condition. It only guarantees startup order. Without health probes, you will still have races.

Probing the port instead of the service. nc -z aurora-db 5432 comes back green while PostgreSQL is still refusing connections. Use pg_isready.

Forgetting restart: "no" on a one-shot service. With an inherited policy, the migration restarts in a loop and service_completed_successfully is never met.

Assuming depends_on reconnects. It is not re-evaluated after startup. Reconnecting with retries is the application's job.

Treating the cache as a hard dependency. If your API dies because Redis is not answering, you have turned an optional accelerator into a single point of failure.

Tip: when two services cannot see each other, the first check is always docker compose exec <source> getent hosts <destination>. If it does not resolve, it is not an application problem: they simply do not share a network.

Exercises

Exercise 1. Add the network alias postgres to aurora-db and prove that the API can connect equally well through aurora-db or through postgres, but that aurora-web resolves neither of them.

Exercise 2. Break the startup on purpose: make aurora-migrations exit with code 1 (for example, command: ["sh", "-c", "echo 'migration failed'; exit 1"]) and observe what docker compose up -d --wait does with the API and the web front end. Explain the result and why it is the desirable behavior.

Exercise 3. Prove that backend's isolation is real in both directions: (a) aurora-db cannot reach the Internet, (b) from another machine on your local network port 5432 cannot be reached, and (c) aurora-api can reach the Internet. Explain why (c) works despite it being on the internal network.

Solutions

Solution 1.

  aurora-db:
    networks:
      backend:
        aliases: [postgres]
docker compose up -d
docker compose exec aurora-api getent hosts aurora-db
docker compose exec aurora-api getent hosts postgres
docker compose exec aurora-web getent hosts postgres; echo "exit code: $?"
172.21.0.3   aurora-db
172.21.0.3   postgres
exit code: 2

The same address for both names: the alias is an extra DNS entry on that network, not another container. For aurora-web, exit code 2 (it does not resolve), because aliases live inside the network in which they are declared and nginx is not on backend. This is the mechanism for migrating a service without touching its clients' code: first you add the new alias, then you change the service name.

Solution 2.

docker compose up -d --wait; echo "exit code: $?"
docker compose ps -a --format "table {{.Service}}\t{{.Status}}"
dependency failed to start: container aurora-libros-aurora-migrations-1 exited (1)
exit code: 1

SERVICE              STATUS
aurora-cache         Up 15 seconds (healthy)
aurora-db            Up 15 seconds (healthy)
aurora-migrations    Exited (1) 3 seconds ago

Neither aurora-api nor aurora-web ever gets created. Compose cuts the chain: if a dependency with service_completed_successfully exits with a non-zero code, everything depending on it is cancelled, and that failure cascades on to the web front end, which depends on the API.

That is exactly what you want. The alternative —starting the API against a half-migrated schema— would produce intermittent errors, corrupted data and an hour of debugging. On top of that, --wait returns exit code 1, so a CI pipeline goes red automatically instead of carrying on over a broken foundation. Compare with the module 3 bash script: there, if step 4 failed, steps 5, 6 and 7 ran anyway.

Solution 3.

# (a) the database has no route to the Internet
docker compose exec aurora-db ping -c 1 -W 2 1.1.1.1
# (b) port 5432 is not published on any interface
ss -ltnp | grep 5432 || echo "5432 not published on the host"
# (c) the API does have a way out
docker compose exec aurora-api wget -qO- -T 3 https://example.com > /dev/null \
  && echo "the API does have outbound access"
ping: sendto: Network is unreachable
5432 not published on the host
the API does have outbound access

(a) and (b) confirm the double isolation: internal: true removes that network's default route, and since aurora-db has no ports, there is no rule on the host redirecting traffic towards it —not even from the host itself, let alone from another machine on the local network.

(c) works because aurora-api is on both networks. A container connected to several networks has an interface on each one, and its default route comes from frontend, which is not internal. Hence the API being the "border": it is the only point through which the data zone communicates with the world, and therefore the only one you have to watch closely.

Conclusion

Aurora Libros is properly assembled now. You know that services find each other by the service name —not the container's— thanks to each network's internal DNS, that aliases add extra names within the network where they are declared, and that between containers you always use the internal port: publishing ports only opens a door from the host, and in this architecture there is exactly one, the web front end's 8080.

You have segmented the platform into two zones: a frontend one with the web front end and the API, and a backend one marked as internal with the data and the migrations, and you have demonstrated it in both directions —nginx cannot even resolve the database's name, and PostgreSQL has no route to the Internet— with aurora-api as the sole border by virtue of being on both networks. And you have startup solved with the distinction that separates a file that works on your laptop from one that always works: started is not the same as ready. pg_isready and redis-cli ping as honest probes, condition: service_healthy to wait until they are, service_completed_successfully with restart: "no" for the migration that runs once, and retry with exponential backoff and jitter in the application, because depends_on covers startup and nothing else. The cache, treated as what it is: an accelerator whose failure degrades the service but does not take it down.

The tally speaks for itself: from fifteen manual steps to a fifty-line script, and from there to git clone and docker compose up -d --wait. But the file still has passwords written in the clear, the image version pinned by hand and the ports hardcoded: exactly what stops you from using it in more than one environment. In the next lesson, Environment Variables in Docker Compose, you will separate the two systems everybody confuses —the ${VAR} substitution Compose performs on the YAML itself, and the variables the process inside the container sees— with their complete precedence table, the project's .env file and its versioned .env.example, and the secrets: block so that credentials stop being visible in a docker inspect.

Docker: From Beginner to Advanced

Module 1: Introduction to Docker

Module 2: Working with Docker Images

Module 3: Docker Containers

Module 4: Docker Compose

Module 5: Advanced Docker Concepts

Module 6: Docker in Production

Module 7: Docker Ecosystem and Tools

© Copyright 2026. All rights reserved