mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-18 16:01:18 +00:00
ef9ce6a6e2
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.
231 lines
9.6 KiB
Markdown
231 lines
9.6 KiB
Markdown
# Deployment Guide
|
|
|
|
End-to-end guide for deploying Warmbly. The control plane runs on a container host (Railway in production); workers run on as many VPSes as you want and are managed from the admin dashboard.
|
|
|
|
## Overview
|
|
|
|
| Plane | What runs | Where |
|
|
|-------|-----------|-------|
|
|
| Control | backend, consumer, tracking, realtime, web | One container host with stable region pinning (Railway, ECS Fargate, Fly, Kubernetes — any of them works). Postgres + Redis + Kafka + Schema Registry + S3 + KMS + DynamoDB are external dependencies. |
|
|
| Execution | worker | One process per VPS, anywhere in the world, supervised by systemd, configured from the admin dashboard over SSH. |
|
|
|
|
The control plane never needs to scale beyond a few replicas; the worker fleet is what fans out for IP diversity.
|
|
|
|
## What you need
|
|
|
|
**For the control plane**:
|
|
|
|
- A Postgres 16 instance (RDS, Neon, Supabase, self-hosted — anything)
|
|
- A Redis 7 instance
|
|
- A Kafka broker with a Schema Registry (Confluent Cloud, Aiven, Redpanda, self-hosted)
|
|
- An S3-compatible bucket
|
|
- AWS KMS for envelope-encryption root, or a KMS-compatible service via LocalStack for local dev
|
|
- DynamoDB (or compatible) for per-user encrypted DEKs
|
|
- A Stripe account (or stripe-mock for non-prod)
|
|
|
|
**For the workers**:
|
|
|
|
- A VPS per worker (Hetzner / OVH / Vultr / DigitalOcean / Linode — anywhere with a public IPv4). Spread providers for IP diversity.
|
|
- Root SSH access on the VPS at install time.
|
|
|
|
## Step 1: Provision the control-plane dependencies
|
|
|
|
Whatever your stack, get the seven external services above running and collect the connection details. Local dev can substitute everything with `make sim` from the repo root (see [local-development.md](local-development.md)) — that runs LocalStack for KMS/Dynamo/S3 and includes everything else as containers.
|
|
|
|
## Step 2: Configure the backend
|
|
|
|
The backend reads from environment variables. Minimum set:
|
|
|
|
```
|
|
APP_ENV=prod
|
|
AWS_REGION=us-east-1
|
|
AWS_ACCESS_KEY_ID=...
|
|
AWS_SECRET_ACCESS_KEY=...
|
|
|
|
PRIMARY_DB=postgres://user:pass@host:5432/warmbly?sslmode=require
|
|
REDIS=redis://default:pass@host:6379
|
|
|
|
KAFKA_BOOTSTRAP_SERVERS=broker1:9092,broker2:9092
|
|
KAFKA_SASL_USERNAME=...
|
|
KAFKA_SASL_PASSWORD=...
|
|
SCHEMA_REGISTRY_URL=https://schema.example.com
|
|
SCHEMA_REGISTRY_KEY=...
|
|
SCHEMA_REGISTRY_SECRET=...
|
|
|
|
AUTH_SECRET=<32+ char random>
|
|
STRIPE_SECRET_KEY=sk_live_...
|
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
|
TURNSTILE_SECRET=...
|
|
GOOGLE_CLIENT_ID=...
|
|
GOOGLE_CLIENT_SECRET=...
|
|
GOOGLE_REDIRECT_URI=https://api.example.com/auth/google/callback
|
|
```
|
|
|
|
Worker-related env (for the dashboard's worker orchestration):
|
|
|
|
```
|
|
WORKER_IMAGE=ghcr.io/youruser/yourfork/worker:prod
|
|
WORKER_INSTALLER_PATH=/app/scripts/install-worker.sh
|
|
WORKER_AWS_ACCESS_KEY_ID=...
|
|
WORKER_AWS_SECRET_ACCESS_KEY=...
|
|
```
|
|
|
|
Release auto-update (optional; safe to omit on a fresh install):
|
|
|
|
```
|
|
RELEASES_ENABLED=true
|
|
RELEASES_GITHUB_REPO=warmbly/warmbly # or your fork
|
|
RELEASES_WORKER_IMAGE_REPO=ghcr.io/warmbly/warmbly/worker
|
|
RELEASES_WEBHOOK_SECRET=<shared with GitHub>
|
|
RELEASES_GITHUB_TOKEN=<optional PAT for higher API limits>
|
|
```
|
|
|
|
A full env reference is `deploy/config/env.example`.
|
|
|
|
## Step 3: Build and push images
|
|
|
|
Tagging a release on GitHub triggers `release.yml`, which builds and pushes:
|
|
|
|
```
|
|
ghcr.io/<owner>/warmbly/worker:vX.Y.Z
|
|
ghcr.io/<owner>/warmbly/worker:vX.Y
|
|
ghcr.io/<owner>/warmbly/worker:vX
|
|
ghcr.io/<owner>/warmbly/worker:prod
|
|
```
|
|
|
|
And the same set of tags for `backend`, `consumer`, `tracking`, `realtime`. The `build-push.yml` workflow on `main` publishes `:{sha}` and `:dev`.
|
|
|
|
For self-hosters: this works out of the box on any fork as long as you have `packages: write` permission enabled in repo settings.
|
|
|
|
## Step 4: Deploy the control plane
|
|
|
|
Pick one. The Dockerfiles in `deploy/docker/` are the deployment unit:
|
|
|
|
- **Railway** (what we use in production): one project per environment, one service per Dockerfile, env vars set in the Railway UI. Pushes to `main` auto-deploy.
|
|
- **ECS Fargate**: a task definition per service, ALB in front, secrets via AWS Secrets Manager. ~200 lines of Terraform.
|
|
- **Fly.io**: `fly launch` per service; nice if you want Elixir distributed clustering for realtime.
|
|
- **Single VPS**: each service as a systemd unit fronted by Caddy. Works for small deployments.
|
|
|
|
The control plane needs database migration to be run before the backend starts. Backend runs migrations automatically on boot (`internal/infrastructure/db/migrate.go`), so no separate migration step is required.
|
|
|
|
## Step 5: Set up the GitHub Release webhook
|
|
|
|
If `RELEASES_ENABLED=true`:
|
|
|
|
1. In your GitHub repo → Settings → Webhooks → Add webhook
|
|
2. Payload URL: `https://api.example.com/webhooks/github/releases`
|
|
3. Content type: `application/json`
|
|
4. Secret: same value as `RELEASES_WEBHOOK_SECRET` on the backend
|
|
5. Events: select **Releases** only
|
|
6. Save
|
|
|
|
From now on, publishing a GitHub release fires the webhook and the backend resolves each profile's channel.
|
|
|
|
## Step 6: Add a worker
|
|
|
|
Two ways.
|
|
|
|
### From the dashboard (recommended)
|
|
|
|
1. Provision a VPS (Hetzner et al). Note its public IPv4 and root user.
|
|
2. In Warmbly admin → Workers → **Add Worker**. Fill in host, port, user, name. Pick a worker profile (or leave blank to use backend env defaults). Click Create.
|
|
3. Copy the generated SSH public key. Paste it into the VPS's `~/.ssh/authorized_keys`:
|
|
```bash
|
|
ssh root@<vps> 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys'
|
|
# paste the key, then Ctrl-D
|
|
```
|
|
4. In the dashboard → worker detail page → **Test connection**. Should succeed and pin the host fingerprint.
|
|
5. Click **Install**. Backend uploads `scripts/install-worker.sh`, runs it, and the worker is live.
|
|
|
|
### From the VPS itself
|
|
|
|
```bash
|
|
curl -fsSL https://get.example.com/worker | sudo bash -s -- \
|
|
--kafka kafka.example.com:9092 \
|
|
--schema-registry https://schema.example.com \
|
|
--redis redis://cache.example.com:6379 \
|
|
--aws-region us-east-1 --aws-key AKIA... --aws-secret ...
|
|
```
|
|
|
|
You can later "adopt" this worker into the dashboard by adding the SSH key to it and pointing a profile at it.
|
|
|
|
## Step 7: Wire up the worker profile
|
|
|
|
In the admin dashboard → Credentials → AWS Credentials → Add. Name it, region, access key, secret. The secret is encrypted via KMS before storage.
|
|
|
|
Then → Worker Profiles → Add. Pick the AWS Credentials row. Fill in Kafka bootstrap, SASL username/password, Schema Registry URL/key/secret, Redis URL. Pick a release channel (`pinned`, `stable`, `dev`) and whether `auto_update` is on.
|
|
|
|
Assign workers to this profile on each worker's detail page. Click Apply to re-write `worker.env` on the VPS and restart.
|
|
|
|
## Day-2 operations
|
|
|
|
Everything happens from the worker detail page:
|
|
|
|
| Action | Effect |
|
|
|--------|--------|
|
|
| Test connection | Open an SSH session, run `true` |
|
|
| Install | Upload installer + env, run it. Idempotent. |
|
|
| Restart | `systemctl restart warmbly-worker.service` |
|
|
| Pull latest & restart | Pull the new image, regenerate unit, restart |
|
|
| Apply config & restart | Rewrite `/etc/warmbly/worker.env`, restart (no docker pull) |
|
|
| Update OS packages | apt/dnf/pacman/apk upgrade noninteractively |
|
|
| Reboot VPS | `shutdown -r +1` |
|
|
| Rotate SSH keys | Generate new ed25519 keypair, install on target |
|
|
| Uninstall | Stop + remove systemd unit and container |
|
|
| Delete | Uninstall + remove row from `workers` table |
|
|
|
|
When `auto_update=true` is set on a profile and a new release fires the webhook, every assigned worker gets the new image automatically. Otherwise the dashboard shows an "update available" hint and waits for you to click Apply.
|
|
|
|
## Releasing a new version
|
|
|
|
```bash
|
|
git tag -a v1.2.4 -m "Release v1.2.4"
|
|
git push origin v1.2.4
|
|
```
|
|
|
|
`release.yml` builds and pushes images. If a release webhook is configured, the backend picks up the new version, updates any profile on `stable` or `dev` channels, and rolls profiles with `auto_update=true`. Otherwise the dashboard shows the new version and you Apply manually.
|
|
|
|
## Rolling back
|
|
|
|
- **Per-profile**: edit the profile, switch channel to `pinned`, set `worker_image` to a specific older tag (e.g. `ghcr.io/.../worker:v1.2.3`), save, then Apply to all workers on that profile.
|
|
- **Per-worker**: same idea, but assign the worker to a different profile (or detach and run the installer manually with the old image).
|
|
|
|
## Troubleshooting
|
|
|
|
**Worker shows `install_state: error`**
|
|
Open the worker detail page, click Test. If that fails, the SSH key isn't in `authorized_keys` on the VPS. If Test passes but Install fails, check the `last_error` shown on the detail page and the output captured under Logs.
|
|
|
|
**Auto-update didn't fire**
|
|
Check GitHub repo → Settings → Webhooks → Recent Deliveries. If the webhook delivery shows 401, the secret is wrong. If it shows 200 but nothing happened, check `Releases panel → Last error` in the dashboard, or `journalctl -u backend` for the boot check.
|
|
|
|
**Worker stuck on old image**
|
|
"Apply config & restart" only rewrites env, not image. Use "Pull latest & restart" instead, or the release auto-update path.
|
|
|
|
**Heartbeat shows offline**
|
|
- Worker can reach Kafka? (firewall, SASL creds)
|
|
- Worker container actually running? (worker detail → Live status)
|
|
- VPS's clock skewed? (Kafka SASL auth is time-sensitive)
|
|
|
|
## Quick reference
|
|
|
|
```bash
|
|
# control plane: locally
|
|
make infra # postgres / redis / kafka / etc. (leave running)
|
|
make app # backend / consumer / worker / tracking / realtime / web
|
|
|
|
# load fixtures
|
|
make seed
|
|
|
|
# full simulation with extra workers + localstack + stripe-mock
|
|
make sim
|
|
|
|
# tear down everything
|
|
make reset
|
|
|
|
# manual worker install on a VPS
|
|
curl -fsSL https://your-host/install-worker.sh | sudo bash -s -- --help
|
|
|
|
# trigger a release manually (build+push happens via GH Actions)
|
|
git tag -a v1.2.4 -m "Release v1.2.4" && git push origin v1.2.4
|
|
```
|