Docker Containerization for Node.js Apps

Docker Containerization for Node.js Apps
A working application on your laptop that fails on the server is a classic deployment problem. Docker solves it by packaging the application with its entire runtime environment — Node.js version, dependencies, environment — into an image that runs identically anywhere.
This module covers writing a production-ready Dockerfile, multi-stage builds, docker-compose for local development, and security best practices for containerised Node.js applications.
This is Module 30 of the Node.js Full‑Stack Developer course.
.dockerignore
Create this before the Dockerfile. It keeps the build context lean:
# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
dist
coverage
.nyc_output
*.test.js
*.spec.js
tests/
docs/
README.md
docker-compose*.yml
Dockerfile*Production Dockerfile — Multi-Stage Build
# Dockerfile
# ── Stage 1: Builder ──────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files first (layer caching — only reinstalls on package changes)
COPY package.json package-lock.json ./
# Install all dependencies (including devDependencies for build)
RUN npm ci
# Copy source code
COPY . .
# Build the React frontend
RUN npm run build:client
# ── Stage 2: Production ───────────────────────────────────────────────────────
FROM node:20-alpine AS production
# Security: run as non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# Copy package files
COPY package.json package-lock.json ./
# Install production dependencies only
RUN npm ci --omit=dev && npm cache clean --force
# Copy built assets from builder stage
COPY --from=builder /app/dist ./dist
# Copy server source code
COPY src ./src
# Set ownership to non-root user
RUN chown -R appuser:appgroup /app
# Switch to non-root user
USER appuser
# Expose port (documentation only — actual port set at runtime)
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
# Start the server
CMD ["node", "src/server.js"]Why alpine?
node:20-alpine is a minimal Linux distribution (~170MB vs ~900MB for node:20). Smaller image = faster pulls, smaller attack surface. Use node:20-alpine for production unless you need libraries that require a full OS.
Building and Running
# Build the image
docker build -t myapp:latest .
# Run the container
docker run \
-p 3000:3000 \
-e MONGODB_URI=mongodb://host.docker.internal:27017/myapp \
-e JWT_ACCESS_SECRET=your-secret \
-e JWT_REFRESH_SECRET=your-refresh-secret \
-e NODE_ENV=production \
-e API_URL=http://localhost:3000 \
-e FRONTEND_URL=http://localhost:3000 \
myapp:latest
# Run detached
docker run -d --name myapp -p 3000:3000 --env-file .env.production myapp:latest
# View logs
docker logs -f myapp
# Stop and remove
docker stop myapp && docker rm myappdocker-compose for Local Development
# docker-compose.yml
version: '3.9'
services:
# ── Application ─────────────────────────────────────────────────────────────
app:
build:
context: .
target: builder # use builder stage for dev (has devDependencies)
command: npm run dev:server
ports:
- "3000:3000"
volumes:
- ./src:/app/src # hot-reload: mount src directory
- /app/node_modules # don't override container's node_modules
environment:
NODE_ENV: development
env_file:
- .env
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_healthy
networks:
- app-network
# ── MongoDB ──────────────────────────────────────────────────────────────────
mongo:
image: mongo:7-jammy
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
environment:
MONGO_INITDB_DATABASE: myapp
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app-network
# ── Redis ─────────────────────────────────────────────────────────────────────
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
networks:
- app-network
# ── Mongo Express (DB UI) ─────────────────────────────────────────────────────
mongo-express:
image: mongo-express:1
ports:
- "8081:8081"
environment:
ME_CONFIG_MONGODB_URL: mongodb://mongo:27017/
ME_CONFIG_BASICAUTH: false
depends_on:
- mongo
networks:
- app-network
profiles:
- tools # only started with: docker compose --profile tools up
volumes:
mongo_data:
redis_data:
networks:
app-network:
driver: bridgedocker-compose for Production
# docker-compose.prod.yml
version: '3.9'
services:
app:
build:
context: .
target: production # production stage — lean image
restart: unless-stopped
ports:
- "3000:3000"
env_file:
- .env.production
depends_on:
- mongo
- redis
networks:
- prod-network
mongo:
image: mongo:7-jammy
restart: unless-stopped
volumes:
- mongo_prod_data:/data/db
environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD}
networks:
- prod-network
# No port mapping — not exposed to host
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_prod_data:/data
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
networks:
- prod-network
volumes:
mongo_prod_data:
redis_prod_data:
networks:
prod-network:
driver: bridgeCommon Docker Commands
# Local development
docker compose up -d # start all services (detached)
docker compose up -d --build # rebuild and start
docker compose down # stop and remove containers
docker compose down -v # also remove volumes (wipes data)
docker compose logs -f app # follow app logs
docker compose exec app sh # shell into running container
# With tools profile
docker compose --profile tools up -d # also starts mongo-express
# Production
docker compose -f docker-compose.prod.yml up -d
# Image management
docker images # list images
docker rmi myapp:latest # remove image
docker system prune # remove all unused resources
docker system prune -a # remove all unused images too
# Debugging
docker inspect myapp # container metadata
docker stats # live resource usage (CPU, memory)
docker exec -it myapp sh # shell into running containerLayer Caching — Making Builds Fast
Docker caches each layer. If a layer's input does not change, Docker reuses the cache. Optimise your Dockerfile to maximise cache hits:
# ✅ Good — package files change rarely, source changes often
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# ❌ Bad — any source change invalidates the npm ci layer
COPY . .
RUN npm ciThe rule: copy things that change rarely before things that change often.
Environment Variables in Docker
Never bake secrets into the image. Pass them at runtime:
# Via --env-file (recommended for many variables)
docker run --env-file .env.production myapp:latest
# Via -e for individual variables
docker run -e JWT_SECRET=mysecret myapp:latestFor production, use your platform's secrets mechanism (Railway vars, AWS SSM, Doppler) and inject at runtime — not from a file on the server.
Container Security Checklist
✅ Run as non-root user (USER appuser)
✅ Use official minimal base image (node:20-alpine)
✅ No secrets baked into the image — passed at runtime
✅ node_modules owned by the app user
✅ Health check defined (HEALTHCHECK)
✅ Production stage has no devDependencies
✅ .dockerignore excludes .env, .git, tests, node_modules
✅ restart: unless-stopped in production compose
✅ Internal services (mongo, redis) not exposed to host in production
✅ Use specific image tags (node:20-alpine, not node:latest)Node.js Full‑Stack Course — Module 30 of 32
Your app runs identically in any environment with Docker. Continue to Module 31 to deploy it to the cloud.
Summary
Docker gives Node.js applications environment consistency and deployment simplicity:
- Use multi-stage builds: a builder stage for compilation and a lean production stage with only what the app needs at runtime
- Always use non-root users (
USER appuser) — never run Node.js as root in a container - Use
node:20-alpinefor production — it is ~170MB vs ~900MB for the Debian variant - Layer caching: copy
package.jsonand runnpm cibefore copying source code docker-compose.ymlfor local development includes app, MongoDB, and Redis with health checks- Pass secrets at runtime via
--env-fileor platform variables — never bake them into the image HEALTHCHECKlets Docker (and orchestrators like Kubernetes) detect unhealthy containers automatically.dockerignorepreventsnode_modules,.env, and test files from bloating the build context
Continue to Module 31: Deploying Node.js to the Cloud →
