Skip to content

DeploymentDocker

Docker

Deploy Courier with Docker — multi-stage Dockerfile, Docker Compose with Redis, and Docker Swarm with Traefik for horizontal scaling.

3 min read

Dockerfile

Multi-stage build optimized for production:

Dockerfile
# Stage 1: Install dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# Stage 2: Build
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 3: Production
FROM node:22-alpine AS production
WORKDIR /app

RUN addgroup -g 1001 -S courier && adduser -S courier -u 1001
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./

USER courier
EXPOSE 3001

ENV NODE_ENV=production
ENV PORT=3001
ENV HOST=0.0.0.0

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1

CMD ["node", "dist/server.js"]
.dockerignore
node_modules
.git
.env
*.md
tests
coverage
.turbo

Non-root user

The courier user (UID 1001) runs the process without root privileges. This is a security best practice for production containers.

Docker Compose

Single-command development and staging setup with Redis:

docker-compose.yml
services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3

  courier:
    build: .
    ports:
      - "3001:3001"
    environment:
      - PORT=3001
      - HOST=0.0.0.0
      - REDIS_URL=redis://redis:6379
      - ALLOWED_ORIGIN=http://localhost:3000
    depends_on:
      redis:
        condition: service_healthy
    restart: unless-stopped

volumes:
  redis-data:
bash
docker compose up -d

Multi-Replica with Compose

Scale to multiple replicas with nginx as a load balancer:

docker-compose.prod.yml
services:
  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data
    command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3

  courier:
    build: .
    deploy:
      replicas: 3
    environment:
      - PORT=3001
      - HOST=0.0.0.0
      - REDIS_URL=redis://redis:6379
      - ALLOWED_ORIGIN=https://app.example.com
    depends_on:
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3001/readyz"]
      interval: 15s
      timeout: 5s
      retries: 3

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - courier

volumes:
  redis-data:
nginx.conf
events {
  worker_connections 4096;
}

http {
  upstream courier {
    ip_hash;  # sticky sessions for SSE/WS
    server courier:3001;
  }

  map $http_upgrade $connection_upgrade {
    default upgrade;
    "" close;
  }

  server {
    listen 80;

    location / {
      proxy_pass http://courier;
      proxy_http_version 1.1;

      # WebSocket upgrade
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection $connection_upgrade;

      # SSE — disable buffering
      proxy_buffering off;
      proxy_cache off;
      proxy_set_header X-Accel-Buffering no;

      # Long-lived connections
      proxy_read_timeout 86400s;
      proxy_send_timeout 86400s;

      # Pass client info
      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Proto $scheme;
    }
  }
}

Docker Swarm

For production deployments with Docker Swarm (including Dokploy):

docker-compose.swarm.yml
version: "3.8"

services:
  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
    volumes:
      - redis-data:/data
    networks:
      - courier-net
    deploy:
      replicas: 1
      placement:
        constraints:
          - node.role == manager
      resources:
        limits:
          memory: 512M

  courier:
    image: your-registry/courier-server:latest
    environment:
      - PORT=3001
      - HOST=0.0.0.0
      - REDIS_URL=redis://redis:6379
      - ALLOWED_ORIGIN=https://app.example.com
    networks:
      - courier-net
    deploy:
      replicas: 3
      update_config:
        parallelism: 1
        delay: 30s
        order: start-first        # zero-downtime: new replica starts before old stops
        failure_action: rollback
      rollback_config:
        parallelism: 0
        order: stop-first
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3
      resources:
        limits:
          cpus: "1"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M
      labels:
        # Traefik labels for automatic routing
        - "traefik.enable=true"
        - "traefik.http.routers.courier.rule=Host(`api.example.com`)"
        - "traefik.http.routers.courier.entrypoints=websecure"
        - "traefik.http.routers.courier.tls.certresolver=letsencrypt"
        - "traefik.http.services.courier.loadbalancer.server.port=3001"

        # Sticky sessions (required for SSE/WS)
        - "traefik.http.services.courier.loadbalancer.sticky.cookie=true"
        - "traefik.http.services.courier.loadbalancer.sticky.cookie.name=courier_affinity"
        - "traefik.http.services.courier.loadbalancer.sticky.cookie.httponly=true"
        - "traefik.http.services.courier.loadbalancer.sticky.cookie.secure=true"
        - "traefik.http.services.courier.loadbalancer.sticky.cookie.samesite=strict"

        # Timeouts for long-lived connections
        - "traefik.http.services.courier.loadbalancer.responseforwarding.flushinterval=1ms"
        - "traefik.http.middlewares.courier-timeout.headers.customrequestheaders.X-Accel-Buffering=no"

    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3001/readyz"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 10s

  traefik:
    image: traefik:v3
    command:
      - "--providers.swarm.endpoint=unix:///var/run/docker.sock"
      - "--providers.swarm.exposedbydefault=false"
      - "--entrypoints.websecure.address=:443"
      - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
      - "--certificatesresolvers.letsencrypt.acme.email=admin@example.com"
      - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
      - "--serversTransport.forwardingTimeouts.dialTimeout=30s"
      - "--serversTransport.forwardingTimeouts.responseHeaderTimeout=0s"
      - "--serversTransport.forwardingTimeouts.idleConnTimeout=90s"
    ports:
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - letsencrypt:/letsencrypt
    networks:
      - courier-net
    deploy:
      placement:
        constraints:
          - node.role == manager

networks:
  courier-net:
    driver: overlay
    attachable: true

volumes:
  redis-data:
  letsencrypt:

Deploy to Swarm:

bash
docker stack deploy -c docker-compose.swarm.yml courier

Swarm Key Points

SettingValueWhy
update_config.order: start-firstZero-downtimeNew replica accepts traffic before old one stops
update_config.delay: 30sConnection drainTime for existing connections to gracefully close
sticky.cookieSession affinitySSE/WS connections must stay on the same replica
flushinterval: 1msSSE streamingTraefik flushes response immediately (no buffering)
responseHeaderTimeout: 0sLong-livedTraefik never times out SSE/WS connections

Dokploy Configuration

If using Dokploy, configure these settings in the application dashboard:

  1. Replicas: Set to 3+ in the deployment section
  2. Environment variables: Add REDIS_URL, ALLOWED_ORIGIN, PORT, HOST
  3. Health check: Path /readyz, interval 15s
  4. Traefik labels: Dokploy auto-generates basic Traefik labels. Add sticky session labels manually in the "Advanced" tab:
traefik.http.services.${SERVICE}.loadbalancer.sticky.cookie=true
traefik.http.services.${SERVICE}.loadbalancer.sticky.cookie.name=courier_affinity
traefik.http.services.${SERVICE}.loadbalancer.responseforwarding.flushinterval=1ms

Drain timeout

Set update_config.delay to at least 30 seconds. This gives existing SSE/WS connections time to gracefully reconnect to a new replica during rolling updates.

Environment Variables

VariableDefaultDescription
PORT3001Server port
HOST0.0.0.0Bind address
REDIS_URL—Redis connection string
ALLOWED_ORIGIN—CORS allowed origin
NODE_ENV—Set to production
HOSTNAME—Auto-set by Docker/K8s, used as PubSub origin
COURIER_LOG_LEVELinfoLowest log level printed: debug, info, warn, error or silent
COURIER_LOG_FORMATtextjson writes one JSON object per line — time, level, scope, pid, message, error — for a log pipeline
NO_COLOR—Any non-empty value turns colour off in the log
FORCE_COLOR—1 keeps colour in the log even when it is not a terminal