Docker and Containerization
Getting started with Docker for development and deployment of applications.
Docker eliminated the "works on my machine" problem at the cost of a learning curve. Once internalised, containerisation becomes the most reliable way to build reproducible, deployment-ready applications. This guide covers practical Docker usage for Node.js/NestJS applications โ the same setup I use across all my production services.
The Mental Model: Image vs Container
An image is a read-only blueprint (like a class). A container is a running instance of that blueprint (like an object). You build images once, then run as many containers as you need. Images are stored in registries (Docker Hub, ECR, GCR).
Multi-Stage Builds: Keep Production Images Lean
A common mistake is shipping development dependencies into production. Multi-stage builds solve this by using one stage to compile/build and a separate minimal stage to run.
# Stage 1 โ build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2 โ production (only runtime deps)
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
# Run as non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "dist/main.js"].dockerignore Is As Important As .gitignore
Without a .dockerignore, the COPY . . instruction sends your entire working directory to the Docker daemon, including node_modules, .git, and local env files. Always create one.
node_modules
dist
.git
.env
.env.local
*.log
coverage
.next
README.mdDocker Compose for Local Development
Docker Compose orchestrates multi-container environments locally. Use it to run your app alongside its dependencies (PostgreSQL, Redis, etc.) with a single command.
# compose.yml
services:
api:
build:
context: .
target: builder # use the build stage for hot-reload
volumes:
- .:/app
- /app/node_modules # anonymous volume โ prevent host override
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://dev:dev@db:5432/dev
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: dev
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dev"]
interval: 5s
timeout: 5s
retries: 5
cache:
image: redis:7-alpine
volumes:
pg_data:Health Checks and Graceful Shutdown
Production containers need health checks so the orchestrator (Kubernetes, ECS) knows when a container is ready for traffic and when to restart it.
// NestJS health check endpoint
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private db: TypeOrmHealthIndicator,
private redis: MicroserviceHealthIndicator,
) {}
@Get()
@HealthCheck()
check() {
return this.health.check([
() => this.db.pingCheck('database'),
() => this.redis.pingCheck('redis', { transport: Transport.REDIS }),
]);
}
}
// Graceful shutdown โ drain in-flight requests before exit
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // handles SIGTERM from Docker/K8s
await app.listen(3000);
}Layer Caching: Order Your Dockerfile Correctly
Docker caches each layer. A cache miss on one layer invalidates all subsequent layers. Put the most frequently changing instructions last.
- FROM (base image โ changes rarely)
- System dependencies with apt/apk (changes rarely)
- COPY package.json package-lock.json (changes when deps change)
- RUN npm ci (re-runs only when lock file changes)
- COPY . . (invalidated by any source file change)
- RUN npm run build
Written by
Md. Saniuzzaman Robin
Full-Stack Software Engineer