Skip to content

πŸ—οΈ 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 together

Each 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-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
ENV PORT=4000
CMD ["node", "index.js"]

βš›οΈ Frontend Dockerfile (Multi-Stage)

Create frontend/Dockerfile:

# ---- Stage 1: Build ----
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Vite outputs the production build to /app/dist
# ---- Stage 2: Serve ----
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Why two stages?

StageBase imageWhat it does
buildernode:22-alpineInstalls deps and runs npm run build β†’ produces /app/dist
finalnginx:alpineCopies 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: bridge

Key things to notice:

SettingWhat it does
build: ./frontendBuilds the image from frontend/Dockerfile
depends_on: backendFrontend waits for backend to start first
DATABASE_URLBackend connects to db by service name (not localhost)
depends_on: dbBackend waits for Postgres to start
volumes: pgdataDatabase data persists even after containers are removed
networks: app-networkAttaches each service to the shared custom network
driver: bridgeStandard 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:

FromToHostname used
BrowserFrontendhttp://localhost:80 (or just http://localhost)
BrowserBackendhttp://localhost:4000
Frontend (inside Docker)Backendhttp://backend:4000
BackendPostgreSQLpostgres://...@db:5432/mydb

Important: Inside the Docker network, services use each other’s service names, not localhost.


πŸš€ Start the App

Terminal window
docker compose up --build
  • --build β†’ forces Docker to rebuild images (useful when you’ve changed code)

After a few seconds:


πŸ›‘ Stop the App

Terminal window
docker compose down

This stops all containers. Your database data is safe thanks to the pgdata volume.

To also wipe the database volume:

Terminal window
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.yml at the project root connects them all
  • Services talk to each other using their service name as the hostname
  • depends_on controls startup order
  • Named volumes keep your database data safe across restarts