Files
warmbly/docker-compose.yml
T

314 lines
12 KiB
YAML

# Warmbly — truly local, no-cloud self-host stack.
#
# docker compose up --build
#
# Brings up the whole platform with NO cloud account of any kind: no AWS, no
# GCP, no Stripe, no Kafka, no Astra. One command, everything local:
#
# Postgres — relational store
# Redis — cache + realtime pub/sub bridge
# NATS — event bus (JetStream); a single ~15MB binary, not Kafka+ZK+SR
# backend — API (:8080), applies migrations on boot
# consumer — event consumer
# worker — send/sync executor (add more for scale; see docs)
# tracking — open/click pixels (:3000)
# realtime — websocket fanout (:4000)
# web — dashboard (:5173) admin — control panel (:5174)
# mailpit — local SMTP sink + web UI (:18025) for testing outbound mail
#
# Providers (all local): EVENTBUS_PROVIDER=nats, KMS_PROVIDER=local,
# BLOB_PROVIDER=filesystem, TASKS_PROVIDER=local, CODEC_PROVIDER=json,
# BILLING_PROVIDER=none, CAPTCHA_PROVIDER=none, PUBSUB_ENABLED=false.
#
# Secrets have dev-friendly defaults so `docker compose up` just works. For a
# real deployment, put your own values in a .env file next to this file:
# AUTH_SECRET, KMS_LOCAL_MASTER_KEY (base64 32 bytes; `make gen-key`),
# CREDENTIALS_ENCRYPTION_KEY (64 hex), INTERNAL_API_TOKEN, and — to connect
# Gmail mailboxes — BOX_GOOGLE_CLIENT_ID / BOX_GOOGLE_CLIENT_SECRET.
#
# LOSING KMS_LOCAL_MASTER_KEY OR CREDENTIALS_ENCRYPTION_KEY IS UNRECOVERABLE:
# every stored mailbox credential is sealed with them. Back them up.
#
# To run on Kafka + S3 instead, overlay docker-compose.kafka.yml (see that file).
x-selfhost-env: &selfhost-env
APP_ENV: ${APP_ENV:-dev}
AWS_CONFIG_ENABLED: "false"
EVENTBUS_PROVIDER: nats
NATS_URL: nats://nats:4222
CODEC_PROVIDER: json
KMS_PROVIDER: local
KMS_LOCAL_MASTER_KEY: ${KMS_LOCAL_MASTER_KEY:-Xr0JA7gqF2POy29a7MRByyqddivTNt8WOyKsOXklazk=}
CREDENTIALS_ENCRYPTION_KEY: ${CREDENTIALS_ENCRYPTION_KEY:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}
BLOB_PROVIDER: filesystem
BLOB_FS_ROOT: /data/blobs
BLOB_PUBLIC_BASE_URL: ${API_PUBLIC_URL:-http://${PUBLIC_HOST:-localhost}:8080}/public
TASKS_PROVIDER: local
BILLING_PROVIDER: none
CAPTCHA_PROVIDER: none
PUBSUB_ENABLED: "false"
PRIMARY_DB: postgres://warmbly:warmbly@postgres:5432/warmbly_dev?sslmode=disable
REDIS: redis://redis:6379
AUTH_SECRET: ${AUTH_SECRET:-local-dev-auth-secret-minimum-32-characters-long}
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-local-dev-internal-token}
services:
# ─── infrastructure ───────────────────────────────────────────────────
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: warmbly
POSTGRES_PASSWORD: warmbly
POSTGRES_DB: warmbly_dev
ports: ["15432:5432"]
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U warmbly"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
ports: ["16379:6379"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
nats:
image: nats:2.10-alpine
# -js: JetStream (durable streams). -m 8222: HTTP monitoring for healthcheck.
command: ["-js", "-sd", "/data", "-m", "8222"]
ports: ["4222:4222", "8222:8222"]
volumes:
- nats_data:/data
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"]
interval: 5s
timeout: 3s
retries: 10
# Local SMTP sink + web UI so outbound mail is visible without a real relay.
# Point mailbox SMTP at mailpit:1025 (or use it as the notification relay).
mailpit:
image: axllent/mailpit:latest
ports:
- "18025:8025"
- "11025:1025"
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
# Local IMAP host for the sandbox demo (`make sandbox`): the mailbox that
# every seeded sender syncs from, so replies and warmup mail actually land in
# the unified inbox. Any username logs in with the static password "sandbox";
# the self-signed cert is fine because sandbox clients set MAIL_TLS_INSECURE.
# In the `sandbox` profile so the lean `make up` self-host never pulls it —
# `make sandbox` / `make infra` start it by naming it explicitly.
dovecot:
image: dovecot/dovecot:latest
profiles: ["sandbox"]
environment:
USER_PASSWORD: "{PLAIN}sandbox"
ports:
- "10143:31143" # IMAP (cleartext; debugging only)
- "10993:31993" # IMAPS (self-signed cert; the sandbox worker + simulator dial this)
healthcheck:
test: ["CMD", "doveadm", "service", "status", "imap-login"]
interval: 5s
timeout: 3s
retries: 10
# ─── application ──────────────────────────────────────────────────────
backend:
build:
context: .
dockerfile: deploy/docker/backend.Dockerfile
ports: ["8080:8080"]
environment:
<<: *selfhost-env
API_HOST: "0.0.0.0:8080"
GIN_MODE: release
# To reach Warmbly from another machine, set PUBLIC_HOST=<ip-or-domain> in
# .env — every URL below derives from it. Or set each URL explicitly.
APP_URL: ${APP_URL:-http://${PUBLIC_HOST:-localhost}:5173}
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-http://${PUBLIC_HOST:-localhost}:5173,http://${PUBLIC_HOST:-localhost}:5174}
WEBSOCKET_URL: ${WEBSOCKET_URL:-ws://${PUBLIC_HOST:-localhost}:4000/socket/websocket}
ENCRYPTED_KEYS_PROVIDER: postgres
EMAIL_NAME: ${EMAIL_NAME:-Warmbly}
EMAIL_ADDRESS: ${EMAIL_ADDRESS:-noreply@warmbly.local}
SMTP_HOST: ${SMTP_HOST:-mailpit}
SMTP_PORT: ${SMTP_PORT:-1025}
TRACKING_DOMAIN: ${TRACKING_DOMAIN:-${PUBLIC_HOST:-localhost}:3000}
GEODB_PATH: /app/data/GeoLite2-City.mmdb
# Gmail mailbox OAuth (leave unset to connect only SMTP/IMAP + Outlook
# mailboxes). See the self-hosting docs for the Google Cloud setup.
BOX_GOOGLE_CLIENT_ID: ${BOX_GOOGLE_CLIENT_ID:-}
BOX_GOOGLE_CLIENT_SECRET: ${BOX_GOOGLE_CLIENT_SECRET:-}
BOX_OUTLOOK_CLIENT_ID: ${BOX_OUTLOOK_CLIENT_ID:-}
BOX_OUTLOOK_CLIENT_SECRET: ${BOX_OUTLOOK_CLIENT_SECRET:-}
volumes:
- blobs:/data/blobs
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
nats: { condition: service_healthy }
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
interval: 10s
timeout: 3s
retries: 10
start_period: 20s
consumer:
build:
context: .
dockerfile: deploy/docker/consumer.Dockerfile
environment:
<<: *selfhost-env
ENCRYPTED_KEYS_PROVIDER: postgres
volumes:
- blobs:/data/blobs
depends_on:
backend: { condition: service_healthy }
nats: { condition: service_healthy }
# One worker by default. Scale out with `docker compose up --scale worker=3`
# (or run more on other machines via the SSH enrollment flow). Outbound IPs
# belong to the mail provider, so more workers = more parallelism, not more IPs.
worker:
build:
context: .
dockerfile: deploy/docker/worker.Dockerfile
environment:
<<: *selfhost-env
MAIL_TLS_INSECURE: "true"
ENCRYPTED_KEYS_PROVIDER: http
ENCRYPTED_KEYS_BACKEND_URL: http://backend:8080
ENCRYPTED_KEYS_WORKER_TOKEN: ${INTERNAL_API_TOKEN:-local-dev-internal-token}
BOX_GOOGLE_CLIENT_ID: ${BOX_GOOGLE_CLIENT_ID:-}
BOX_GOOGLE_CLIENT_SECRET: ${BOX_GOOGLE_CLIENT_SECRET:-}
BOX_OUTLOOK_CLIENT_ID: ${BOX_OUTLOOK_CLIENT_ID:-}
BOX_OUTLOOK_CLIENT_SECRET: ${BOX_OUTLOOK_CLIENT_SECRET:-}
volumes:
- blobs:/data/blobs
depends_on:
backend: { condition: service_healthy }
nats: { condition: service_healthy }
tracking:
build:
context: ./tracking
dockerfile: Dockerfile
ports: ["3000:3000"]
environment:
APP_ENV: ${APP_ENV:-dev}
AWS_CONFIG_ENABLED: "false"
TRACKING_HOST: "0.0.0.0"
TRACKING_PORT: "3000"
EVENTBUS_PROVIDER: nats
NATS_URL: nats://nats:4222
KAFKA_TRACKING_TOPIC: tracking-events
# Overridable so `make dev` (native backend) can point at
# host.docker.internal:8080 while `docker compose up` uses the container.
BACKEND_INTERNAL_URL: ${BACKEND_INTERNAL_URL:-http://backend:8080}
INTERNAL_API_TOKEN: ${INTERNAL_API_TOKEN:-local-dev-internal-token}
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
nats: { condition: service_healthy }
realtime:
build:
context: .
dockerfile: deploy/docker/realtime.Dockerfile
ports: ["4000:4000"]
environment:
PHX_HOST: ${PHX_HOST:-${PUBLIC_HOST:-localhost}}
PORT: 4000
DATABASE_URL: postgres://warmbly:warmbly@postgres:5432/warmbly_dev?sslmode=disable
DATABASE_SSL: "false"
DATABASE_POOL_SIZE: "10"
REDIS_URL: redis://redis:6379
# Must equal the backend AUTH_SECRET so JWTs validate across services.
JWT_SECRET: ${AUTH_SECRET:-local-dev-auth-secret-minimum-32-characters-long}
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-local-development-secret-key-base-minimum-64-characters-for-phoenix}
PUBSUB_ENABLED: "false"
CHECK_ORIGIN: "false"
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
web:
image: node:22-alpine
working_dir: /app
volumes:
- ./web:/app
- web_node_modules:/app/node_modules
ports: ["5173:5173"]
command: >
sh -c '
until corepack enable; do echo "corepack retry..."; sleep 2; done;
pnpm install || { echo "pnpm install failed — fix package.json then restart web"; exit 1; };
pnpm dev --host
'
environment:
VITE_APP_URL: ${APP_URL:-http://${PUBLIC_HOST:-localhost}:5173}
VITE_API_URL: ${API_PUBLIC_URL:-http://${PUBLIC_HOST:-localhost}:8080}
VITE_TRACKING_DOMAIN: ${TRACKING_DOMAIN:-${PUBLIC_HOST:-localhost}:3000}
# Captcha is off server-side (CAPTCHA_PROVIDER=none); the dev bypass token
# keeps the widget satisfied without a Cloudflare key.
VITE_TURNSTILE_KEY: "1x00000000000000000000AA"
VITE_TURNSTILE_BYPASS_TOKEN: "warmbly-local-turnstile-bypass"
depends_on:
backend: { condition: service_healthy }
admin:
image: node:22-alpine
working_dir: /app
volumes:
- ./admin:/app
- admin_node_modules:/app/node_modules
ports: ["5174:5174"]
command: >
sh -c '
until corepack enable; do echo "corepack retry..."; sleep 2; done;
pnpm install || { echo "pnpm install failed — fix package.json then restart admin"; exit 1; };
pnpm dev --host --port 5174
'
environment:
VITE_API_URL: ${API_PUBLIC_URL:-http://${PUBLIC_HOST:-localhost}:8080}
depends_on:
backend: { condition: service_healthy }
# ─── one-shots ────────────────────────────────────────────────────────
# Optional demo seed. Run explicitly: docker compose --profile seed run --rm seed
seed:
build:
context: .
dockerfile: deploy/docker/backend.Dockerfile
entrypoint: ["/app/seed"]
environment:
<<: *selfhost-env
SEED_RICH: "true"
depends_on:
backend: { condition: service_healthy }
profiles: ["seed"]
volumes:
postgres_data:
redis_data:
nats_data:
blobs:
web_node_modules:
admin_node_modules: