Local development
Run the full stack locally with Docker, hot reload, seeding, and native services.
The whole stack runs locally via a single docker-compose.yml at the repo root. Profiles let you opt into heavier setups for simulation testing.
Prerequisites
- Docker (20.10+) and Docker Compose v2
- Git
- Go 1.25+
- Node 22+ and pnpm (for web)
Optional, only for the two auxiliary services when run natively:
- Rust (for tracking)
- Elixir 1.18+ (for realtime)
One command
make devThis is the whole setup for a fresh clone. It brings up the no-cloud Docker infra (postgres, redis, nats, mailpit), waits until everything is actually ready, applies migrations, loads the seed fixtures, installs web dependencies on first run, starts the realtime and tracking services as containers, and then runs the backend, consumer, worker, dashboard, and admin panel together in your terminal.
Open http://localhost:5173 and log in with [email protected] / password123.
Ctrl-C stops the app services; the Docker infra stays up, so the next make dev
skips straight to starting the app. The Go services run natively with go run,
so a code change plus Ctrl-C and re-run recompiles in a second or two. Variants:
make dev SEED=false # skip fixture seeding
make dev AI_PROVIDER=ollama # with the AI assistant on (see the AI provider section)Tracking (:3000) and realtime (:4000) run as containers under make dev, so
pixels, click redirects, and live dashboard updates work without the Rust or
Elixir toolchains. The native make tracking / make realtime targets are for
iterating on those services themselves.
Make targets
Day-one:
make dev # one command: infra + migrations + seed + backend/consumer/worker + realtime + tracking + dashboard + admin
make infra # postgres, redis, nats, mailpit (leave running)
make run # backend + consumer + worker (native, after make infra)
make up # the whole no-cloud stack in Docker (docker compose up --build)
make gen-key # print a KMS_LOCAL_MASTER_KEY for a real deployment
make seed # load rich fixtures (3 orgs, 6 mailboxes, a campaign)
make sandbox # fully working demo environment (see /development/sandbox/)
make reset # nuke everything including volumes, start overThe split lets multiple git worktrees share the stateful stuff. make infra
is a "start once and forget"; every worktree uses the same project name
(-p warmbly), so a second worktree's make app recreates the language
containers in place against its source without touching infra.
Iterating on code:
# Hot reload is on by default under `make app`:
# - Go saves -> air rebuilds the binary in-container (~2-5s)
# - Rust saves -> cargo-watch rebuilds (~2-10s debug build)
# - Elixir saves -> Phoenix reloads modules in-process
# - Web saves -> Vite HMR (browser updates instantly)
#
# So normally you don't restart anything manually.
# Tail logs:
make app-logs # all hot-reload services
make logs # everything including infra
make logs backend # one service
make logs backend consumer # multipleIf you're on the prod-image flow (make up) instead of make app,
binaries are baked into the image and you need make restart <svc> /
make restart-go / make restart-all to pick up code changes.
All targets shell out to docker compose -p warmbly. If you don't have Make, the equivalents are:
docker compose -p warmbly up -d postgres redis nats mailpit # infra
docker compose -p warmbly -f docker-compose.yml -f docker-compose.dev.yml up -d --build \
backend consumer worker tracking realtime web admin # app
docker compose -p warmbly up -d dovecot # sandbox IMAP host
docker compose -p warmbly --profile seed run --rm seed # seed
docker compose -p warmbly --profile seed --profile sandbox down -v # resetWhat's running
The no-cloud stack has no AWS, GCP, Stripe, or Kafka. After make infra && make app:
- postgres, redis: relational store and cache / realtime bridge
- nats: event bus (JetStream), a single small binary in place of Kafka + Zookeeper + Schema Registry
- mailpit: SMTP catcher with a web UI
- dovecot: real IMAP server hosting sandbox mailbox inboxes (
sandboxprofile, started bymake sandbox) - backend, consumer, tracking, realtime, web, admin: app services
- worker: one send/sync worker (scale with
docker compose up --scale worker=N, or add machines via the SSH enrollment flow)
Encryption, blob storage, and delayed tasks all run on local providers (KMS_PROVIDER=local, BLOB_PROVIDER=filesystem, TASKS_PROVIDER=local), so nothing external is needed. See the self-hosting guide for the provider switches.
Service URLs
Mostly standard ports. A few are offset because their defaults conflict too often: Postgres 15432, Redis 16379, NATS monitoring 8222, Mailpit UI 18025 and SMTP 11025. Everything else uses its natural port. Override locally in a docker-compose.override.yml if you still hit a conflict.
| Service | URL |
|---|---|
| Backend API | http://localhost:8080 |
| Tracking | http://localhost:3000 |
| Realtime | http://localhost:4000 |
| Web (Vite dev) | http://localhost:5173 |
| Admin (Vite dev) | http://localhost:5174 |
| Mailpit | http://localhost:18025 |
| Dovecot IMAP / IMAPS | localhost:10143 / localhost:10993 |
| NATS | localhost:4222 (monitoring http://localhost:8222) |
| Postgres | localhost:15432 |
| Redis | localhost:16379 |
Database setup
The backend runs migrations automatically on boot (internal/infrastructure/db/migrate.go), so there's no separate migrate step. Migrations live in internal/infrastructure/db/migrations/.
Seeding fixtures
make seed runs the seeder one-shot. It's idempotent, safe to re-run after schema changes.
Baseline (always loads):
| Field | Value |
|---|---|
[email protected] | |
| Password | password123 |
The dev user's org always loads as a mid-flight workspace, not an empty shell:
- 4 warmed mailboxes on the shared worker, all in the premium warmup pool
- Folders, tags, and categories with real bindings (mailbox tags, campaign folders, contact categories, inbox thread labels)
- ~30 contacts with titles and companies, a few unsubscribed or suppressed
- An active 3-step campaign with 24 leads spread across the funnel (sent, opened, replied, bounced, queued), plus a draft campaign
- 14 days of stats history (campaign sends, warmup volume, per-mailbox counts), always including sends today
- A unified inbox with reply chains, an objection, an out-of-office, a bounce, and a warmup-style exchange
- A CRM pipeline with deals, tasks, and notes, reply templates, notifications, and an AI credit balance with transaction history
All dates are computed relative to the current time, so the workspace looks active no matter when you seed.
When SEED_RICH=true (default in docker-compose.yml), also loads:
- 3 orgs (Acme free, Beta pro, Gamma enterprise) each with their own owner user (password
password123) - 3 workers matching the
docker-compose.ymlhostnames (shared / premium / dedicated) - 6 email accounts spread across workers, joined to the right warmup pools
- A Beta campaign with a 2-step sequence
- 10 contacts, 2 of them unsubscribed (exercises suppression behaviour)
Encryption and storage
The local stack needs no AWS. Envelope encryption uses a local AES master key (KMS_PROVIDER=local, KMS_LOCAL_MASTER_KEY, a fixed dev value in the Makefile), and blobs (avatars, logos) go to a directory (BLOB_PROVIDER=filesystem, BLOB_FS_ROOT). Flip KMS_PROVIDER=aws / BLOB_PROVIDER=s3 to use AWS KMS and any S3-compatible store instead. See the self-hosting guide for every provider switch.
Connecting psql / Redis CLI
docker compose exec postgres psql -U warmbly -d warmbly_dev
docker compose exec redis redis-cliExternal clients can use:
- Postgres:
localhost:15432userwarmblypasswordwarmblydbwarmbly_dev - Redis:
localhost:16379
Running services natively
make dev already runs the Go services and the dashboard natively against the
Docker infra. When you want them in separate terminals instead of one, each has
its own target with the full dev environment pre-wired (no manual env exports):
make infra # once: the backing services in Docker
make backend # API on :8080 (applies migrations on boot)
make consumer # event consumer (NATS -> postgres)
make worker # send/sync worker (run more for parallelism)
make run # backend + consumer + worker in one terminal
make web # dashboard dev server on :5173
make admin # admin app on :5174
make site # marketing site on :4321Each target sets the same env you would otherwise export by hand (see
deploy/config/env.example for the full reference). Saving a Go file and
re-running the target recompiles in a second or two against the warm build
cache.
Tracking (Rust) and realtime (Elixir)
make dev runs both as containers; make run does not include them. The native
targets need their own toolchains (cargo, elixir + mix) and are for working on
those services directly:
make tracking # tracking pixel + click redirect service on :3000
make realtime # Phoenix websocket fanout on :4000AI provider
The AI features (the dashboard assistant, reply drafts, contact research, and automation AI nodes) are off by default in local dev, so the assistant returns a clean 503 until you pick a provider. Pass AI_PROVIDER to the dev targets; the preset fills in the base URL, so you supply just a key and (optionally) a model.
# free local model, no key: install Ollama, pull a tool-capable model, run
ollama pull llama3.1 # or qwen2.5
make backend AI_PROVIDER=ollama
make run AI_PROVIDER=ollama AI_MODEL=qwen2.5
# OpenRouter: one key fronts every vendor; switch models via AI_MODEL
make backend AI_PROVIDER=openrouter AI_KEY=sk-or-... AI_MODEL=deepseek/deepseek-chat
# Groq free tier (fast) or hosted OpenAI
make backend AI_PROVIDER=groq AI_KEY=gsk_... AI_MODEL=openai/gpt-oss-20b
make backend AI_PROVIDER=openai AI_KEY=sk-...AI_PROVIDER=ollama needs no key and marks the model free (AI_FREE), so AI credits are not charged and the assistant shows a "free model" notice. Local and free-tier models are less capable, but tool calls and the full agent loop work; use llama3.1 (8B) or qwen2.5, not a 1–3B model. Leave AI_PROVIDER unset to keep AI off. See the self-hosting guide for the full provider list.
Mailpit
All outbound mail is captured by Mailpit. The backend uses plain SMTP (mailpit:1025) in dev rather than SES, so no AWS credentials are needed. Mailpit accepts SMTP AUTH with any credentials (no TLS required), so worker sends from sandbox mailboxes are captured too.
- Web UI:
http://localhost:18025 - SMTP from inside docker:
mailpit:1025 - SMTP from host:
localhost:11025
Note: Mailpit speaks SMTP only, not IMAP. The worker's IMAP sync path is exercised through the Dovecot service instead; see the sandbox for the full live-mail environment.
Email templates
Email templates live in internal/notify/templates/. Render tests:
go test ./internal/notify/templates/ -vTo preview templates in a browser, dump them to disk:
go test ./internal/notify/templates/ -run TestPreview -v
# Files land in the test temp dir, path printed in outputOr just trigger the auth flow in the running app and watch the email arrive in Mailpit.
Common tasks
Rebuild one service
make restart backendReset Postgres only
docker compose stop postgres
docker volume rm warmbly_postgres_data
docker compose up -d postgresInspect the event bus (NATS)
NATS runs with JetStream and an HTTP monitoring endpoint:
curl http://localhost:8222/jsz | jq # JetStream streams + message counts
curl http://localhost:8222/varz | jq # server info and connectionsTo tail subjects live, use the NATS CLI (nats from nats-io) against nats://localhost:4222, for example nats sub 'warmbly.>'. On the optional Kafka overlay, the event bus is Kafka instead; see the self-hosting guide.
Troubleshooting
Port already in use. Use lsof -i :5432 (or whichever) to find what's holding it. The compose ports are offset on purpose; if you have a local Postgres on 5432 it shouldn't clash.
Backend can't reach the event bus. NATS starts in a second or two and healthchecks gate the dependent services, so docker compose up should handle ordering. If you brought services up out of order, docker compose restart backend consumer.
Scheduled sends never fire. Delayed sends run through the in-process Postgres task poller (TASKS_PROVIDER=local), so the backend must be running. Check the backend log; the task reconcilers cancel and re-enqueue anything missed within a few minutes.
Next steps
- Sandbox: a fully working demo environment on this stack
- Architecture: control vs execution plane, encryption model
- Self-hosting: running in production
- Event system: event bus and topic reference