---
title: Self-hosting
description: 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.

<Mermaid
  chart={`
flowchart LR
  U["Browser"] --> WEB["web :5173 / admin :5174"]
  WEB --> API
  subgraph CP["Control plane (docker compose)"]
    API["backend :8080"] --> PG[("Postgres")]
    API --> BUS{{"NATS JetStream"}}
    BUS --> CONS["consumer"] --> PG
    TRK["tracking :3000"] --> BUS
    RT["realtime :4000"] --- RD[("Redis")]
  end
  BUS --> W1["worker"]
  BUS --> W2["worker (VPS)"]
  W1 --> P["Gmail · Microsoft · SMTP"]
  W2 --> P
  P --> R["Recipients"]
`}
/>

## Quick start

You need Docker 20.10+ with Compose v2 and Git. 2 cores / 4 GB RAM is comfortable for a small install.

```bash
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`:

   ```bash
   make grant-admin EMAIL=you@example.com      # ROLE=super|support|ops|analyst
   ```

3. Optional demo data (`dev@warmbly.com` / `password123`, mailboxes, campaigns, history):

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

![The dashboard after your first login](/dashboard-campaigns.png)

For a demo where mail actually flows end to end (sends, replies, opens, clicks), see the [sandbox](/development/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`:

```bash
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

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

```bash
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):

```bash
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:

```bash
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:

```bash
SMTP_HOST=smtp.example.com        # unset falls back to AWS SES (requires AWS credentials)
SMTP_PORT=587
EMAIL_NAME=Warmbly
EMAIL_ADDRESS=noreply@example.com
```

## 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, 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

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

```bash
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`](https://github.com/warmbly/warmbly/blob/main/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:

```bash
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.

<Mermaid
  chart={`
sequenceDiagram
  participant A as Admin panel
  participant B as Backend
  participant V as VPS
  A->>B: Create worker (one-time enrollment token)
  V->>B: GET /worker-install.sh
  V->>B: POST /api/v1/workers/enroll (token)
  B-->>V: full worker config (worker.env)
  V->>V: systemd unit + docker run
  V-->>B: heartbeat every 90s
`}
/>

**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:

```bash
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`):

| 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

```bash
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

```bash
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

| 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

```bash
make up          # the whole platform in Docker
make gen-key     # print a fresh KMS_LOCAL_MASTER_KEY
make grant-admin EMAIL=you@example.com   # 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](/development/local-development/), [architecture](/development/architecture/), [event system](/development/events/).
