Files
warmbly/deploy
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
..
2026-01-29 08:50:38 +01:00

Warmbly Deployment

Two distinct planes, deployed differently.

Plane Services How
Control backend, consumer, tracking, realtime, web Container hosting in one region (Railway in production). Stable region-pinning so KMS/DynamoDB/S3 calls stay local.
Execution worker One process per VPS, anywhere with a public IPv4. Managed from the admin dashboard over SSH.

Directory layout

deploy/
├── docker/
│   ├── backend.Dockerfile
│   ├── consumer.Dockerfile
│   ├── worker.Dockerfile
│   └── realtime.Dockerfile
└── config/
    └── env.example

The tracking Dockerfile lives at tracking/Dockerfile. The local-dev compose is docker-compose.yml at the repo root.

Building images

docker build -f deploy/docker/backend.Dockerfile  -t warmbly/backend  .
docker build -f deploy/docker/consumer.Dockerfile -t warmbly/consumer .
docker build -f deploy/docker/worker.Dockerfile   -t warmbly/worker   .
docker build -f deploy/docker/realtime.Dockerfile -t warmbly/realtime .
docker build -f tracking/Dockerfile               -t warmbly/tracking tracking/

GitHub Actions publishes these to GHCR automatically. See ../resources/cicd.md.

Local development

make dev    # infra + app + one worker
make sim    # adds premium + dedicated workers
make seed   # rich fixture
make tools  # kafka-ui at :18090
make reset  # nuke volumes

Full reference: ../resources/local-development.md.

Deploying the control plane

The Dockerfiles in deploy/docker/ are the deployment unit. Production runs on Railway. Other valid targets: Fly.io, ECS Fargate, single-VPS systemd. Migrations run automatically on backend boot.

Configuration is env-driven — see deploy/config/env.example for the full env reference, or ../resources/deployment-guide.md for a step-by-step.

Worker deployment

Workers run on per-VPS machines so cold-mail traffic spreads across many IPs. Worker identity is a deterministic UUIDv5 derived from the VPS's public IPv4 — same IP, same worker.

Add a worker from the admin dashboard:

  1. Provision a VPS, note its public IP + root user
  2. Admin → Workers → Add Worker
  3. Paste the generated SSH public key into the VPS's ~/.ssh/authorized_keys
  4. Click Test, then Install

The backend SSHes in, uploads scripts/install-worker.sh, configures systemd, and starts the worker container. From then on, all lifecycle operations (restart, update, system updates, reboot, rotate keys, logs, uninstall) happen from the dashboard.

Manual install on the VPS is also supported:

sudo bash scripts/install-worker.sh \
  --kafka kafka.example.com:9092 \
  --schema-registry https://schema.example.com \
  --redis redis://cache.example.com:6379 \
  --aws-region us-east-1 --aws-key ... --aws-secret ...

Why per-VPS instead of Kubernetes DaemonSet

Cold-mail reputation lives at the IP level. K8s nodes typically NAT pods through a small set of egress IPs, so a per-node DaemonSet does not deliver IP diversity. Workers don't depend on Postgres, so cluster-level service discovery isn't needed. Spreading across VPS providers and regions is the only thing that actually moves the deliverability needle.

Worker env reference

Workers in production should be assigned to a worker profile in the dashboard. The profile bundles all of these:

Env var Source Notes
APP_ENV profile prod selects alias/master-key; otherwise alias/master-key-dev
AWS_REGION profile (via AWS credentials row)
AWS_ACCESS_KEY_ID profile (via AWS credentials row)
AWS_SECRET_ACCESS_KEY profile (via AWS credentials row) encrypted at rest
KAFKA_BOOTSTRAP_SERVERS profile
KAFKA_SASL_USERNAME profile
KAFKA_SASL_PASSWORD profile encrypted at rest
SCHEMA_REGISTRY_URL profile
SCHEMA_REGISTRY_KEY profile
SCHEMA_REGISTRY_SECRET profile encrypted at rest
REDIS profile full URL with embedded password; encrypted at rest
WORKER_TIER (worker row) shared or dedicated

The worker does not open a Postgres connection. Do not add one.

Auto-update

Each worker profile picks a release channel (pinned / stable / dev) and an auto_update toggle. When a GitHub release fires the webhook, the backend resolves the channel and (if auto_update=true) rolls every assigned worker. See ../resources/cicd.md.

Health checks

curl http://localhost:8080/health    # backend
curl http://localhost:3000/health   # tracking
curl http://localhost:4000/health   # realtime

Documentation