Modern software development requires collaboration, consistency, and reliability. Yet, building the same application across different machines — Windows, macOS, Linux — has always been painful.
Before Docker, developers fought with:
- “It works on my machine, not on yours.”
- Dependency conflicts
- Different OS environments
- Version mismatches (Node 16 vs 18, Python 3.8 vs 3.10)
- Global package problems
- Messy onboarding
- Inconsistent staging/production environments
Docker solves all these by giving developers a portable, reproducible environment — called a container — that works exactly the same everywhere.
This blog covers:
- Why Docker exists
- How Docker works
- Multi-stage builds
- Reverse proxying with NGINX
- SPA routing
- Static caching
- Full-stack docker-compose setup
- Best practices
- And 20+ DevOps questions with complete answers
This is the exact knowledge used in real companies.
1. The Root Problem Docker Solves
Software depends on the environment, not just the code.
Different machines = different:
- OS
- libraries
- runtimes
- versions
- package managers
- filesystem behavior
Example:
A project may require Node 18, but one dev has Node 16.
Another uses PostgreSQL 13, another uses 15.
One dev is on Linux, another on Windows.
This creates chaos.
Docker introduces standardized environments:
“If it works in the container, it will work everywhere.”
You package your app + dependencies + environment into a container.
2. Dockerfiles: Reproducible Build Environments
A Dockerfile defines how an app is built.
Example: Backend multi-stage Dockerfile
# Stage 1 — Builder
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2 — Runner
FROM node:18-alpine AS runner
WORKDIR /app
COPY package*.json ./
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 8080
CMD ["node", "dist/server.js"]
This ensures:
- same Node version everywhere
- same dependency versions
- same build output
3. Why Multi-Stage Builds Matter
Multi-stage builds bring:
✓ Smaller final images
You remove build tools, compilers, TypeScript, etc.
✓ More secure images
No unnecessary binaries or source code.
✓ Faster CI/CD
Only runtime files are copied.
✓ Cleaner separation
Build stage ≠ runtime stage.
4. Docker Compose: Orchestrating Multi-Service Apps
Modern apps need multiple services:
- Frontend (React)
- Backend (Node)
- Database (Postgres)
- Reverse proxy (Nginx)
Docker Compose connects them together.
Example full-stack architecture:
version: '3.8'
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
networks:
- app-network
backend:
build:
context: ./backend
dockerfile: Dockerfile
environment:
DATABASE_URL: postgres://postgres:mysecretpassword@db:5432/multi-stage-app
JWT_SECRET: supersecret
PORT: 8080
depends_on:
- db
networks:
- app-network
db:
image: postgres:15
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: mysecretpassword
POSTGRES_DB: multi-stage-app
volumes:
- db_data:/var/lib/postgresql/data
networks:
- app-network
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./frontend/dist:/usr/share/nginx/html:ro
depends_on:
- frontend
- backend
networks:
- app-network
networks:
app-network:
volumes:
db_data:
5. Docker Networking — Why We Use Service Names
Inside Docker:
proxy_pass http://backend:8080;
NOT:
proxy_pass http://localhost:8080;
Because localhost refers to the NGINX container itself, not the backend.
Docker auto-creates DNS records for service names.
6. NGINX: Serving Frontend + Proxying Backend
Production apps should never expose the Vite dev server (5173).
Instead, React/Vite builds static files → NGINX serves them.
nginx.conf
events {}
http {
gzip on;
gzip_types text/plain text/css application/json application/javascript image/svg+xml;
gzip_min_length 1024;
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Cache static assets for 1 year
location ~* \.(?:js|css|png|jpg|jpeg|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Reverse proxy for backend API
location /api/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}
}
7. React Frontend Multi-Stage Dockerfile
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine AS production
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Questions
Q1 — Why did you use COPY ./package.* ./package.* instead of copying only package.json and package-lock.json explicitly?
What would be the downside of using this wildcard approach in a real enterprise pipeline?
Q2 — Why do we run npm ci instead of npm install in Docker builds?
Explain specifically in terms of:
- reproducibility
- caching
- CI/CD environments
Q3 — How does the Docker layer cache behave with your current COPY order?
Describe which layers will rebuild when:
- You change
package.json - You change only source files like
server.js
Q4 — Why is EXPOSE 8080 not actually exposing a port?
Explain what it really does.
Q5 — What security or optimization improvements could you add to this Dockerfile for production?
Suggest at least two improvements (e.g., non-root user, multi-stage build, etc.)
Q5 — Why does backend copy node_modules from builder?
Because:
- Avoids reinstalling dependencies
- Ensures the same dependency tree used during build
- Faster builds
- Smaller layers
Q6 — Why do we need absolute paths like /app/dist when copying from another stage?
Because:
- Each Docker stage has its own filesystem
- COPY from another stage requires absolute paths
- WORKDIR of current stage doesn’t apply to previous stages
Q7 — Why shouldn’t we use npm prune --production in builder stage?
Because devDependencies (TypeScript, bundlers, etc.) are needed to build the app.
Pruning too early breaks builds.
Q8 — Why shouldn’t the backend expose 8080:8080 in production?
Because:
- Directly exposes backend to the internet
- Bypasses NGINX security
- Increases attack surface
- Makes private endpoints public
Backend should only be reachable internally.
Q9 — Why must SPA apps use:
try_files $uri $uri/ /index.html;
Because refreshing /dashboard should not 404.
SPAs rely on client-side routing.
Q10 — Why can static assets be cached for 1 year?
Because filenames are fingerprinted:
main.92af71.js
vendor.39df22.js
They never change.
New build → new filename.
So long caching is safe.