Files
warmbly/resources/local-development.md
T
Matthew Meszaros ef9ce6a6e2 refactor: split make dev into make infra and make app
Every docker compose invocation in the Makefile now pins
-p warmbly so all git worktrees target the same compose
project. This means infra (postgres, redis, kafka, mailpit,
localstack, stripe-mock, cloud-tasks-emulator, zookeeper,
schema-registry) is brought up once and stays running across
worktree switches. App services (backend, consumer, worker,
tracking, realtime, web) recreate in place per worktree against
the bind-mounted source.

Removed targets:
- dev, dev-down, dev-logs (and the DEV_SVCS / SVCS vars)

Added targets:
- infra, infra-down
- app, app-down, app-logs

Daily flow becomes:

    make infra                     # once, from any worktree
    cd /path/to/worktree-a
    make app                       # bring up app code for branch A
    cd /path/to/worktree-b
    make app                       # recreates app against branch B;
                                   # infra untouched, caches warm

The named cache volumes already shared their content across
worktrees (warmbly_gomodcache, warmbly_gocache, warmbly_cargo_home,
warmbly_cargo_target, warmbly_mix_deps, warmbly_mix_build); pinning
the project name additionally makes container ownership shared,
which is what eliminates the per-worktree cold start.

README.md, resources/local-development.md, resources/deployment-guide.md,
deploy/README.md, and docker-compose.dev.yml all updated to reflect
the new targets.
2026-05-25 11:43:33 +00:00

284 lines
9.5 KiB
Markdown

