Skip to Content

The Full Stack

Estimated time: 30–40 minutes

This page connects three services: your Next.js frontend, a small Express API, and a PostgreSQL database. By the end, one command starts all three, already able to reach each other.

Project Layout

Compose does not require your services to live in one folder. A common layout keeps each service in its own subfolder, each with its own Dockerfile. One compose.yaml at the root ties them together.

      • Dockerfile
      • package.json
      • (your Next.js app)
      • Dockerfile
      • package.json
      • index.js
    • compose.yaml

A Minimal Express API

The API only needs to prove one thing: it can reach the database. Create api/index.js:

api/index.js
const express = require('express') const { Pool } = require('pg') const app = express() const pool = new Pool({ connectionString: process.env.DATABASE_URL }) app.get('/health', async (req, res) => { const result = await pool.query('SELECT NOW()') res.json({ status: 'ok', dbTime: result.rows[0].now }) }) app.listen(4000, () => console.log('API running on port 4000'))
api/package.json
{ "name": "api", "version": "1.0.0", "main": "index.js", "scripts": { "start": "node index.js" }, "dependencies": { "express": "^4.19.0", "pg": "^8.11.0" } }
api/Dockerfile
FROM node:20-alpine WORKDIR /app COPY package.json package-lock.json* ./ RUN npm install COPY . . EXPOSE 4000 CMD ["npm", "start"]

This Dockerfile follows the same pattern from Building Images: dependency files copied first, then the rest, so the cache works the same way. No multi-stage build here — this API is small enough that it does not matter yet.

The Compose File

compose.yaml
services: web: build: context: ./web ports: - "3000:3000" environment: - API_URL=http://api:4000 depends_on: - api api: build: context: ./api ports: - "4000:4000" environment: - DATABASE_URL=postgresql://appuser:secret@db:5432/appdb depends_on: - db db: image: postgres:16-alpine environment: - POSTGRES_USER=appuser - POSTGRES_PASSWORD=secret - POSTGRES_DB=appdb volumes: - db-data:/var/lib/postgresql/data volumes: db-data:

Reading This File Piece by Piece

  • web — builds from ./web, and gets API_URL=http://api:4000. Notice the hostname is api — the service name from this same file, not an IP address. This is exactly what you proved by hand in Container Networking: Compose creates a shared network for every service automatically.
  • api — builds from ./api, and connects to the database using db as the hostname, again the service name.
  • db — uses the official postgres image directly, no custom Dockerfile needed. POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DB are read by that image on first startup to create a user, password, and database automatically.
  • db-data — a named volume, the concept from Data & Networking. Postgres stores its actual files at /var/lib/postgresql/data inside the container. Mounting a named volume there means the data survives even after docker compose down.
  • depends_on — controls start order. api waits for db’s container to start before starting itself. This is not the same as waiting for Postgres to be ready to accept connections — the next page fixes that gap.

Running the Stack

Start everything

Terminal
docker compose up --build

--build forces Docker to rebuild web and api if their Dockerfiles changed.

Confirm each service works

Terminal
curl http://localhost:3000 curl http://localhost:4000/health

The second command should return JSON with "status":"ok" and the current database time — proof the API reached PostgreSQL.

Confirm web can reach api by name

Terminal
docker compose exec web wget -qO- http://api:4000/health

This is the same proof from Container Networking, now happening inside a stack Compose built for you.

Hands-on Task

  1. Build the three-service stack above with your own Next.js app as web.
  2. Confirm localhost:4000/health returns a successful response with a database timestamp.
  3. Run docker compose down, then docker compose up again — confirm the database still has the same data (because db-data is a named volume).
  4. Run docker compose down -v this time, then start again — confirm PostgreSQL reinitializes from scratch, since the volume was deleted too.

Quick Check

  • Can you explain why api connects to db, not to an IP address?
  • Do you understand why db-data needs to be a named volume, not left out entirely?
  • Can you explain what depends_on guarantees, and what it does not guarantee?

Next → MongoDB Alternative

docker compose nextjs express postgresql example, docker compose multi service, docker compose database volume

Last updated on