TechLead
Lesson 13 of 18
5 min read
Docker & DevOps

Docker for Backend & APIs

Build production-ready Docker configurations for Node.js, Express, and other backend services

Node.js / Express API

Production Dockerfile

# Dockerfile
FROM node:20-alpine AS builder

WORKDIR /app

# Install dependencies first (better caching)
COPY package*.json ./
RUN npm ci

# Copy source and build
COPY . .
RUN npm run build

# Production stage
FROM node:20-alpine

# Security: non-root user
RUN addgroup -S app && adduser -S app -G app

WORKDIR /app

COPY --from=builder /app/package*.json ./
RUN npm ci --production && npm cache clean --force

COPY --from=builder --chown=app:app /app/dist ./dist

USER app

ENV NODE_ENV=production
EXPOSE 4000

# Use dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/server.js"]

Health Check Endpoint

// server.js — Health check endpoint
app.get('/health', (req, res) => {
  res.status(200).json({
    status: 'healthy',
    uptime: process.uptime(),
    timestamp: new Date().toISOString()
  });
});

app.get('/ready', async (req, res) => {
  try {
    // Check database connection
    await db.query('SELECT 1');
    // Check Redis connection
    await redis.ping();
    res.status(200).json({ status: 'ready' });
  } catch (error) {
    res.status(503).json({ status: 'not ready', error: error.message });
  }
});

Docker Compose with Health Checks

# docker-compose.yml
services:
  api:
    build: .
    ports:
      - "4000:4000"
    environment:
      DATABASE_URL: postgres://admin:secret@db:5432/myapp
      REDIS_URL: redis://redis:6379
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:4000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U admin"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data
    command: redis-server --appendonly yes

volumes:
  pgdata:
  redis-data:

Graceful Shutdown

// Graceful shutdown for Docker containers
const server = app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

// Handle SIGTERM (docker stop)
process.on('SIGTERM', () => {
  console.log('SIGTERM received. Shutting down gracefully...');
  server.close(async () => {
    // Close database connections
    await db.end();
    await redis.quit();
    console.log('Server closed.');
    process.exit(0);
  });

  // Force exit after 30 seconds
  setTimeout(() => {
    console.error('Forced shutdown after timeout');
    process.exit(1);
  }, 30000);
});

Logging Best Practices

// Log to stdout/stderr (Docker captures these)
// ✅ Good: structured JSON logging
const log = (level, message, meta = {}) => {
  console.log(JSON.stringify({
    timestamp: new Date().toISOString(),
    level,
    message,
    ...meta
  }));
};

log('info', 'Server started', { port: 4000 });
log('error', 'Database connection failed', { error: err.message });

// ❌ Bad: logging to files inside the container
// fs.writeFileSync('/var/log/app.log', message);

Production Checklist

  • ✅ Use multi-stage builds to minimize image size
  • ✅ Run as non-root user
  • ✅ Implement health check endpoints (/health and /ready)
  • ✅ Handle SIGTERM for graceful shutdown
  • ✅ Log to stdout/stderr in structured JSON format
  • ✅ Use dumb-init or tini as PID 1

Continue Learning