ποΈ Dockerizing a Multi-Container App
A real-world app usually has multiple moving parts working together. In this guide weβll containerize a full-stack app consisting of:
- βοΈ React (Vite) β the frontend
- π’ Node.js + Express β the backend API
- π PostgreSQL β the database
Each piece runs in its own container. Docker Compose ties them all together.
ποΈ Project Structure
Hereβs what the project looks like before we add Docker files:
my-app/βββ frontend/ β React Vite appβ βββ src/β βββ index.htmlβ βββ package.jsonβββ backend/ β Node.js Express APIβ βββ index.jsβ βββ package.jsonβββ docker-compose.yml β ties everything togetherEach service (frontend, backend, database) gets its own Dockerfile inside its folder. PostgreSQL uses an official image β no Dockerfile needed for it.
π³ Backend Dockerfile
Create backend/Dockerfile:
FROM node:22-alpineWORKDIR /appCOPY package*.json ./RUN npm installCOPY . .ENV PORT=4000CMD ["node", "index.js"]βοΈ Frontend Dockerfile (Multi-Stage)
Create frontend/Dockerfile:
# ---- Stage 1: Build ----FROM node:22-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm installCOPY . .RUN npm run build# Vite outputs the production build to /app/dist
# ---- Stage 2: Serve ----FROM nginx:alpineCOPY --from=builder /app/dist /usr/share/nginx/htmlEXPOSE 80CMD ["nginx", "-g", "daemon off;"]Why two stages?
| Stage | Base image | What it does |
|---|---|---|
builder | node:22-alpine | Installs deps and runs npm run build β produces /app/dist |
| final | nginx:alpine | Copies only the built /dist files and serves them on port 80 |
The final image has no Node.js or source code β just the static HTML/CSS/JS and Nginx. This makes it much smaller and safer for production.
π The docker-compose.yml
Create docker-compose.yml at the project root:
version: '3.8'
services: frontend: build: ./frontend ports: - "80:80" depends_on: - backend networks: - app-network
backend: build: ./backend ports: - "4000:4000" environment: - DATABASE_URL=postgres://postgres:secret@db:5432/mydb depends_on: - db networks: - app-network
db: image: postgres:16-alpine restart: always environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: secret POSTGRES_DB: mydb volumes: - pgdata:/var/lib/postgresql/data networks: - app-network
volumes: pgdata:
networks: app-network: driver: bridgeKey things to notice:
| Setting | What it does |
|---|---|
build: ./frontend | Builds the image from frontend/Dockerfile |
depends_on: backend | Frontend waits for backend to start first |
DATABASE_URL | Backend connects to db by service name (not localhost) |
depends_on: db | Backend waits for Postgres to start |
volumes: pgdata | Database data persists even after containers are removed |
networks: app-network | Attaches each service to the shared custom network |
driver: bridge | Standard Docker bridge network β services can talk to each other by name |
π How They All Talk to Each Other
Docker Compose puts all services on a shared network. Each service is reachable by its service name:
| From | To | Hostname used |
|---|---|---|
| Browser | Frontend | http://localhost:80 (or just http://localhost) |
| Browser | Backend | http://localhost:4000 |
| Frontend (inside Docker) | Backend | http://backend:4000 |
| Backend | PostgreSQL | postgres://...@db:5432/mydb |
Important: Inside the Docker network, services use each otherβs service names, not
localhost.
π Start the App
docker compose up --build--buildβ forces Docker to rebuild images (useful when youβve changed code)
After a few seconds:
- βοΈ Frontend: http://localhost (Nginx serving on port 80)
- π’ Backend API: http://localhost:4000
- π PostgreSQL running silently in the background
π Stop the App
docker compose downThis stops all containers. Your database data is safe thanks to the pgdata volume.
To also wipe the database volume:
docker compose down -vβ Quick Summary
- Each custom service (frontend, backend) gets its own
Dockerfile - Official services like PostgreSQL use
image:β no Dockerfile needed docker-compose.ymlat the project root connects them all- Services talk to each other using their service name as the hostname
depends_oncontrols startup order- Named
volumeskeep your database data safe across restarts