WarmblyDocs

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 --build

Then:

  1. Open http://localhost:5173 and register (captcha is off by default).

  2. 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
  3. Optional demo data ([email protected] / password123, mailboxes, campaigns, history):

    docker compose -p warmbly --profile seed run --rm seed

The dashboard after your first login

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 credentials

Back up KMS_LOCAL_MASTER_KEY and CREDENTIALS_ENCRYPTION_KEY. They seal every stored mailbox credential; losing them is unrecoverable.

What's running

ServicePortPurpose
web / admin5173 / 5174Dashboard and admin panel (nginx static builds, URLs injected at container start)
backend8080REST API; applies migrations on every boot; GET /health
tracking3000Open pixels and click redirects; GET /health
realtime4000WebSocket fanout; GET /health
consumer / workernoneEvent processor; send/sync executor
postgres / redis15432 / 16379PostgreSQL 16; cache and realtime bridge
nats4222 (mon 8222)Event bus (JetStream)
mailpit18025 (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 domain

Then 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.com

Connect 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

SubsystemCompose defaultOpt-in
Event busNATS JetStream (EVENTBUS_PROVIDER=nats)Kafka (=kafka, needs a kafka-tagged build)
CodecJSON (CODEC_PROVIDER=json)Avro + Schema Registry (=avro, same tag)
Root keyLocal AES (KMS_PROVIDER=local)AWS KMS (=aws)
Blob storageFilesystem (BLOB_PROVIDER=filesystem)S3 / MinIO / R2 (=s3 + BLOB_BUCKET)
Task schedulerIn-process poller (TASKS_PROVIDER=local)GCP Cloud Tasks (=gcloud)
BillingOff (BILLING_PROVIDER=none, everything unlocked)Stripe (=stripe)
CaptchaOff (CAPTCHA_PROVIDER=none)Cloudflare Turnstile (=turnstile)
Realtime transportRedis 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, and env.example set 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_ENABLED must match across backend, consumer, and realtime.

Optional subsystems

FeatureEnable with
Stripe billingBILLING_PROVIDER=stripe, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET
Turnstile captchaCAPTCHA_PROVIDER=turnstile + TURNSTILE_SECRET (backend), WARMBLY_TURNSTILE_KEY (web/admin). Compose pins captcha off, so use a docker-compose.override.yml
Sign in with GoogleGOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REDIRECT_URI
Sign in with AppleAPPLE_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 tuningNOTIFICATION_PUSH_WINDOW (default 5h), NOTIFICATION_EMAIL_DAILY_CAP (default 25, 0 means uncapped)
Error trackingSENTRY_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_PROVIDEREndpointNotes
openaiapi.openai.comalso the only provider for warmup content batch generation
openrouteropenrouter.aione key fronts every vendor; switch via AI_MODEL
groqapi.groq.comfast, free tier
ollamalocalhost:11434no key, free local model (AI_FREE auto-true)
anthropicapi.anthropic.comAnthropic connector
customyour AI_BASE_URLvLLM, 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=3

Remote 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.com

Or 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):

TriggerImagesTags
Push to mainbackend, consumer, worker, tracking, realtime:<sha>, :dev
Tag vX.Y.Zall 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 boot

Back up three things:

  1. Postgres: docker compose -p warmbly exec -T postgres pg_dump -U warmbly warmbly_dev > backup.sql
  2. The blobs volume (uploads and stored message bodies on the filesystem provider)
  3. 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

SymptomCheck
Backend exits at bootFirst 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 401sINTERNAL_API_TOKEN must match on backend, workers (ENCRYPTED_KEYS_WORKER_TOKEN), and tracking; unset fails closed
Worker install_state: errorTest connection first (SSH key in authorized_keys?), then last_error and Logs on the detail page
Heartbeat offlineCan the VPS reach the backend URL, NATS/Kafka, and Redis? Is the container running (Live status)?
Mailbox stalls after ~1hWorker missing BOX_GOOGLE_* / BOX_OUTLOOK_*; set in the profile and restart
Auto-update didn't fireGitHub 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 updatesAUTH_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 environment

See also: local development, architecture, event system.

On this page