mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-20 00:01:21 +00:00
314 lines
12 KiB
YAML
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:
|