Deploy nxt-backend on DigitalOcean App Platform
One App Platform app. api and worker always build from this repository with the Node.js buildpack (not Docker — that is the control-panel default, and it is the wrong choice here).
nxt-device-messaging is a third component only when metering is on in this deployment’s JSON config. If that capability is off, skip the GHCR image, skip DEVICE_MESSAGING_*, and do not create a Valkey for this app. api / worker do not talk to device-messaging except from metering code, which is not loaded when the flag is off.
When metering is on, device-messaging is a pre-built GHCR image, not an Nx app and not a buildpack build of this monorepo.
| Component | When | Source | Replicas | Redis / Valkey |
|---|---|---|---|---|
api | always | this repo (buildpack) | as you scale HTTP | none for device queues |
worker | always | this repo (buildpack) | 1 once real jobs exist | none for device queues |
device-messaging | metering on | ghcr.io/nxtgrid/nxt-device-messaging:<tag> | 1 | its own Valkey |
Put them in the same app so they get App Platform private DNS. TypeScript/Zod shapes for the command API and webhook: @nxtgrid/device-messaging-contract. Human contract: that repo’s docs/guides/integrating.md. Prefer a version tag (v0.1.2) over :latest.
Source runbook: docs/deployment/digital-ocean-buildpack.md in nxt-backend. Database: docs/deployment/supabase.md.
Prerequisites
- DigitalOcean App Platform access.
- GitHub access to
nxtgrid/nxt-backend(or your fork). - A Supabase project with migrations applied (
pnpm exec supabase db pushfrom the source repo). Localseed.sqlis not for production. - App-level
SUPABASE_URL,SUPABASE_SECRET_KEY, and the other secrets in the source.env.examplefiles. - JSON config at boot (
NXT_CONFIG_JSON,NXT_CONFIG_PATH, or bundledconfig.default.json). Secrets stay in env, not in that JSON.
1. Create the app (api)
DigitalOcean App Platform → Create App → connect GitHub → repo root, branch you want to run.
Do not accept the defaults without checking:
- Name. The UI often uses
nxt-backend. Pick something likeapior[your-org]-api. - Size. Start small; scale later.
- Build strategy. This is not a Docker deploy. Set Node.js Buildpack.
Build command:
corepack enable && pnpm install --frozen-lockfile && pnpm exec nx sync && pnpm exec nx build api
Run command (no backticks in the DigitalOcean field):
node apps/api/dist/main.js
HTTP port 3000 (PORT). After the first deploy, set the HTTP health check to GET /health (that path also hits Postgres organizations — a 200 means the database answered).
Set NX_DAEMON=false at build time so Nx does not write a daemon log into the buildpack export (that has broken DigitalOcean container registry layers). NODE_ENV=production at app level is fine for both components.
2. Add worker
Same GitHub repo and branch. Node.js Buildpack.
Build:
corepack enable && pnpm install --frozen-lockfile && pnpm exec nx sync && pnpm exec nx build worker
Run:
node apps/worker/dist/main.js
The process still calls listen(PORT) so Nest can boot, but it has no HTTP controllers. Prefer a Worker component (no public URL). If you add it as a web service, do not publish that port as an API and do not use /health as a product probe — watch Runtime Logs for heartbeat every 30 seconds.
Today heartbeat is log-only, so extra replicas only duplicate log lines. Once collectors land, keep one worker (or you will double-run jobs). Do not run this worker and an old job process under legacy/ against the same schedules.
3. Add device-messaging (GHCR) — metering on only
Add a component from a container image, not from this GitHub repo.
- Image:
ghcr.io/nxtgrid/nxt-device-messaging - Tag:
v0.1.2(or the tag you actually run; keep it in lockstep with the contract package) - HTTP port: 3100
- Instance count: 1 (the service is single-writer)
- If the GHCR package is private, add a registry credential for
ghcr.io
Do not point this component at the nxt-backend Dockerfile or buildpack.
Hub: Deploy nxt-device-messaging.
Its own Valkey
Create a Valkey/Redis resource for this component only. Device-messaging in-flight state lives there; api / worker must not share it. Wire REDIS_HOST, REDIS_PORT, password, and REDIS_TLS=true on DigitalOcean managed Valkey as in that repo’s .env.example. Do not put that password in app-wide env if api/worker should not see it.
Public vs private URLs
| Who | URL |
|---|---|
api enqueue / get / cancel / token / provisioning | Private — ${device-messaging.PRIVATE_URL} or http://device-messaging:3100. Do not use *.ondigitalocean.app for this hop. |
| ChirpStack (and other vendor ingress) | Public HTTPS host of this component → POST /ingress/:pluginId |
Device-messaging webhook back to api | Private — ${api.PRIVATE_URL} plus the path metering will expose (HMAC still required) |
Command routes stay Bearer-protected (DEVICE_MESSAGING_API_KEY) even if the host is public. Ingress stays unauthenticated at the service; only vendors should be able to reach it (firewall / which routes you expose).
Health check on this component: GET /healthz (not /health).
Set eventWebhook.url in the device-messaging JSON config to the private api URL once metering exposes the receiver.
4. App-wide environment variables
Set at app level so api and worker inherit them (Settings → App-Level Environment Variables).
| Variable | Value | Notes |
|---|---|---|
NODE_ENV | production | Build and run |
NX_DAEMON | false | Needed at build time |
When metering is on, set these on api (not app-wide unless you want the worker to see them). If metering is off, leave them unset and do not add the sidecar.
| Variable | Notes |
|---|---|
DEVICE_MESSAGING_BASE_URL | Private component URL (no trailing slash). Local analogue: http://127.0.0.1:3100. |
DEVICE_MESSAGING_API_KEY | Bearer toward device-messaging (that service’s key). |
DEVICE_MESSAGING_WEBHOOK_SECRET | Same value as device-messaging’s DEVICE_MESSAGING_WEBHOOK_SECRET. |
If metering is on and DEVICE_MESSAGING_BASE_URL is missing, fail boot (or degrade that capability). Do not silently no-op enqueue. If metering is off, do not require these vars. On current main, metering is not loaded in apps/api yet — the env names are placeholders in apps/api/.env.example.
5. Verify
api — public URL:
curl -sS "https://<api-host>/health"
Expect 200 and {"status":"ok"}. That path also queries organizations, so a 200 means Postgres answered, not only that Node is listening.
worker — Runtime Logs: periodic heartbeat lines from @nestjs/schedule.
device-messaging (only if you added it):
curl -sS "https://<device-messaging-host>/healthz"
Expect 200 and {"ok":true}.
Failure modes
| Symptom | Likely cause | What to check |
|---|---|---|
| Build uses Docker and fails or is huge | Default build strategy | Node.js buildpack; build/run commands above |
| Buildpack export / registry layer error | Nx daemon log | NX_DAEMON=false at build time |
/health not 200 | Process up but Postgres down, or wrong port | PORT=3000; Supabase URL/secret; organizations table from migrations |
| Worker “API” 404 | Expected | No controllers; use logs, not HTTP |
| Duplicate jobs | Two workers or leftover legacy/ scheduler | One worker; stop the old process first |
| Device-messaging boot crash / no delivery | metering sidecar without its own Valkey or config | device-messaging App Platform |
api talking to *.ondigitalocean.app for enqueue | Public hop | ${device-messaging.PRIVATE_URL} |