Dockerfile
Multi-stage build optimized for production:
# 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"]node_modules
.git
.env
*.md
tests
coverage
.turboNon-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:
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:docker compose up -dMulti-Replica with Compose
Scale to multiple replicas with nginx as a load balancer:
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: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):
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:
docker stack deploy -c docker-compose.swarm.yml courierSwarm Key Points
| Setting | Value | Why |
|---|---|---|
update_config.order: start-first | Zero-downtime | New replica accepts traffic before old one stops |
update_config.delay: 30s | Connection drain | Time for existing connections to gracefully close |
sticky.cookie | Session affinity | SSE/WS connections must stay on the same replica |
flushinterval: 1ms | SSE streaming | Traefik flushes response immediately (no buffering) |
responseHeaderTimeout: 0s | Long-lived | Traefik never times out SSE/WS connections |
Dokploy Configuration
If using Dokploy, configure these settings in the application dashboard:
- Replicas: Set to 3+ in the deployment section
- Environment variables: Add
REDIS_URL,ALLOWED_ORIGIN,PORT,HOST - Health check: Path
/readyz, interval 15s - 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=1msDrain 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
| Variable | Default | Description |
|---|---|---|
PORT | 3001 | Server port |
HOST | 0.0.0.0 | Bind 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_LEVEL | info | Lowest log level printed: debug, info, warn, error or silent |
COURIER_LOG_FORMAT | text | json 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 |