Tech Story
As a platform engineer, I want production Docker images to be lean, reproducible, and secure so that deployed containers have minimal attack surface and known-good dependency trees.
ELI5 Context
What is a multi-stage Docker build?
Normally, a Docker image includes everything you used to build your app: compilers, test tools, dev dependencies. That's wasteful and insecure — you're shipping your entire workshop when you only need the finished product. A multi-stage build splits this into two phases:
- Builder stage: install everything, compile the TypeScript → JavaScript
- Runner stage: start fresh with a clean image, copy only the compiled output and production dependencies
The result is an image that might be 80% smaller and contains only what's needed to run.
Why Node.js Alpine?
Alpine Linux is a minimal OS — about 5MB vs Ubuntu's 80MB. Less OS = less attack surface. Node.js 22 LTS Alpine is the current recommended base.
Why a non-root user?
Docker containers run as root by default. If your app has a security vulnerability that lets an attacker run code, they'd have root inside the container — and potentially on the host. A non-root user limits the blast radius: the attacker can only do what node can do.
What is a HEALTHCHECK instruction?
Docker can periodically check if your container is actually working (not just running). For the backend, it hits GET /health. If the check fails multiple times, Docker marks the container unhealthy. This is what lets Docker Compose's depends_on: condition: service_healthy work — the backend won't start until Postgres is confirmed healthy.
What is .dockerignore?
Like .gitignore, but for Docker. Prevents node_modules/, .env files, test coverage, and git history from being copied into the image. Without it, your .env.production could accidentally end up inside a public Docker image.
Technical Elaboration
backend/Dockerfile (new or rewrite)
# Stage 1 — builder
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci # reproducible install (uses lockfile exactly)
COPY . .
RUN npm run build # compiles TypeScript → dist/
# Stage 2 — runner
FROM node:22-alpine AS runner
WORKDIR /app
RUN addgroup -S node && adduser -S node -G node # non-root user
COPY package*.json ./
RUN npm ci --omit=dev # production dependencies only
COPY --from=builder /app/dist ./dist
USER node # switch to non-root
EXPOSE 3001
HEALTHCHECK --interval=15s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:3001/health || exit 1
CMD ["node", "dist/main.js"]
frontend/Dockerfile (new or rewrite)
# Stage 1 — builder
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # produces /app/dist (Vite output)
# Stage 2 — runner (nginx serves the static files)
FROM nginx:alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=15s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:80 || exit 1
frontend/nginx.conf (new)
Nginx config for serving the React SPA. Key requirement: all routes must return index.html so React Router handles client-side routing:
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html; # SPA fallback
}
}
backend/.dockerignore (new)
node_modules/
dist/
.env*
*.test.ts
coverage/
.git/
frontend/.dockerignore (new)
node_modules/
dist/
.env*
*.test.ts
coverage/
.git/
Backend: add GET /health endpoint
New HealthModule in backend/src/health/:
health.controller.ts: @Get('health') returns { status: 'ok', timestamp: new Date().toISOString() } with HTTP 200
health.module.ts: registers the controller
- Import in
AppModule
Acceptance Criteria
Dependencies
Tech Story
As a platform engineer, I want production Docker images to be lean, reproducible, and secure so that deployed containers have minimal attack surface and known-good dependency trees.
ELI5 Context
What is a multi-stage Docker build?
Normally, a Docker image includes everything you used to build your app: compilers, test tools, dev dependencies. That's wasteful and insecure — you're shipping your entire workshop when you only need the finished product. A multi-stage build splits this into two phases:
The result is an image that might be 80% smaller and contains only what's needed to run.
Why Node.js Alpine?
Alpine Linux is a minimal OS — about 5MB vs Ubuntu's 80MB. Less OS = less attack surface. Node.js 22 LTS Alpine is the current recommended base.
Why a non-root user?
Docker containers run as root by default. If your app has a security vulnerability that lets an attacker run code, they'd have root inside the container — and potentially on the host. A non-root user limits the blast radius: the attacker can only do what
nodecan do.What is a HEALTHCHECK instruction?
Docker can periodically check if your container is actually working (not just running). For the backend, it hits
GET /health. If the check fails multiple times, Docker marks the container unhealthy. This is what lets Docker Compose'sdepends_on: condition: service_healthywork — the backend won't start until Postgres is confirmed healthy.What is
.dockerignore?Like
.gitignore, but for Docker. Preventsnode_modules/,.envfiles, test coverage, and git history from being copied into the image. Without it, your.env.productioncould accidentally end up inside a public Docker image.Technical Elaboration
backend/Dockerfile(new or rewrite)frontend/Dockerfile(new or rewrite)frontend/nginx.conf(new)Nginx config for serving the React SPA. Key requirement: all routes must return
index.htmlso React Router handles client-side routing:backend/.dockerignore(new)frontend/.dockerignore(new)Backend: add
GET /healthendpointNew
HealthModuleinbackend/src/health/:health.controller.ts:@Get('health')returns{ status: 'ok', timestamp: new Date().toISOString() }with HTTP 200health.module.ts: registers the controllerAppModuleAcceptance Criteria
dist/+ prod deps onlynpm ciused in both stagesCMDrunsnode dist/main.js.dockerignorepresent for both; excludes.env*,node_modules/, test files,coverage/,.git/CMDHEALTHCHECKon both imagesEXPOSEmatches actual runtime portnginx:alpinewith SPA fallback configGET /healthendpoint returns 200Dependencies