57.16 Project 16: 3-tier app: React frontend, Node.js API and PostgreSQL
Why: the problem
Suppose we build the events app for a college fest in Pune, like a small BookMyShow: students see events and register for them. Most real web apps have three tiers: a frontend in the browser (React), a backend API (Node.js), and a database (PostgreSQL). Without Docker the frontend developer runs Node 22, the backend developer runs Node 18, Postgres is installed differently on each laptop, and the server needs all of them again. React also has a trap: in development it runs on a Node dev server, but in production it is only static HTML, JS and CSS files that any web server can serve. With Docker we build React in a multi-stage image (Node builds, nginx serves), run the API in its own small image, and keep Postgres in a container with a volume. nginx also proxies /api to the backend, so the browser talks to one address and we avoid CORS problems. This is the same shape companies like Swiggy and Zomato use, at a much bigger scale.
What: every term in plain English
- 3-tier — Presentation (React), logic (API) and data (database), each in its own layer and container.
- React — A JavaScript library for building user interfaces from components.
- Vite — A fast build tool for React. npm run build makes the static files in dist/.
- Multi-stage build — Stage 1 uses Node to build; stage 2 copies only dist/ into nginx. Node never ships.
- Reverse proxy /api — nginx sends /api/... requests to the api container, so the browser uses one origin.
- CORS — A browser rule that blocks calls to another origin. Same-origin through nginx avoids it.
- SPA fallback — try_files ... /index.html so React routes like /events/3 work on refresh.
- pg — The Node.js PostgreSQL client library.
How: step by step
- Write the Node API with Postgres. Express + pg, health check.
- Build the API image. npm ci, non-root, HEALTHCHECK.
- Write the React app. Vite, fetch /api/events.
- Build React in multi-stage. Node build, nginx serve.
- Compose all three tiers. Networks: front and back.
- Develop with hot reload. compose.dev.yaml.
- Clean up. down -v.
Architecture
Folder layout
three-tier/
compose.yaml
compose.dev.yaml
.env
db/init.sql
api/
Dockerfile
package.json
server.js
web/
Dockerfile
.dockerignore
nginx.conf
index.html
package.json
vite.config.js
src/main.jsx
Example 1: Tier 2 and 3: the Node API and PostgreSQL
Goal. Tier 2 and 3: the Node API and PostgreSQL.
api/package.json
{
"name": "fest-api",
"version": "1.0.0",
"type": "module",
"scripts": { "start": "node server.js" },
"dependencies": { "express": "^4.21.1", "pg": "^8.13.0" }
}
api/server.js
import express from 'express';
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
const app = express();
app.use(express.json());
app.get('/api/health', async (req, res) => {
try { await pool.query('SELECT 1'); res.json({ status: 'ok' }); }
catch (e) { res.status(503).json({ status: 'db down' }); }
});
app.get('/api/events', async (req, res) => {
const { rows } = await pool.query('SELECT id, title, venue, seats_left FROM events ORDER BY id');
res.json(rows);
});
app.post('/api/events/:id/register', async (req, res) => {
const { name } = req.body || {};
if (!name) return res.status(400).json({ error: 'name is required' });
const r = await pool.query(
'UPDATE events SET seats_left = seats_left - 1 WHERE id = $1 AND seats_left > 0 RETURNING seats_left',
[req.params.id]
);
if (r.rowCount === 0) return res.status(409).json({ error: 'event full or not found' });
await pool.query('INSERT INTO registrations (event_id, name) VALUES ($1, $2)', [req.params.id, name]);
res.status(201).json({ ok: true, seats_left: r.rows[0].seats_left });
});
const server = app.listen(3000, () => console.log('api on 3000'));
process.on('SIGTERM', () => server.close(() => pool.end()));
api/Dockerfile
FROM node:22-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js .
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s \
CMD wget -qO- http://localhost:3000/api/health || exit 1
CMD ["node", "server.js"]
db/init.sql
CREATE TABLE events (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
venue TEXT NOT NULL,
seats_left INT NOT NULL CHECK (seats_left >= 0)
);
CREATE TABLE registrations (
id SERIAL PRIMARY KEY,
event_id INT REFERENCES events(id),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
INSERT INTO events (title, venue, seats_left) VALUES
('Robotics Workshop', 'Lab 2', 40),
('Battle of Bands', 'Main Ground', 500),
('Hackathon', 'Library Hall', 2);
What each line does
pg.Pool max: 10— Keeps up to 10 database connections open and reuses them.$1, $2— Postgres placeholders; values are sent apart from the SQL, which stops SQL injection.WHERE seats_left > 0 RETURNING— Takes one seat in one atomic SQL step, so two students can never get the last seat at the same time.409— Conflict: the event is full.SIGTERM handler— docker stop sends SIGTERM; we finish requests and close the pool cleanly.npm ci --omit=dev— Installs exact versions from package-lock.json, without dev tools.USER node— The official Node image has a non-root user called node.CHECK (seats_left >= 0)— The database itself refuses negative seats.
Green output
$ docker run --rm -v "$PWD/api":/app -w /app node:22-alpine npm install
added 78 packages, and audited 79 packages in 4s
$ docker build -t fest-api:1.0 api
=> naming to docker.io/library/fest-api:1.0
$ docker images fest-api
REPOSITORY TAG SIZE
fest-api 1.0 161MB
Expected result. package-lock.json is created by a throwaway Node container, then the API image is built from it.
Example 2: Tier 1: the React app built with Vite
Goal. Tier 1: the React app built with Vite.
web/package.json
{
"name": "fest-web",
"version": "1.0.0",
"type": "module",
"scripts": { "dev": "vite --host 0.0.0.0", "build": "vite build" },
"dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" },
"devDependencies": { "vite": "^5.4.10", "@vitejs/plugin-react": "^4.3.3" }
}
web/vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: { proxy: { '/api': process.env.API_URL || 'http://localhost:3000' } },
});
web/index.html
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8" /><title>Pune College Fest</title></head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
web/src/main.jsx
import React, { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
function App() {
const [events, setEvents] = useState([]);
const [msg, setMsg] = useState('');
const load = () => fetch('/api/events').then(r => r.json()).then(setEvents);
useEffect(() => { load(); }, []);
async function register(id) {
const r = await fetch(`/api/events/${id}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Student' }),
});
const body = await r.json();
setMsg(r.ok ? `Registered. Seats left: ${body.seats_left}` : body.error);
load();
}
return (
<main>
<h1>Pune College Fest</h1>
<p>{msg}</p>
<ul>
{events.map(e => (
<li key={e.id}>
{e.title} at {e.venue} ({e.seats_left} seats)
<button disabled={e.seats_left === 0} onClick={() => register(e.id)}>Register</button>
</li>
))}
</ul>
</main>
);
}
createRoot(document.getElementById('root')).render(<App />);
What each line does
vite build— Makes dist/ with index.html and hashed JS and CSS files.server.proxy '/api'— In development, Vite forwards /api to the API, the same way nginx does in production.fetch('/api/events')— A relative URL: the browser calls the same origin, so there is no CORS and no hard-coded host.useState / useEffect— React hooks: keep the events list and load it once when the page opens.disabled={e.seats_left === 0}— The button turns off when an event is full.createRoot(...).render— Puts the App component into the div with id root.
Green output
$ docker run --rm -v "$PWD/web":/app -w /app node:22-alpine sh -c 'npm install && npm run build'
vite v5.4.10 building for production...
dist/index.html 0.38 kB
dist/assets/index-DkT3a9Qe.js 143.21 kB | gzip: 46.10 kB
built in 1.62s
Expected result. Only static files come out of the build. That is all nginx needs.
Example 3: Multi-stage React image with nginx, and Compose for all three tiers
Goal. Multi-stage React image with nginx, and Compose for all three tiers.
web/Dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
HEALTHCHECK --interval=10s --timeout=3s CMD wget -qO- http://localhost/ > /dev/null || exit 1
web/nginx.conf
server {
listen 80;
root /usr/share/nginx/html;
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location /api/ {
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
try_files $uri $uri/ /index.html;
}
}
web/.dockerignore
node_modules
dist
compose.yaml
services:
web:
build: ./web
image: fest-web:1.0
ports: ["8080:80"]
networks: [front]
depends_on:
api: { condition: service_healthy }
restart: unless-stopped
api:
build: ./api
image: fest-api:1.0
environment:
DATABASE_URL: postgres://fest:${DB_PASS}@db:5432/fest
networks: [front, back]
depends_on:
db: { condition: service_healthy }
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: fest
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: fest
volumes:
- pg-data:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/01.sql:ro
networks: [back]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fest -d fest"]
interval: 5s
retries: 20
restart: unless-stopped
networks:
front:
back:
volumes:
pg-data:
.env
DB_PASS=change-me-db
What each line does
FROM node AS build ... FROM nginx— The final image is nginx plus about 150 KB of files. Node, npm and node_modules stay in the build stage.COPY --from=build /app/dist— Takes only the built files from stage 1.expires 1y, immutable— Vite file names have a hash, so they can be cached for a year; a new build makes new names.proxy_pass http://api:3000— No path after the port, so /api/events goes to the API as /api/events.try_files ... /index.html— React handles its own routes; refresh on /events/3 still loads the app.networks front and back— web reaches api, api reaches db, but web can never reach db directly.Only web has ports— The API and Postgres are private. All traffic comes through nginx on 8080.
Green output
$ docker compose up -d --build --wait
Container three-tier-db-1 Healthy
Container three-tier-api-1 Healthy
Container three-tier-web-1 Healthy
$ docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep fest
fest-web:1.0 48.7MB
fest-api:1.0 161MB
$ curl -s localhost:8080/api/events
[{"id":1,"title":"Robotics Workshop","venue":"Lab 2","seats_left":40},{"id":2,"title":"Battle of Bands","venue":"Main Ground","seats_left":500},{"id":3,"title":"Hackathon","venue":"Library Hall","seats_left":2}]
$ docker compose exec web wget -qO- -T 2 db:5432 || echo "web cannot reach db"
wget: bad address 'db:5432'
web cannot reach db
Expected result. The full app runs on one port; the React image is under 50 MB, and the network split keeps the database away from the web tier.
Example 4: Development mode with hot reload, and the last-seat test
Goal. Development mode with hot reload, and the last-seat test.
compose.dev.yaml
services:
web:
build: !reset null
image: node:22-alpine
working_dir: /app
command: sh -c "npm install && npm run dev"
environment:
API_URL: http://api:3000
volumes:
- ./web:/app
- web-modules:/app/node_modules
ports: ["5173:5173"]
api:
build: !reset null
image: node:22-alpine
working_dir: /app
command: sh -c "npm install && node --watch server.js"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"]
interval: 5s
start_period: 30s
volumes:
- ./api:/app
- api-modules:/app/node_modules
volumes:
web-modules:
api-modules:
race test
for i in 1 2 3; do
curl -s -X POST localhost:8080/api/events/3/register -H 'Content-Type: application/json' -d '{"name":"S'$i'"}' &
done
wait
docker compose exec db psql -U fest -d fest -c 'SELECT seats_left FROM events WHERE id = 3'
What each line does
compose.dev.yaml— Overrides web and api with plain Node images and our code mounted live.npm run dev— Vite dev server on 5173 with hot reload: save a .jsx file and the browser updates.node --watch— Node 22 restarts the API when server.js changes.web-modules volume— node_modules stays inside a volume, so Linux packages do not clash with a Mac or Windows host.API_URL— Tells the Vite proxy to send /api to the api service.Three requests at once (&)— Hackathon has 2 seats; the atomic UPDATE lets exactly two win.
Green output
$ docker compose -f compose.yaml -f compose.dev.yaml up -d
# Browser: http://localhost:5173 -> edit web/src/main.jsx title, page updates in under 1 s
$ docker compose -f compose.yaml -f compose.dev.yaml down
$ docker compose up -d --wait
$ (race test)
{"ok":true,"seats_left":1}
{"ok":true,"seats_left":0}
{"error":"event full or not found"}
seats_left
------------
0
Expected result. Hot reload for daily coding, and proof that the last seat is never sold twice.
Every command, step by step (plain docker and Compose)
-
Make folders
mkdir -p three-tier/api three-tier/web/src three-tier/db && cd three-tierWhat it does: One folder per tier.
(no output) -
Add files
find . -type f -not -path '*/node_modules/*' | sortWhat it does: Copy every file from the examples.
./.env ./api/Dockerfile ./api/package.json ./api/server.js ./compose.yaml ./db/init.sql ./web/Dockerfile ./web/index.html ./web/nginx.conf ./web/package.json ./web/src/main.jsx ./web/vite.config.js -
Make lock files
docker run --rm -v "$PWD/api":/app -w /app node:22-alpine npm install docker run --rm -v "$PWD/web":/app -w /app node:22-alpine npm installWhat it does: npm ci in the Dockerfiles needs package-lock.json.
added 78 packages -
Build and start
docker compose up -d --build --waitWhat it does: Builds fest-web and fest-api, then starts all three.
Container three-tier-web-1 Healthy -
Open the app
echo http://localhost:8080What it does: The React page lists three events.
http://localhost:8080 -
Call the API through nginx
curl -s localhost:8080/api/eventsWhat it does: Same origin as the page.
[{"id":1,"title":"Robotics Workshop",...}] -
Register
curl -s -X POST localhost:8080/api/events/1/register -H 'Content-Type: application/json' -d '{"name":"Asha"}'What it does: Takes one seat.
{"ok":true,"seats_left":39} -
SPA refresh
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/events/3What it does: try_files returns index.html, not 404.
200 -
Check private tiers
docker compose port api 3000 || echo privateWhat it does: The API has no host port.
private -
Logs per tier
docker compose logs api --tail 3What it does: Each tier logs on its own.
api-1 | api on 3000 -
Data survives
docker compose down && docker compose up -d --wait && curl -s localhost:8080/api/events | grep -o '"seats_left":39'What it does: pg-data keeps registrations.
"seats_left":39 -
Clean up
docker compose down -v docker image rm fest-web:1.0 fest-api:1.0What it does: Removes containers, volume and images.
Volume three-tier_pg-data Removed
When it is red 1
502 Bad Gateway (on /api/events)
Cause. nginx cannot reach api:3000: the API crashed or is not on the front network.
Fix, step by step
- docker compose logs api.
- Make sure api is in networks: [front, back].
When it is red 2
Access to fetch at 'http://localhost:3000/api/events' from origin 'http://localhost:8080' has been blocked by CORS policy
Cause. The React code calls the API on another port.
Fix, step by step
- Use a relative URL: fetch('/api/events').
- Let nginx proxy /api, so both share one origin.
When it is red 3
404 Not Found (refresh on /events/3)
Cause. nginx looked for a file called events/3.
Fix, step by step
- Add try_files $uri $uri/ /index.html; in location /.
When it is red 4
npm ERR! The `npm ci` command can only install with an existing package-lock.json
Cause. package-lock.json was never created.
Fix, step by step
- Run npm install once in a node container to make it.
- Commit package-lock.json with the code.
When it is red 5
Error: connect ECONNREFUSED 172.20.0.2:5432
Cause. The API started before Postgres was ready.
Fix, step by step
- Keep depends_on db: condition: service_healthy.
- pg.Pool also retries on the next request.
Practice tasks
Easy Task 1: API
Why: Tier 2.
What to do: Build fest-api:1.0 and run it with only Postgres.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the three-tier folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose up -d db api
docker compose exec api wget -qO- localhost:3000/api/healthEasy Task 2: Build
Why: Tier 1.
What to do: Build the React dist folder in a node container.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the three-tier folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker run --rm -v "$PWD/web":/app -w /app node:22-alpine sh -c 'npm install && npm run build'Easy Task 3: Size
Why: Multi-stage.
What to do: Compare fest-web:1.0 with node:22-alpine size.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the three-tier folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker images | grep -E 'fest-web|node'Medium Task 4: Proxy
Why: Same origin.
What to do: Call /api/events through port 8080 only.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the three-tier folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
curl -s localhost:8080/api/eventsMedium Task 5: Networks
Why: Isolation.
What to do: Prove web cannot resolve db but api can.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the three-tier folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose exec web nslookup db || echo blocked
docker compose exec api nslookup dbMedium Task 6: Race
Why: Atomic SQL.
What to do: Send 5 registrations at once to the 2-seat Hackathon.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the three-tier folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
for i in 1 2 3 4 5; do curl -s -X POST localhost:8080/api/events/3/register -H 'Content-Type: application/json' -d '{"name":"x"}' & done; waitMedium Task 7: Dev
Why: Hot reload.
What to do: Run compose.dev.yaml and change the page title.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the three-tier folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose -f compose.yaml -f compose.dev.yaml up -dHard Task 8: Cache
Why: Headers.
What to do: Show Cache-Control on an assets file and none on index.html.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the three-tier folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
curl -sI localhost:8080/ | grep -i cache
curl -sI localhost:8080/assets/$(docker compose exec web ls /usr/share/nginx/html/assets | head -1) | grep -i cacheHard Task 9: Scale
Why: API.
What to do: Scale the API to 3 and reload nginx.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the three-tier folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose up -d --scale api=3
docker compose exec web nginx -s reloadEasy Task 10: Clean
Why: Habit.
What to do: Remove everything.
- Use a VM or laptop with Docker and Compose (2 GB RAM is enough).
- Create the three-tier folder with the files from this lesson.
- Run the steps and compare with the solution.
Solution
docker compose down -v
docker image rm fest-web:1.0 fest-api:1.0Homework: try at home
Add a login page to the React app and a POST /api/login in the API that returns a JWT (secret from .env), protect the register endpoint with it, and add a Redis container to rate-limit registrations to 5 per minute per user.
Recap
- Three tiers, three containers: web, api, db.
- React is built by Node and served by nginx in a multi-stage image.
- nginx proxies /api so there is one origin and no CORS.
- Two networks keep the database away from the web tier.
- Atomic SQL stops double booking.
- A dev override gives hot reload with the same Compose project.
FAQ
Q. Can I use Next.js instead of React with Vite?
Yes, but Next.js with server rendering needs a Node container at run time, not only nginx.
Q. Where should the API URL be set for React?
Use relative /api paths; values baked at build time cannot change per server.
Q. Can I put the React files on S3 and CloudFront?
Yes. That is common in production; the API stays in containers. See Project 58.13.
Q. Why is fest-api bigger than fest-web?
It has Node and node_modules; fest-web is only nginx and static files.
Q. Should Postgres run in a container in production?
Many teams use Amazon RDS instead. Project 58.1 shows the same API with RDS.
Q. How do I add HTTPS?
Put Traefik or certbot in front of the web container, as in Projects 4 and 58.7.
Interview questions
-
Describe a 3-tier architecture in Docker.
Model answer: A web tier (nginx with built frontend), an app tier (API container) and a data tier (database with a volume), on separate networks with only the web tier published.
-
How do you containerise a React app for production?
Model answer: Multi-stage: build with Node, copy dist into nginx, add SPA fallback and cache headers.
-
How do you avoid CORS between frontend and API?
Model answer: Serve both from one origin with a reverse proxy, or set exact CORS headers on the API.
-
How do you stop two users booking the last seat?
Model answer: Use one atomic UPDATE with a condition, or a transaction with row locks.
-
How do you run the same stack for development and production?
Model answer: One base compose.yaml plus an override file for dev with bind mounts and hot reload.
Quick quiz
Q1. What are the three tiers in this project?
React frontend (in nginx), Node.js API and PostgreSQL.
Q2. Why build React in a multi-stage Dockerfile?
Node is needed only to build; the final image is just nginx with static files.
Q3. Why does nginx proxy /api?
So the browser talks to one origin and CORS is not needed.
Q4. Why two networks, front and back?
So the web tier cannot reach the database directly.
Q5. What does try_files ... /index.html do?
It sends unknown paths to React so client routes work on refresh.
Samajla ka?
Lab
- Finish every project in this chapter on one Ubuntu VM or laptop.
- Keep each project in its own folder.
- Run docker compose down -v after each project.
- Write the commands you typed in a notebook.
- Break one thing on purpose in each project and fix it.
Practice task
Pick any two projects and combine them behind one nginx container on ports 80 and 8080.
Ravindra Bagale's Tip
Project kelyashivay Docker yet nahi. Roj ek project kara, aani interview madhe tyacha demo dakhva.
In short
- Every project follows the same order: code, Dockerfile, compose, run, test, clean.
- Data lives in named volumes.
- Containers talk by service name on a user-defined network.
- Secrets come from .env files that never go to Git.
Revision card
Interview card