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 |
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.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.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 |