3.3Running example: web application + database
The essentials in 30 seconds
- Three services, one network: api (local build), bdd (postgres + volume), adminer (dev console) — the api reaches bdd:5432 by name, with no hard-coded IP.
- The connection URL travels in a variable: DATABASE_URL read by the api — no hard-coded host in the code, config changes without touching the image.
- depends_on orders, retry hardens: the database starts more slowly — the api retries its connection at startup, otherwise the first launch crashes.
Running mini-case — Idir wires up the team's mock-up
Idir, junior DevOps in Algiers, must deliver an API + database mock-up that everyone launches with one command: his first attempt hard-codes the database IP and crashes every other startup. He switches to name-based wiring (the api reaches bdd:5432), to the URL in the DATABASE_URL variable, to the persistent volume — and adds a retry loop on the api side. Result: up -d --build, the api answers on :3000, adminer on :8081, the database is reachable only from inside; down then up: the table created the day before is still there.
This same case returns in the lesson, the diagram, the script and the 48h challenge.
1. Three services, one network, names — not IPs
A real app is api (YOUR image, local build from ./api), bdd (postgres + named volume) and adminer (lightweight web SQL admin console, published in dev). Compose connects them on the same network: the api reaches bdd:5432 by service name — IPs change on every recreation, names stay. Never a hard-coded IP in code: this is the rule that makes the stack portable.
The api reads DATABASE_URL as a variable (host = bdd, database, credentials): config travels, code stays identical between dev and prod. We publish the api (:3000) and adminer (:8081, dev only); the database is NEVER published — reachable only from inside. Minimal exposed surface: only what must go out, goes out.
2. Order is not enough: mandatory retry, proven persistence
depends_on starts the database before the api, but not its AVAILABILITY: postgres takes several seconds to accept connections. Without a retry loop at startup, the api crashes on the first launch then "works" on the second — the classic symptom. The difference between a demo and a stack that survives restarts is this retry with delay on the application side (healthcheck schedules on top, level 4.2).
The proof that everything holds: down then up — the table created via adminer must survive, because it lives in the volume, not in the container. Idir makes it his systematic acceptance test: what does not survive a restart is not persisted, period. Persisted database, never published, retry at startup: the trio of a healthy stack.
🔎 Concrete example — API + Postgres + Adminer stack, wired and persisted
Idir writes the mock-up compose: variables, volume, network, opt-in adminer in dev.
services:
api:
build: ./api
ports: ["3000:3000"]
environment:
DATABASE_URL: ${DATABASE_URL}
depends_on: [bdd]
networks: [appnet]
bdd:
image: postgres:16.4
environment:
POSTGRES_USER: ${POSTGRES_USER:-app}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-appdb}
volumes: [pgdata:/var/lib/postgresql/data]
networks: [appnet]
adminer:
image: adminer:4.8
profiles: [dev]
ports: ["8081:8080"]
networks: [appnet]
volumes:
pgdata:
networks:
appnet:
# Lancer : docker compose --profile dev up -d --build
Reading: api and adminer are published, the database is not — reachable from inside.
Lesson: open :3000 (api) and :8081 (adminer); down/up proves the table survives.
Section vocabulary
- DATABASE_URL
- The full connection URL passed as a variable: host = service name, never a hard-coded IP.
Simple example: Idir's api reads ${DATABASE_URL} pointing to bdd.
What it is not: yesterday's IP found in inspect, copied into code.
Reflex: host = service name, config = variable.
- Retry
- The reconnection loop at startup: the api retries until the database accepts.
Simple example: The retry that cures the first-startup crash.
What it is not: a single connection that fails if the database is slow.
Reflex: every connection at startup = retry with delay.
- Adminer
- Lightweight web SQL admin console, published in dev to see and fill the database.
Simple example: Idir creates the test table on :8081.
What it is not: the database published in production "to debug".
Reflex: console in dev only, never in prod.
- Local build
- Service built from a folder (./api) instead of a remote image: your containerised code.
Simple example: The api image built at up --build.
What it is not: code copied by hand into an nginx container.
Reflex: --build after each code change.
- Exposed surface
- All published ports: minimise it — only what is needed goes out.
Simple example: api + adminer (dev) published, bdd internal.
What it is not: postgres published on the host "just in case".
Reflex: every published port must justify its existence.
In practice — Set up the wired, persisted mini-app
- Create ./api (Dockerfile + server reading DATABASE_URL with retry at startup).
- Write the compose above (URL in a variable, database unpublished) and run up -d --build.
- Check the api (:3000) and adminer (:8081, dev profile), create a table.
- Restart everything (down then up): the table must survive — otherwise the volume is badly mounted.
Ready-to-say script — delivering the mock-up to the team (30 s)
"One command: up -d --build with the dev profile. The api answers on 3000, the database console on 8081, the database is reachable only from inside. The URL travels in a variable, retry absorbs startup, and down/up loses nothing — tested."
Say when delivering the mock-up; show the down/up with the table intact.
Copy-paste template — API + database + console (valid)
services:
api:
build: ./api
ports:
- "${PORT:-3000}:3000"
environment:
DATABASE_URL: ${DATABASE_URL}
depends_on:
- bdd
networks:
- appnet
bdd:
image: postgres:16.4
environment:
POSTGRES_USER: ${POSTGRES_USER:-app}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-appdb}
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- appnet
adminer:
image: adminer:4.8
profiles:
- dev
ports:
- "8081:8080"
networks:
- appnet
volumes:
pgdata:
networks:
appnet:
# .env local (NON commité) : DATABASE_URL, POSTGRES_PASSWORD
Freelancer
Name-based wiring + URL in a variable and your mock-up starts at every client.
SME in Algiers
Idir delivers the mock-up with one command: api on 3000, console on 8081, persisted database.
Project team
Standardise DATABASE_URL + startup retry for all team services.
Beginner
This week: api + database wired by names, table created, down/up proving persistence.
✓ Best practices
- Join by service names, configure with environment variables.
- Add a connection retry at startup for each service using the database.
- Keep the admin console in dev only (dev profile), minimal surface in prod.
✗ Common pitfalls
- Hard-coded IP in code: it changes on every recreation.
- Believing depends_on = ready: without retry, the first startup crashes.
- Publishing the database in production: attack surface widened for nothing.
Key takeaways
- ONE NETWORK, names, one URL in a variable: bdd:5432 via DATABASE_URL, never an IP.
- PERSISTED database (named volume) and NEVER published: internal only.
- MANDATORY retry at startup: depends_on orders, retry really waits.
- DOWN/UP proves persistence: the table must survive the restart.
48h challenge — apply tomorrow
Set up api + database wired by names with retry, and prove persistence with a down/up in front of a colleague.
- My services join by names via variables.
- My database is persisted and unpublished.
- My api retries its connection at startup.
Frequently asked questions
The api crashes on first startup then works: normal?
YES, if it does not retry: the database starts more slowly than the api (depends_on orders without waiting). Add a retry loop with delay at api startup — the crash disappears. Level 4 addition: database-side healthcheck + startup condition on the api side to schedule cleanly.
Where to really put the password and URL?
In DEV: uncommitted .env next to the compose (documented by a dummy .env.example). In PROD: managed secret (mounted file, CI, orchestrator). NEVER in plain text in the committed compose, never in the image. And the database stays unpublished: the secret only travels on the internal Compose network.
The api must reach postgres on the Compose network. Which healthy URL?