Production Docker — Angular nginx Build, Resource Limits, and Rolling Updates

A production Docker deployment differs from development in several critical ways: images are built once and promoted through environments rather than rebuilt per environment, secrets are injected at runtime from a secrets manager rather than from .env files, resources are constrained to prevent one container from starving others, logging is centralised, and health checks drive automatic recovery. This lesson builds the production Dockerfile and Compose override for the MEAN Stack task manager — optimised for minimal image size, maximum security, and operational reliability.

Development vs Production Differences

Concern Development Production
Source code Bind-mounted — changes reflected immediately Baked into image at build time
Dependencies All (devDependencies included) Production only (npm ci --only=production)
Node process nodemon with –inspect node directly (or PM2 for clustering)
Secrets From local .env file Injected from secrets manager at runtime
Ports All exposed for debugging (Mongo, Redis) Only public-facing ports exposed
Logging Console output Structured JSON, collected by log aggregator
Image tag latest or dev Git commit SHA or semantic version
Resource limits Unlimited CPU and memory limits defined
Note: Tag production images with the Git commit SHA: docker build -t myapp:$(git rev-parse --short HEAD) .. This makes every deployed version traceable — you can look at the running container’s image tag and know exactly which commit it was built from. Using :latest for production means you lose traceability — you cannot tell which version is running or roll back to a specific commit. Immutable, content-addressed tags are a production best practice.
Tip: For the Angular production build, use nginx to serve the static files — not the Angular dev server or a Node.js file server. The Angular build output is a directory of static HTML, CSS, and JavaScript files that nginx can serve with sendfile and gzip compression at far higher performance than any JavaScript file server. The Dockerfile builds Angular in a Node.js stage, then copies the dist/ output into an nginx stage — a classic multi-stage pattern.
Warning: Always set memory limits on production containers. Without limits, a memory leak in the Express API can consume all available server memory, crashing other containers and potentially the host. Set limits in the Compose file: deploy: resources: limits: memory: 512m. Choose limits based on observed memory usage under load — too low causes OOM kills, too high defeats the purpose. Monitor memory usage in production and adjust accordingly.

Production Dockerfiles

# ── Angular Production Dockerfile ─────────────────────────────────────────
# Stage 1: Build Angular app
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build -- --configuration production
# Output: /app/dist/app/browser/

# Stage 2: Serve with nginx
FROM nginx:alpine AS production
# Copy built Angular files
COPY --from=builder /app/dist/app/browser /usr/share/nginx/html
# Copy nginx config (SPA routing + gzip + caching headers)
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# nginx.conf — SPA routing config for Angular
server {
    listen 80;
    root   /usr/share/nginx/html;
    index  index.html;

    # Gzip compression
    gzip on;
    gzip_types text/css application/javascript application/json image/svg+xml;
    gzip_min_length 1024;

    # Cache static assets with hash filenames forever
    location ~* \.(js|css|woff2?|png|jpg|svg|ico)$ {
        expires    1y;
        add_header Cache-Control "public, immutable";
        try_files  $uri =404;
    }

    # SPA routing — all unmatched routes serve index.html
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Health check endpoint
    location /health {
        return 200 "OK";
        add_header Content-Type text/plain;
    }
}
# docker-compose.prod.yml — Production overrides
version: '3.9'

services:
  mongodb:
    ports: []    # remove dev port exposure — not accessible externally
    environment:
      MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER}
      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASS}
    deploy:
      resources:
        limits:
          memory: 1g
          cpus:   '1.0'
        reservations:
          memory: 512m

  redis:
    ports: []
    deploy:
      resources:
        limits:
          memory: 256m

  api:
    build:
      target: production    # use the production stage (not development)
    image: ${REGISTRY}/taskmanager-api:${GIT_SHA}
    environment:
      NODE_ENV:   production
      LOG_LEVEL:  info
    deploy:
      replicas: 2           # two instances for redundancy
      resources:
        limits:
          memory: 512m
          cpus:   '0.5'
      update_config:
        parallelism: 1      # rolling update: one at a time
        delay: 10s
      restart_policy:
        condition: on-failure
        max_attempts: 3

  angular:
    build:
      target: production    # build and serve with nginx
    image: ${REGISTRY}/taskmanager-angular:${GIT_SHA}
    ports:
      - "80:80"
    deploy:
      resources:
        limits:
          memory: 64m       # nginx is very lightweight

  # Remove angular-dev service — not needed in production

