Files
warmbly/resources/local-development.md
T
Matthew Meszaros e5c0c8a448 chore(dev): standardize ports where they don't conflict
Most container ports go back to their natural defaults — the offsets
that existed weren't justified, they just made URLs harder to remember.
Now standard:

  backend         8080   (was always 8080)
  tracking        3000   (was 13000)
  realtime        4000   (was 14000)
  web             5173   (was 15173 — already changed)
  kafka           9092   (was 19092)
  schema-registry 8081   (was 18081)
  localstack      4566   (was 14566)
  cloud-tasks     8123   (was 18123)
  stripe-mock     12111  (always was)

Kept offset (the defaults conflict too often on real dev machines):

  postgres        15432  (system postgres / sibling project)
  redis           16379  (sibling docker projects with redis)
  mailpit ui      18025  (sibling docker projects with mailpit)
  mailpit smtp    11025  (same)
  kafka-ui        18090  (8080 already used by backend)

Touched: docker-compose.yml, Makefile (test-seed SEED_TEST_DB), READMEs
(root + deploy), local-development.md + deployment-guide.md. Internal
docker-network refs (kafka:29092, mailpit:1025, etc.) unchanged — only
host-port mappings moved. Compose validated, all default-profile
services come up healthy on the new ports.
2026-05-22 14:51:16 +00:00

8.8 KiB

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:

make dev        # infra + app + one worker (the everyday default)
make sim        # adds premium + dedicated workers; full simulation
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

Iterating on code:

# Web has Vite HMR — save a file in web/src/, browser updates instantly.
# No need to restart anything for web changes.

# Everything else needs a rebuild because the binary is compiled into
# the image. One target, takes the service name positionally:
make restart backend
make restart-go                    # all Go services in one shot
make restart-all                   # Go + Rust + Elixir

# Tail logs:
make logs                          # everything
make logs backend                  # one service
make logs backend consumer         # multiple

There's no "restart without rebuild" target because in this setup that never does what you want — the container would come back with the same old binary. If you ever genuinely need that (env var change only, etc.), use docker compose restart <service> directly.

All targets shell out to docker compose. If you don't have Make, the equivalents are:

docker compose up                                            # dev
docker compose --profile sim up                              # sim
docker compose --profile seed run --rm seed                  # seed
docker compose --profile tools up -d kafka-ui                # tools
docker compose --profile sim --profile seed --profile tools down -v   # reset

What's running

In default profile (make dev):

  • 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

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)

make dev  # in another terminal, leave running for infra

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

docker compose stop web
cd web
pnpm install
pnpm dev

Tracking (Rust)

docker compose stop tracking
cd tracking
cargo run

Realtime (Elixir)

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.

Note: Mailpit speaks SMTP only, not IMAP. The worker's IMAP sync path is not exercised in make dev — for that, add a real IMAP server (e.g. GreenMail) to the stack.

Email templates

Email templates live in internal/notify/templates/. Render tests:

go test ./internal/notify/templates/ -v

To 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 output

Or just trigger the auth flow in the running app and watch the email arrive in Mailpit.

Common tasks

Rebuild one service

make restart backend

Reset Postgres only

docker compose stop postgres
docker volume rm warmbly_postgres_data
docker compose up -d postgres

List Kafka topics

docker compose exec kafka kafka-topics --bootstrap-server localhost:29092 --list

Consume a topic

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

curl http://localhost:8081/subjects | jq
curl http://localhost:8081/subjects/tracking-events-value/versions/latest | jq

Troubleshooting

Port already in uselsof -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 dev works.

Next steps