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
- The final architecture
- How services find each other: Compose's DNS
- Who talks to whom and over which port
- Segmentation into two networks
- Demonstration: the web front end cannot reach the database
- Startup order: why a bare
depends_onis not enough - Real health probes and
condition: service_healthy - A service that runs once:
service_completed_successfully - Why the application must retry anyway
- The complete, commented
compose.yaml - End-to-end startup
- The onboarding tally
- 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.
- 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.
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.
- 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.
- 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 |
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.
- 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-dbThe 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.8nc: 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 unreachableFrom 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.
- Startup order: why a bare
depends_on is not enough
depends_on is not enoughTry what happens with no condition at all, with depends_on: [aurora-db] on the API:
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 1Compose 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.
- Real health probes and
condition: service_healthy
condition: service_healthyThe 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_healthyNow 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.
- A service that runs once:
service_completed_successfully
service_completed_successfullyAurora 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:
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.
- 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.
- The complete, commented
compose.yaml
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 outsideA 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.
- 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 HealthyRead 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.
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-cacheFirst 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.
- 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 --waitThat 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.
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: $?"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 agoNeither 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
- What Is Docker?
- Installing Docker
- Docker Architecture
- Basic Docker Commands
- Understanding Docker Images
- Creating Your First Docker Container
- The Course Project: The Aurora Libros Platform
Module 2: Working with Docker Images
- Docker Hub and Repositories
- Building Docker Images
- Dockerfile Basics
- Advanced Dockerfile Instructions
- Managing Docker Images
- Tagging and Publishing Images
Module 3: Docker Containers
- Running Containers
- Container Lifecycle
- Managing Containers
- Inspecting and Debugging Containers
- Docker Networking
- Data Persistence with Volumes
- Resource Limits and Restart Policies
Module 4: Docker Compose
- Introduction to Docker Compose
- Defining Services in Docker Compose
- Docker Compose Commands
- Multi-Container Applications
- Environment Variables in Docker Compose
- Profiles, Overrides and Multiple Environments
- Local Development with Docker Compose
Module 5: Advanced Docker Concepts
- Docker Networking Deep Dive
- Docker Storage Options
- Docker Security Best Practices
- Optimizing Docker Images
- Advanced Builds with BuildKit and Buildx
- Logging and Monitoring in Docker
- The Runtime Inside: Namespaces, Cgroups and Layers
Module 6: Docker in Production
- Preparing an Image for Production
- CI/CD with Docker
- Orchestrating Containers with Docker Swarm
- Introduction to Kubernetes
- Deploying Docker Containers in Kubernetes
- Scaling and Load Balancing
- Deployment Strategies and Rollback
