# Standalone self-host compose file — no repo clone needed. Quickstart: # curl -O https://raw.githubusercontent.com/MillionSend/millionsend/main/deploy/docker-compose.yml # curl -o .env https://raw.githubusercontent.com/MillionSend/millionsend/main/.env.example # (generate the two secrets in .env, then) # docker compose up -d # Upgrade: docker compose pull && docker compose up -d — migrations run on boot. # The default image is the latest tagged release (:edge follows main); to hold # a version, set MILLIONSEND_IMAGE in .env to a version tag or an immutable # @sha256 digest. # # Optional services sit behind compose profiles — COMPOSE_PROFILES in .env: # docs the documentation site on DOCS_PORT # smtp the SMTP relay; mount a STARTTLS keypair first (SELF_HOSTING.md) # mx inbound mail on port 25; needs RECEIVING_MX_HOST (SELF_HOSTING.md) # clamav virus scanning of received mail; set CLAMAV_HOST=clamav:3310 # backup scheduled pg_dump to S3-compatible storage; needs S3_BACKUP_BUCKET services: postgres: image: postgres:17-alpine@sha256:eee6c6e1b0894fa4fe0075eb054af9fd1cf39a35321c2d4110626cfe506411fb # Three app pools plus the queue's own would exceed the 100 default; unused slots cost nothing. # No parallel query: a queue fetch must never go parallel, a two-core box gains nothing from it, # and a bad plan then costs one core instead of all of them. # No JIT: on these short queries the LLVM compile costs more CPU than it saves, and every # backend that compiles once keeps LLVM loaded for as long as it lives. command: ["postgres", "-c", "max_connections=200", "-c", "max_parallel_workers_per_gather=0", "-c", "jit=off"] environment: POSTGRES_USER: millionsend POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-millionsend} POSTGRES_DB: millionsend volumes: - pgdata:/var/lib/postgresql/data restart: unless-stopped healthcheck: test: ["CMD-SHELL", "pg_isready -U millionsend -d millionsend"] interval: 5s timeout: 5s retries: 10 millionsend: image: "${MILLIONSEND_IMAGE:-ghcr.io/millionsend/millionsend:latest}" env_file: .env environment: PROCESS: all ports: - "${WEB_BIND_ADDRESS:-127.0.0.1}:${WEB_PORT:-3000}:3000" # web dashboard - "${API_BIND_ADDRESS:-127.0.0.1}:${PORT:-3001}:${PORT:-3001}" # api depends_on: postgres: condition: service_healthy restart: unless-stopped # Above the supervisor's 35 s child-exit budget (scripts/start.mjs). stop_grace_period: 45s init: true security_opt: - no-new-privileges:true cap_drop: - ALL read_only: true tmpfs: - /tmp - /app/apps/web/.next/cache - /app/apps/docs/.next/cache - /app/apps/docs/.source docs: image: "${MILLIONSEND_IMAGE:-ghcr.io/millionsend/millionsend:latest}" env_file: .env environment: PROCESS: docs ports: # The docs process is fixed at 3002 inside the container; static content, no database. - "${DOCS_BIND_ADDRESS:-127.0.0.1}:${DOCS_PORT:-3002}:3002" # documentation site profiles: ["docs"] restart: unless-stopped init: true security_opt: - no-new-privileges:true cap_drop: - ALL read_only: true tmpfs: - /tmp - /app/apps/web/.next/cache - /app/apps/docs/.next/cache - /app/apps/docs/.source smtp: image: "${MILLIONSEND_IMAGE:-ghcr.io/millionsend/millionsend:latest}" env_file: .env environment: PROCESS: smtp ports: - "${SMTP_BIND_ADDRESS:-127.0.0.1}:${SMTP_PORT:-2587}:${SMTP_PORT:-2587}" # smtp relay # Opt-in: without a mounted STARTTLS keypair the relay refuses to start, # and an always-on service would restart-loop from the first boot. profiles: ["smtp"] depends_on: postgres: condition: service_healthy restart: unless-stopped init: true security_opt: - no-new-privileges:true cap_drop: - ALL read_only: true tmpfs: - /tmp - /app/apps/web/.next/cache - /app/apps/docs/.next/cache - /app/apps/docs/.source mx: image: "${MILLIONSEND_IMAGE:-ghcr.io/millionsend/millionsend:latest}" # Its own variables only, not env_file: the MX faces the internet and has # no use for the app's keys. environment: PROCESS: mx MX_INGEST_URL: "http://millionsend:${PORT:-3001}/internal/mx" MX_INGEST_SECRET: "${MX_INGEST_SECRET:-}" MX_HOSTNAME: "${MX_HOSTNAME:-${RECEIVING_MX_HOST:-}}" MX_TLS_CERT_PATH: /tls/fullchain.pem MX_TLS_KEY_PATH: /tls/privkey.pem ports: # Public by design: other mail servers deliver here. IPv4 only: over # IPv6, Docker's proxy would replace each client's address with its own. - "0.0.0.0:25:2525" volumes: # A certbot deploy hook copies the keypair here; STARTTLS follows it. - ./mx-tls:/tls:ro # The keypair is root:1000 0640 on the host; this group lets the image's # non-root user read it. group_add: ["1000"] profiles: ["mx"] restart: unless-stopped # Above the supervisor's 35 s child-exit budget (scripts/start.mjs). stop_grace_period: 45s init: true security_opt: - no-new-privileges:true cap_drop: - ALL read_only: true tmpfs: - /tmp clamav: # ClamAV's Debian build: the Alpine-based clamav/clamav image is published # for amd64 only, and this stack also runs on arm64. image: clamav/clamav-debian:1.4 # clamd keeps its signatures in memory; ClamAV recommends 4 GiB of RAM. No # host port: clamd's protocol has neither authentication nor encryption. profiles: ["clamav"] volumes: # Without it, every restart downloads the whole signature set again. - clamav-db:/var/lib/clamav restart: unless-stopped # No cap_drop or read_only here: the image's entrypoint chowns the # database and creates /run/clamav as root before dropping to its own user. security_opt: - no-new-privileges:true backup: # Published from scripts/backup on the same tags as the app. image: "${MILLIONSEND_BACKUP_IMAGE:-ghcr.io/millionsend/backup:latest}" env_file: .env # Stays a no-op (prints a hint, then idles: an exit would restart-loop it) # until S3_BACKUP_BUCKET and the S3_* credentials are set in .env. profiles: ["backup"] restart: unless-stopped init: true security_opt: - no-new-privileges:true cap_drop: - ALL read_only: true volumes: # pg_dump stages the dump on disk before upload; a volume rather than # tmpfs so a large database does not land in RAM. - backup-scratch:/tmp depends_on: postgres: condition: service_healthy volumes: pgdata: backup-scratch: clamav-db: