Self-hosting
Run the whole Warmbly platform yourself, from a one-command Docker stack to a distributed worker fleet.
The default stack needs no cloud account of any kind: no AWS, no GCP, no Stripe, no Kafka. One docker-compose.yml runs everything on local, open-source pieces, and every external dependency is an env-var switch you can flip later.
Quick start
You need Docker 20.10+ with Compose v2 and Git. 2 cores / 4 GB RAM is comfortable for a small install.
git clone https://github.com/warmbly/warmbly && cd warmbly
make up # or: docker compose -p warmbly up -d --buildThen:
-
Open
http://localhost:5173and register (captcha is off by default). -
Grant yourself platform admin (the only way to seed the first one), then sign in at
http://localhost:5174:make grant-admin [email protected] # ROLE=super|support|ops|analyst -
Optional demo data (
[email protected]/password123, mailboxes, campaigns, history):docker compose -p warmbly --profile seed run --rm seed

For a demo where mail actually flows end to end (sends, replies, opens, clicks), see the sandbox.
Set your secrets
The compose file ships dev defaults for every secret so the quick start works. Before exposing the install, set your own in a .env next to docker-compose.yml:
AUTH_SECRET=<random, 32+ chars> # JWT signing; shared with realtime
INTERNAL_API_TOKEN=<random> # workers + tracking authenticate with this
SECRET_KEY_BASE=<random, 64+ chars> # Phoenix session key (realtime)
KMS_LOCAL_MASTER_KEY=<make gen-key> # 32 bytes base64; seals per-org data keys
CREDENTIALS_ENCRYPTION_KEY=<openssl rand -hex 32> # exactly 64 hex chars; seals mailbox credentialsBack up KMS_LOCAL_MASTER_KEY and CREDENTIALS_ENCRYPTION_KEY. They seal every stored mailbox credential; losing them is unrecoverable.
What's running
| Service | Port | Purpose |
|---|---|---|
| web / admin | 5173 / 5174 | Dashboard and admin panel (nginx static builds, URLs injected at container start) |
| backend | 8080 | REST API; applies migrations on every boot; GET /health |
| tracking | 3000 | Open pixels and click redirects; GET /health |
| realtime | 4000 | WebSocket fanout; GET /health |
| consumer / worker | none | Event processor; send/sync executor |
| postgres / redis | 15432 / 16379 | PostgreSQL 16; cache and realtime bridge |
| nats | 4222 (mon 8222) | Event bus (JetStream) |
| mailpit | 18025 (SMTP 11025) | Dev mail catcher for notification email |
There is no separate migration step: migrations are embedded in the backend binary (a standalone /app/migrate binary ships in the image too).
Reaching it from your network
Set one variable in .env and every URL (app, CORS, websocket, tracking, blobs, frontend config) derives from it:
PUBLIC_HOST=192.168.1.50 # your machine's LAN IP, or a domainThen open http://192.168.1.50:5173.
Behind a reverse proxy with HTTPS
Terminate TLS in a proxy (Caddy, nginx, Traefik) and set the URLs explicitly instead of PUBLIC_HOST (compose only derives plain http:// forms):
APP_URL=https://app.example.com # proxy to :5173
API_PUBLIC_URL=https://api.example.com # proxy to :8080
CORS_ALLOW_ORIGINS=https://app.example.com,https://admin.example.com
WEBSOCKET_URL=wss://ws.example.com/socket/websocket # proxy to :4000
TRACKING_DOMAIN=t.example.com # proxy to :3000; use a separate, neutral domain
PHX_HOST=ws.example.comConnect Gmail and Microsoft mailboxes
SMTP/IMAP mailboxes need no extra setup. For Gmail and Microsoft 365, create OAuth apps and set:
BOX_GOOGLE_CLIENT_ID=... # redirect URI: <api base>/addresses/google/callback
BOX_GOOGLE_CLIENT_SECRET=...
BOX_OUTLOOK_CLIENT_ID=... # redirect URI: <api base>/addresses/outlook/callback
BOX_OUTLOOK_CLIENT_SECRET=...These must reach the backend and every worker (compose does this; remote workers get them via their profile). Without them on the worker, a connected mailbox stalls when its first token expires, about an hour in. They are separate from GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET, which enable sign-in with Google.
Notification email
The backend's own transactional mail (confirmations, resets, digests) goes out over SMTP. Compose points it at the bundled Mailpit; switch to a real relay before going live:
SMTP_HOST=smtp.example.com # unset falls back to AWS SES (requires AWS credentials)
SMTP_PORT=587
EMAIL_NAME=Warmbly
EMAIL_ADDRESS=[email protected]Provider switches
| Subsystem | Compose default | Opt-in |
|---|---|---|
| Event bus | NATS JetStream (EVENTBUS_PROVIDER=nats) | Kafka (=kafka, needs a kafka-tagged build) |
| Codec | JSON (CODEC_PROVIDER=json) | Avro + Schema Registry (=avro, same tag) |
| Root key | Local AES (KMS_PROVIDER=local) | AWS KMS (=aws) |
| Blob storage | Filesystem (BLOB_PROVIDER=filesystem) | S3 / MinIO / R2 (=s3 + BLOB_BUCKET) |
| Task scheduler | In-process poller (TASKS_PROVIDER=local) | GCP Cloud Tasks (=gcloud) |
| Billing | Off (BILLING_PROVIDER=none, everything unlocked) | Stripe (=stripe) |
| Captcha | Off (CAPTCHA_PROVIDER=none) | Cloudflare Turnstile (=turnstile) |
| Realtime transport | Redis pub/sub (PUBSUB_ENABLED=false) | Google Cloud Pub/Sub (=true + GCP_PROJECT_ID) |
Three gotchas:
- Bare binaries default to the cloud values (
kafka,avro,s3,aws). Compose, the Makefile, andenv.exampleset the local values for you; hand-rolled environments must set them or the process exits at boot. - Kafka/Avro are also build-time opt-ins:
--build-arg GO_TAGS=kafka(Go),--build-arg CARGO_FEATURES=kafka(tracking). PUBSUB_ENABLEDmust match across backend, consumer, and realtime.
Optional subsystems
| Feature | Enable with |
|---|---|
| Stripe billing | BILLING_PROVIDER=stripe, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET |
| Turnstile captcha | CAPTCHA_PROVIDER=turnstile + TURNSTILE_SECRET (backend), WARMBLY_TURNSTILE_KEY (web/admin). Compose pins captcha off, so use a docker-compose.override.yml |
| Sign in with Google | GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI |
| Sign in with Apple | APPLE_APP_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_KEY_SECRET |
| Mobile push (APNs) | APNS_KEY_PATH (or APNS_KEY), APNS_KEY_ID, APNS_TEAM_ID, APNS_TOPIC on backend + consumer; partial config disables push with a warning, never a crash |
| Notification tuning | NOTIFICATION_PUSH_WINDOW (default 5h), NOTIFICATION_EMAIL_DAILY_CAP (default 25, 0 means uncapped) |
| Error tracking | SENTRY_DSN; optional in dev, required on every Go service when APP_ENV=prod (which also makes the GeoIP file at GEODB_PATH mandatory) |
AI provider
Omit all AI vars to run with AI off: AI endpoints return a clean 503, everything else works. Set on backend and consumer:
AI_PROVIDER=openai # openai | openrouter | groq | ollama | anthropic | custom
AI_API_KEY=sk-... # not needed for ollama
AI_MODEL= # blank uses the provider's preset
AI_BASE_URL= # required for custom (any OpenAI-compatible endpoint)AI_PROVIDER | Endpoint | Notes |
|---|---|---|
openai | api.openai.com | also the only provider for warmup content batch generation |
openrouter | openrouter.ai | one key fronts every vendor; switch via AI_MODEL |
groq | api.groq.com | fast, free tier |
ollama | localhost:11434 | no key, free local model (AI_FREE auto-true) |
anthropic | api.anthropic.com | Anthropic connector |
custom | your AI_BASE_URL | vLLM, LocalAI, LM Studio |
AI_MODEL_TRIAL / AI_MODEL_PAID optionally split models by plan. Web search for the assistant: SEARCH_PROVIDER=serper (+SEARCH_API_KEY) or =searxng (+SEARCH_API_URL).
The full env reference is deploy/config/env.example.
Workers
Outbound mail leaves through each mailbox's own provider, so the source IP is the provider's, not the worker's. Workers are interchangeable executors with no database: commands in from the event bus, encrypted keys over the backend's internal API, a heartbeat back every 90 seconds.
More workers on the same host:
docker compose -p warmbly up -d --scale worker=3Remote workers
A remote worker VPS must be able to reach the backend URL, NATS (or Kafka), and Redis.
Token enrollment (one command on the VPS): creating a worker via the admin API with generate_enrollment_token returns a one-time wmenroll_... token, then:
curl -fsSL https://api.example.com/worker-install.sh | sudo bash -s -- \
--enroll wmenroll_... --api-base https://api.example.comOr SSH-managed from the admin panel: Workers, Add Worker (host, port, user), paste the generated SSH public key into the VPS's ~/.ssh/authorized_keys, click Test connection (first success pins the host fingerprint), then Install.
Either way the installer installs Docker if missing, derives the worker's UUID from the machine's public IPv4 (same IP, same identity and reputation), writes /etc/warmbly/worker.env, creates the warmbly-worker.service systemd unit, and adds a daily self-update timer. --help lists all flags (--ips for multi-IP machines, --update, --uninstall, --purge).
Set WORKER_IMAGE on the backend explicitly (e.g. ghcr.io/<owner>/warmbly/worker:prod); its built-in default does not match what CI publishes.
Profiles and day-2 operations
A worker profile (admin panel) bundles the shared config once: event bus settings, Redis URL, backend URL and token, worker image, release channel (pinned, stable, dev), auto-update flag. Secrets are encrypted at rest. When a profile changes, assigned workers are flagged stale and one click rewrites their env over SSH and restarts them.
From each worker's detail page: Test connection, Install (idempotent), Restart, Pull latest and restart, Apply config and restart (env only, no image pull), Live status, Logs, Update OS packages, Reboot, Rotate SSH keys, Uninstall, Delete.
Images and releases
CI publishes multi-arch images to ghcr.io/<owner>/warmbly/ (works on any fork with packages: write):
| Trigger | Images | Tags |
|---|---|---|
Push to main | backend, consumer, worker, tracking, realtime | :<sha>, :dev |
Tag vX.Y.Z | all of the above + web, admin | :vX.Y.Z, :vX.Y, :vX, :prod |
Worker auto-update
RELEASES_ENABLED=true
RELEASES_GITHUB_REPO=<owner>/warmbly
RELEASES_WORKER_IMAGE_REPO=ghcr.io/<owner>/warmbly/worker
RELEASES_WEBHOOK_SECRET=<shared with GitHub>Add a GitHub webhook: payload URL https://api.example.com/webhooks/github/releases, JSON, the same secret, Releases events only. Publishing a release resolves the new tag for stable/dev profiles and rolls workers with auto-update on (the dashboard shows a v1.2.3 -> v1.2.4 diff either way).
Roll back by switching a profile to pinned with an older image tag and clicking Apply. Control-plane migrations are forward-only, so prefer rolling forward.
Upgrading and backups
git pull && docker compose -p warmbly up -d --build # migrations apply on backend bootBack up three things:
- Postgres:
docker compose -p warmbly exec -T postgres pg_dump -U warmbly warmbly_dev > backup.sql - The
blobsvolume (uploads and stored message bodies on the filesystem provider) - Your
.env, above all the two encryption keys; a database backup without them cannot decrypt anything
Beyond compose
The compose file is a template, not a requirement. The Dockerfiles in deploy/docker/ plus tracking/, web/, and admin/ are the deployment units; any container host works with the env reference above. The frontends are configured at container start via WARMBLY_* vars, so the same images run anywhere.
Troubleshooting
| Symptom | Check |
|---|---|
| Backend exits at boot | First log lines; usual causes: cloud provider defaults outside compose, malformed CREDENTIALS_ENCRYPTION_KEY (needs 64 hex), APP_ENV=prod without SENTRY_DSN or the GeoIP file |
| Workers/tracking get 401s | INTERNAL_API_TOKEN must match on backend, workers (ENCRYPTED_KEYS_WORKER_TOKEN), and tracking; unset fails closed |
Worker install_state: error | Test connection first (SSH key in authorized_keys?), then last_error and Logs on the detail page |
| Heartbeat offline | Can the VPS reach the backend URL, NATS/Kafka, and Redis? Is the container running (Live status)? |
| Mailbox stalls after ~1h | Worker missing BOX_GOOGLE_* / BOX_OUTLOOK_*; set in the profile and restart |
| Auto-update didn't fire | GitHub webhook Recent Deliveries: 401 = wrong secret; 200 with no effect = check the Releases panel and backend logs |
| Worker stuck on old image | "Apply config" only rewrites env; use "Pull latest and restart" |
| No realtime updates | AUTH_SECRET must equal realtime's JWT_SECRET; PUBSUB_ENABLED must agree on backend, consumer, realtime |
Quick reference
make up # the whole platform in Docker
make gen-key # print a fresh KMS_LOCAL_MASTER_KEY
make grant-admin [email protected] # bootstrap the first platform admin
make logs # follow logs (make logs backend for one service)
make status # docker compose ps
make stop # stop everything, keep data
make reset # tear down including volumes (destroys data)
make dev # local development stack instead (native Go + hot reload)
make sandbox # live end-to-end demo environmentSee also: local development, architecture, event system.