How It Works

Step 1 — Angular Multi-Stage Build Produces a Pure Static Asset Bundle

The builder stage runs ng build --configuration production, which produces minified, tree-shaken, hashed JavaScript and CSS bundles in dist/app/browser/. The production stage starts fresh from nginx:alpine and copies only the built files. The final image contains only nginx and the compiled Angular assets — no Node.js, no npm, no Angular CLI, no TypeScript source. This image is typically 20–30MB versus 500MB+ for a full Node.js image.

Step 2 — nginx Handles SPA Routing with try_files

Angular is a single-page application — all routes are handled by Angular’s router in the browser. When a user navigates directly to /tasks/42, nginx sees a request for a path that has no corresponding file. The try_files $uri $uri/ /index.html directive says: try the exact path, try it as a directory, and if neither exists, serve index.html. This lets Angular’s router take over and render the correct component.

Step 3 — Immutable Cache Headers Maximise CDN Efficiency

Angular’s production build generates filenames with content hashes (main.abc123.js). Since the filename changes whenever the content changes, these files can be cached forever — the browser never needs to re-validate them. Setting Cache-Control: public, immutable and expires: 1y tells browsers and CDNs to cache these files for a year. Only index.html must not be cached long-term — it references the current hashed asset filenames.

Step 4 — Resource Limits Prevent Noisy Neighbor Problems

In a multi-container deployment on a shared host, one container consuming unbounded memory or CPU affects all others. The deploy.resources.limits configuration caps each container’s resource usage. When a container exceeds its memory limit, the OOM killer terminates it and Docker restarts it (per the restart policy). This is a controlled failure mode — better than cascading failures where one service brings down the entire server.

Step 5 — Rolling Updates Deploy Without Downtime

The update_config: parallelism: 1, delay: 10s configuration in the production Compose file means when deploying a new API version, Docker Swarm updates one replica at a time with a 10-second delay between each. During the update, at least one old replica continues serving traffic. If the new replica fails its health check, the update is automatically rolled back. This zero-downtime deployment strategy is available with both Docker Swarm and Kubernetes.

Common Mistakes

Mistake 1 — Serving the Angular dev server in production

❌ Wrong — ng serve is not built for production traffic:

CMD ["ng", "serve", "--host", "0.0.0.0"]  # dev server in production!
# Single-threaded, no gzip, no caching, hot reload overhead, exposes internals

✅ Correct — build and serve with nginx:

RUN npm run build -- --configuration production
FROM nginx:alpine
COPY --from=builder /app/dist/app/browser /usr/share/nginx/html

Mistake 2 — Not setting memory limits

❌ Wrong — a memory leak can crash the entire server:

api:
    image: myapp:latest
    # No resource limits — memory leak → OOM → entire host goes down

✅ Correct — set limits based on observed usage:

api:
    deploy:
        resources:
            limits: { memory: 512m, cpus: '0.5' }

Mistake 3 — Using :latest tag in production

❌ Wrong — cannot trace which code version is deployed or roll back:

image: myapp:latest   # which commit? when? cannot tell or roll back

✅ Correct — use Git SHA for immutable, traceable tags:

docker build -t myapp:$(git rev-parse --short HEAD) .
# docker-compose.prod.yml: image: myapp:${GIT_SHA}

Quick Reference

Task Code
Angular prod Dockerfile Node builder stage → nginx stage with dist files
SPA routing in nginx try_files $uri $uri/ /index.html
Cache static assets expires 1y; add_header Cache-Control "public, immutable"
Tag with Git SHA docker build -t app:$(git rev-parse --short HEAD) .
Memory limit deploy: resources: limits: memory: 512m
Rolling update update_config: parallelism: 1, delay: 10s
Remove dev port ports: [] in prod override file
Prod Compose command docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

🧠 Test Yourself

An Angular SPA is served by nginx. A user bookmarks /tasks/42 and returns later. nginx looks for a file at that path, finds none, and returns a 404. What nginx configuration fixes this?