Skip to content

Tech Story: Harden Dockerfiles for production #102

@GitAddRemote

Description

@GitAddRemote

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:

  1. Builder stage: install everything, compile the TypeScript → JavaScript
  2. 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

  • Both images use Node.js 22 LTS Alpine
  • Multi-stage build: builder compiles; runner copies dist/ + prod deps only
  • npm ci used in both stages
  • Backend CMD runs node dist/main.js
  • .dockerignore present for both; excludes .env*, node_modules/, test files, coverage/, .git/
  • Non-root user created and active before CMD
  • HEALTHCHECK on both images
  • EXPOSE matches actual runtime port
  • Frontend served by nginx:alpine with SPA fallback config
  • GET /health endpoint returns 200

Dependencies

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend services and logicconfigConfiguration and feature flagsfrontendFrontend app and dashboardtech-storyTechnical implementation story

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions