Skip to main content

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.

ComponentWhenSourceReplicasRedis / Valkey
apialwaysthis repo (buildpack)as you scale HTTPnone for device queues
workeralwaysthis repo (buildpack)1 once real jobs existnone for device queues
device-messagingmetering onghcr.io/nxtgrid/nxt-device-messaging:<tag>1its 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 push from the source repo). Local seed.sql is not for production.
  • App-level SUPABASE_URL, SUPABASE_SECRET_KEY, and the other secrets in the source .env.example files.
  • JSON config at boot (NXT_CONFIG_JSON, NXT_CONFIG_PATH, or bundled config.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 like api or [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​

WhoURL
api enqueue / get / cancel / token / provisioningPrivate — ${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 apiPrivate — ${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).

VariableValueNotes
NODE_ENVproductionBuild and run
NX_DAEMONfalseNeeded 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.

VariableNotes
DEVICE_MESSAGING_BASE_URLPrivate component URL (no trailing slash). Local analogue: http://127.0.0.1:3100.
DEVICE_MESSAGING_API_KEYBearer toward device-messaging (that service’s key).
DEVICE_MESSAGING_WEBHOOK_SECRETSame 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​

SymptomLikely causeWhat to check
Build uses Docker and fails or is hugeDefault build strategyNode.js buildpack; build/run commands above
Buildpack export / registry layer errorNx daemon logNX_DAEMON=false at build time
/health not 200Process up but Postgres down, or wrong portPORT=3000; Supabase URL/secret; organizations table from migrations
Worker “API” 404ExpectedNo controllers; use logs, not HTTP
Duplicate jobsTwo workers or leftover legacy/ schedulerOne worker; stop the old process first
Device-messaging boot crash / no deliverymetering sidecar without its own Valkey or configdevice-messaging App Platform
api talking to *.ondigitalocean.app for enqueuePublic hop${device-messaging.PRIVATE_URL}