Configuration

Docker Compose

Cleanmails runs as a six-container Docker Compose stack: PostgreSQL, Redis, API server, background worker, Next.js frontend, and Caddy reverse proxy.

Services

ServiceImagePurposePorts
postgrespostgres:16-alpinePrimary database5432 (internal)
redisredis:7-alpineTask queue & cache (Asynq)6379 (internal)
apicleanmails-api:latestGo API server (Gin)8080 (internal)
workercleanmails-worker:latestBackground job processorNone
frontendcleanmails-frontend:latestNext.js dashboard3000 (internal)
caddycaddy:2-alpineReverse proxy + auto SSL80, 443

Production Compose File

docker-compose.prod.yml
services:
  postgres:
    image: postgres:16-alpine
    restart: always
    environment:
      POSTGRES_DB: cleanmails
      POSTGRES_USER: cleanmails
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U cleanmails"]
      interval: 5s
      timeout: 5s
      retries: 5
    deploy:
      resources:
        limits:
          memory: 512M

  redis:
    image: redis:7-alpine
    restart: always
    command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru --appendonly yes
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 5
    deploy:
      resources:
        limits:
          memory: 192M

  api:
    image: cleanmails-api:latest
    restart: always
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    env_file: .env
    environment:
      - GIN_MODE=release
    volumes:
      - uploads_data:/app/uploads
      - /opt/cleanmails:/opt/cleanmails
      - /var/run/docker.sock:/var/run/docker.sock
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 10s
    deploy:
      resources:
        limits:
          memory: 256M

  worker:
    image: cleanmails-worker:latest
    restart: always
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    env_file: .env
    deploy:
      resources:
        limits:
          memory: 256M

  frontend:
    image: cleanmails-frontend:latest
    restart: always
    depends_on:
      api:
        condition: service_healthy
    environment:
      - HOSTNAME=0.0.0.0
      - PORT=3000
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 15s
    deploy:
      resources:
        limits:
          memory: 256M

  caddy:
    image: caddy:2-alpine
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      frontend:
        condition: service_healthy
      api:
        condition: service_healthy
    deploy:
      resources:
        limits:
          memory: 64M

volumes:
  pgdata:
  redis_data:
  caddy_data:
  caddy_config:
  uploads_data:

Caddyfile

Caddy routes API requests to the Go backend and all other traffic to the Next.js frontend:

Caddyfile
yourdomain.com {
    handle /api/* {
        reverse_proxy api:8080
    }
    handle /health {
        reverse_proxy api:8080
    }
    handle /t/* {
        reverse_proxy api:8080
    }
    handle /unsubscribe/* {
        reverse_proxy api:8080
    }
    handle {
        reverse_proxy frontend:3000
    }
    header {
        X-Content-Type-Options nosniff
        X-Frame-Options DENY
        Strict-Transport-Security "max-age=31536000"
    }
}

Caddy auto-provisions Let's Encrypt certificates when a domain is configured.

Volumes

VolumePurpose
pgdataPostgreSQL database files
redis_dataRedis AOF persistence
uploads_dataUploaded files (logos, CSVs)
caddy_dataSSL certificates
caddy_configCaddy configuration state

Common Commands

bash
# Start all services
cd /opt/cleanmails
docker compose -f docker-compose.prod.yml up -d

# View logs
docker compose -f docker-compose.prod.yml logs -f

# View logs for a specific service
docker compose -f docker-compose.prod.yml logs -f api

# Stop all services
docker compose -f docker-compose.prod.yml down

# Restart a single service
docker compose -f docker-compose.prod.yml restart api

# Check status
docker compose -f docker-compose.prod.yml ps

# Rebuild after update
docker compose -f docker-compose.prod.yml up -d --force-recreate
Docker socket mount

The API container mounts /var/run/docker.sock and /opt/cleanmails. This is required for the self-update feature and system management capabilities.

Health Checks

All services include Docker health checks. The startup order is enforced via depends_on with condition: service_healthy:

  1. PostgreSQL + Redis start first and must pass health checks
  2. API + Worker start after database and cache are healthy
  3. Frontend starts after the API is healthy
  4. Caddy starts after both frontend and API are healthy

Development Compose

For local development, a simpler docker-compose.yml runs only PostgreSQL and Redis:

docker-compose.yml (dev)
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: cleanmails
      POSTGRES_USER: cleanmails
      POSTGRES_PASSWORD: cleanmails_secret
    ports:
      - "5432:5432"

  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
    ports:
      - "6379:6379"

Run the API and worker directly with go run during development.