# Local Development
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
For native development (running services outside Docker against the containerized infra), also install:
- Go 1.25+
- Rust (for tracking)
- Elixir 1.18+ (for realtime)
- Node 22+ and pnpm (for web)
## Make targets
Day-one:
```bash
make infra # postgres, redis, kafka, mailpit, localstack, etc. (leave running)
make app # backend, consumer, worker, tracking, realtime, web (hot reload)
make sim # adds premium + dedicated workers; full simulation (prod-image flow)
make seed # load rich fixtures (3 orgs, 6 mailboxes, a campaign)
make tools # debugging UIs (kafka-ui at :18090)
make reset # nuke everything including volumes — start over
```
The 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:
```bash
# 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 # multiple
```
If you're on the prod-image flow (`make up` / `make sim`) 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:
```bash
docker compose -p warmbly -f docker-compose.yml -f docker-compose.dev.yml up -d \
postgres redis zookeeper kafka schema-registry mailpit \
localstack stripe-mock cloud-tasks-emulator # infra
docker compose -p warmbly -f docker-compose.yml -f docker-compose.dev.yml up -d --build \
backend consumer worker-shared-1 tracking realtime web # app
docker compose -p warmbly --profile sim up # sim
docker compose -p warmbly --profile seed run --rm seed # seed
docker compose -p warmbly --profile tools up -d kafka-ui # tools
docker compose -p warmbly --profile sim --profile seed --profile tools down -v # reset
```
## What's running
After `make infra && make app`:
- **postgres**, **redis**, **zookeeper**, **kafka**, **schema-registry** — infra
- **localstack** — KMS + DynamoDB + S3 emulation
- **stripe-mock** — Stripe API surrogate
- **mailpit** — SMTP catcher with a web UI
- **cloud-tasks-emulator** — Google Cloud Tasks surrogate
- **backend**, **consumer**, **tracking**, **realtime**, **web** — app services
- **worker-shared-1** — one worker bound to the shared profile
The `sim` profile adds two more workers (`worker-premium-1`, `worker-dedicated-1`) so you can exercise tier-based assignment, worker rebalancing, and per-pool routing.
## Service URLs
Mostly standard ports. A few are offset because their defaults conflict too often: Postgres `15432`, Redis `16379`, Mailpit UI `18025` and SMTP `11025`, kafka-ui `18090` (because 8080 is backend). 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 |
| Mailpit | http://localhost:18025 |
| Kafka | localhost:9092 |
| Schema Registry | http://localhost:8081 |
| Postgres | localhost:15432 |
| Redis | localhost:16379 |
| LocalStack | http://localhost:4566 |
| stripe-mock | http://localhost:12111 |
| Cloud Tasks emulator | http://localhost:8123 |
| kafka-ui (with `make tools`) | http://localhost:18090 |
## 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 | `dev@warmbly.com` |
| Password | `password123` |
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.yml` hostnames (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)
## LocalStack
The `localstack` service provides KMS, DynamoDB, and S3 locally. A one-shot init container (`localstack-init`) creates everything Warmbly expects:
- KMS alias `alias/master-key-dev` for envelope encryption
- DynamoDB tables `UserEncryptedKeys` and `EmailMessageData`
- S3 bucket `main`
Backend, consumer, and workers point at LocalStack via `AWS_ENDPOINT_URL=http://localstack:4566`. Production deployments leave that var unset and hit real AWS.
## Connecting psql / Redis CLI
```bash
docker compose exec postgres psql -U warmbly -d warmbly_dev
docker compose exec redis redis-cli
```
External clients can use:
- Postgres: `localhost:15432` user `warmbly` password `warmbly` db `warmbly_dev`
- Redis: `localhost:16379`
## Running services natively
If you want hot reload, run a service natively and point it at the docker infra.
### Backend (Go)
```bash
make infra && make app # in another terminal, leave running
# Install air for hot reload
go install github.com/cosmtrek/air@latest
# Point at containerized infra
export PRIMARY_DB="postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable"
export REDIS="redis://localhost:16379"
export KAFKA_BOOTSTRAP_SERVERS="localhost:9092"
export SCHEMA_REGISTRY_URL="http://localhost:8081"
export AWS_ENDPOINT_URL="http://localhost:4566"
export AWS_REGION="us-east-1"
export AWS_ACCESS_KEY_ID="test"
export AWS_SECRET_ACCESS_KEY="test"
# ... rest of env, see deploy/config/env.example
air -c .air.toml
```
### Web (React)
The web service in compose already mounts `./web` and runs `pnpm dev`. To run it locally instead:
```bash
docker compose stop web
cd web
pnpm install
pnpm dev
```
### Tracking (Rust)
```bash
docker compose stop tracking
cd tracking
cargo run
```
### Realtime (Elixir)
```bash
docker compose stop realtime
cd realtime
mix deps.get && mix phx.server
```
## 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.
- 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 not exercised by the default stack; for that, add a real IMAP server (e.g. GreenMail) to the stack.
## Email templates
Email templates live in `internal/notify/templates/`. Render tests:
```bash
go test ./internal/notify/templates/ -v
```
To preview templates in a browser, dump them to disk:
```bash
go test ./internal/notify/templates/ -run TestPreview -v
# Files land in the test temp dir, path printed in output
```
Or just trigger the auth flow in the running app and watch the email arrive in Mailpit.
## Common tasks
### Rebuild one service
```bash
make restart backend
```
### Reset Postgres only
```bash
docker compose stop postgres
docker volume rm warmbly_postgres_data
docker compose up -d postgres
```
### List Kafka topics
```bash
docker compose exec kafka kafka-topics --bootstrap-server localhost:29092 --list
```
### Consume a topic
```bash
docker compose exec kafka kafka-console-consumer \
--bootstrap-server localhost:29092 \
--topic tracking-events --from-beginning
```
Or use kafka-ui at http://localhost:18090 (`make tools`).
### Inspect schema registry
```bash
curl http://localhost:8081/subjects | jq
curl http://localhost:8081/subjects/tracking-events-value/versions/latest | jq
```
## Troubleshooting
**Port already in 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 Kafka** — Kafka needs ~30s to fully start. Healthchecks gate the dependent services, so `docker compose up` should handle this. If you brought services up in a weird order, `docker compose restart backend consumer`.
**Schema registry "incompatible schema"** — happens if you tweaked an Avro schema and the registry already has an older version. In dev: `make reset` to nuke volumes.
**LocalStack init failed:** check `docker compose logs localstack-init`. Usually means LocalStack itself isn't ready yet; the dependency wait should handle it, but re-running `make infra` works.
## Next steps
- [Architecture](architecture.md) — control vs execution plane, encryption model
- [Deployment Guide](deployment-guide.md) — running in production
- [Events](Events.md) — Kafka event reference