-
Notifications
You must be signed in to change notification settings - Fork 0
Tech Story: Harden Dockerfiles for production #102
Description
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 1frontend/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 200health.module.ts: registers the controller- Import in
AppModule
Acceptance Criteria
- Both images use Node.js 22 LTS Alpine
- Multi-stage build: builder compiles; runner copies
dist/+ prod deps only -
npm ciused in both stages - Backend
CMDrunsnode dist/main.js -
.dockerignorepresent for both; excludes.env*,node_modules/, test files,coverage/,.git/ - Non-root user created and active before
CMD -
HEALTHCHECKon both images -
EXPOSEmatches actual runtime port - Frontend served by
nginx:alpinewith SPA fallback config -
GET /healthendpoint returns 200
Dependencies
- Blocks: Tech Story: Docker Compose production configuration for Station #108 (Docker Compose prod config relies on these images being correct)
- Blocks: Tech Story: GitHub Actions CI/CD — release-tag SSH deploy with graceful restart #90 (CI/CD builds these images)