Multi-Container Project
Table of Contents
- Project Overview
- Backend: NestJS + PostgreSQL + Redis
- Frontend: React/Vite
- Compose: Wiring It All Together
- Networks
- Environment Variables
- Dependencies (Startup Order)
- Persistent Data
- Run It
Time to combine everything from Parts 8–10 into a real multi-container application. The full project lives in multi-container-project/ and is composed of four services: a React/Vite frontend, a NestJS backend, a PostgreSQL database, and a Redis cache—all orchestrated with a single docker-compose.yml.
1. Project Overview
multi-container-project/
├── docker-compose.yml
├── .env.example
├── frontend/ # React (Vite) - talks to the backend API
│ ├── Dockerfile
│ ├── package.json
│ └── src/
└── backend/ # NestJS - exposes /health, /db-check, /cache-check
├── Dockerfile
├── package.json
└── src/
- Frontend fetches
/healthfrom the backend on load and renders the response. - Backend exposes three endpoints:
/health(liveness),/db-check(round-trips a query to PostgreSQL), and/cache-check(reads/writes a key in Redis). - PostgreSQL and Redis run as their own services using official images—no custom Dockerfile needed for either.
2. Backend: NestJS + PostgreSQL + Redis
The backend (backend/src/app.service.ts) opens a pg.Pool for PostgreSQL and an ioredis client for Redis, both configured entirely from environment variables so the same code runs unmodified across environments:
this.pool = new Pool({
host: this.config.get("DB_HOST", "db"),
port: Number(this.config.get("DB_PORT", "5432")),
user: this.config.get("DB_USER", "postgres"),
password: this.config.get("DB_PASSWORD", "postgres"),
database: this.config.get("DB_NAME", "app_db"),
});
this.redis = new Redis({
host: this.config.get("REDIS_HOST", "redis"),
port: Number(this.config.get("REDIS_PORT", "6379")),
});
Notice the hosts are db and redis—the service names from docker-compose.yml, resolved automatically by Docker's embedded DNS (see Part 9, section 6).
# backend/Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "start:dev"]
3. Frontend: React/Vite
The frontend (frontend/src/App.jsx) reads the backend's URL from a Vite environment variable and calls its /health endpoint:
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:3000";
useEffect(() => {
fetch(`${API_URL}/health`)
.then((res) => res.json())
.then(setHealth);
}, []);
# frontend/Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]
4. Compose: Wiring It All Together
All four services are defined in docker-compose.yml. frontend and backend use build (custom Dockerfiles); db and redis use official images directly:
services:
frontend:
build: ./frontend
ports:
- "5173:5173"
environment:
VITE_API_URL: http://localhost:3000
depends_on:
- backend
backend:
build: ./backend
ports:
- "3000:3000"
environment:
DB_HOST: db
REDIS_HOST: redis
depends_on:
- db
- redis
db:
image: postgres:16-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
5. Networks
No network is explicitly created by hand—Compose gives every service in the file a shared app-net bridge network, so frontend, backend, db, and redis can all resolve each other by service name (recap: Part 10, section 4).
networks:
app-net:
driver: bridge
6. Environment Variables
Database credentials are pulled from a .env file at the project root (never committed—only .env.example is), with sane defaults inline via ${VAR:-default} syntax:
environment:
DB_USER: ${DB_USER:-postgres}
DB_PASSWORD: ${DB_PASSWORD:-postgres}
DB_NAME: ${DB_NAME:-app_db}
Copy the example file before first run:
cp .env.example .env
7. Dependencies (Startup Order)
depends_on chains the startup order: frontend waits on backend, and backend waits on db and redis. As covered in Part 10, section 3, this only controls start order, not readiness—the backend's pg.Pool and ioredis client both retry/queue connections internally, so brief startup races are handled gracefully here.
8. Persistent Data
Both stateful services mount a named volume so data survives docker compose down (without -v):
volumes:
postgres_data: # PostgreSQL data directory
redis_data: # Redis RDB/AOF persistence directory
This is the same pattern from Part 8: Docker Volumes—the volumes are managed by Docker and can be backed up/restored exactly as described there.
9. Run It
cd multi-container-project
cp .env.example .env
# Build images and start every service
docker compose up --build
# Frontend: http://localhost:5173
# Backend: http://localhost:3000/health
# http://localhost:3000/db-check
# http://localhost:3000/cache-check
# Tear down (add -v to also delete the Postgres/Redis volumes)
docker compose down
Buy Me a Coffee
Found this tutorial helpful? Consider buying me a coffee to support more free content!