3.3 · Running example: web application + database

Level 3 · Docker Compose

3.3Running example: web application + database

Goal: run a real mini-application (API + database) wired by names.
Estimated time: 15 min · self-paced reading · If you only have 5 min: read “The essentials in 30s” + the diagram.

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.

Common misinterpretation: "depends_on is enough for availability, and publishing the database helps debugging". Wrong, twice. depends_on orders without waiting: without retry, the first startup crashes — it is normal and predictable, not a mystery. And publishing postgres on the host (especially in production) widens the attack surface for illusory comfort: debug via adminer in dev and exec in prod, never by exposing the database.
Wired, persisted, lean app Names, variable, retry, minimal surface Wire by namesapi → bdd:5432, URL in DATABASE_URL+Persist + retryNamed volume, reconnection loop+Expose the minimumapi and adminer (dev) published, database internal down/up proves persistence; retry proves robustness.
Names, variable, retry, minimal surface
Text version of the diagram (SEO / screen readers / translation): 1) Wire: Idir makes the api reach bdd:5432 by name via DATABASE_URL instead of a hard-coded IP. 2) Harden: retry at startup on the api side (depends_on orders, retry really waits) + named volume for the database. 3) Expose: api and adminer published, database internal only — down/up proves the table survives. Lesson: names, variable, retry, minimal surface.

🔎 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

  1. Create ./api (Dockerfile + server reading DATABASE_URL with retry at startup).
  2. Write the compose above (URL in a variable, database unpublished) and run up -d --build.
  3. Check the api (:3000) and adminer (:8081, dev profile), create a table.
  4. Restart everything (down then up): the table must survive — otherwise the volume is badly mounted.
A real multi-service app runs, wired by names, persisted and with minimal surface.

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.

Caution: Passwords and connection URL via the local .env, never hard-coded: ${DATABASE_URL} and ${POSTGRES_PASSWORD} in the compose, real values only in the uncommitted .env. In production: managed secret (file, CI, orchestrator) — never the committed compose in plain text, never the published database.

✓ 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.

Section exit checklist
  • My services join by names via variables.
  • My database is persisted and unpublished.
  • My api retries its connection at startup.
Self-rating 1-5: is there still a hard-coded IP in my code or compose?does my stack survive a down/up without loss?

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.

Check your understanding

The api must reach postgres on the Compose network. Which healthy URL?

Additional resources (optional, to go further)

Level 3 examFresh, harder questions to test what you've learned