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:
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')){
"name": "api",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "^4.19.0",
"pg": "^8.11.0"
}
}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
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 getsAPI_URL=http://api:4000. Notice the hostname isapi— 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 usingdbas the hostname, again the service name.db— uses the officialpostgresimage directly, no custom Dockerfile needed.POSTGRES_USER,POSTGRES_PASSWORD, andPOSTGRES_DBare 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/datainside the container. Mounting a named volume there means the data survives even afterdocker compose down.depends_on— controls start order.apiwaits fordb’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
docker compose up --build--build forces Docker to rebuild web and api if their Dockerfiles changed.
Confirm each service works
curl http://localhost:3000
curl http://localhost:4000/healthThe 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
docker compose exec web wget -qO- http://api:4000/healthThis is the same proof from Container Networking, now happening inside a stack Compose built for you.
Hands-on Task
- Build the three-service stack above with your own Next.js app as
web. - Confirm
localhost:4000/healthreturns a successful response with a database timestamp. - Run
docker compose down, thendocker compose upagain — confirm the database still has the same data (becausedb-datais a named volume). - Run
docker compose down -vthis time, then start again — confirm PostgreSQL reinitializes from scratch, since the volume was deleted too.
Quick Check
- Can you explain why
apiconnects todb, not to an IP address? - Do you understand why
db-dataneeds to be a named volume, not left out entirely? - Can you explain what
depends_onguarantees, and what it does not guarantee?
Next → MongoDB Alternative