Skip to main content

Self-hosted Nango on Railway

Status (2026-05-25): Nango is deployed on Railway for all three environments (dev, staging, production). The Nango catalog autocomplete, SA tool-permissions toggle UI, and OAuth connect session + revoke wiring are all live (PRs #422, #423, #426, #427, #428). Issue #421 closed.

h.work uses the D-28 hybrid integration strategy: existing Shopify/Amazon integrations remain bespoke, while new OAuth SaaS vendors such as QuickBooks and Xero route through a single self-hosted Nango environment. Per-org isolation is enforced at the connection level: the Connect-session flow lets Nango generate the connectionId (pinning one is forbidden with a session token โ€” see "Connect flow & revoke" below), and the API verifies at confirm time that the id actually belongs to the calling org before persisting it. Legacy rows created via the raw scripting flow (pre-2026-07-22) keep the deterministic {org_slug}-{provider} id as a fallback.

Nango is Elastic License v2. OQ-203 must be ratified by Eusden before production rollout.

Railway shapeโ€‹

Create these services in the humanwork Railway project:

  • nango-server from Docker image nangohq/nango-server:hosted
  • nango-db as Railway Postgres 16 with a persistent volume
  • reuse the existing humanwork Redis service via REDIS_URL

Railway deploymentsโ€‹

Railway project: humanwork (ee0b91b9-1c2b-4e4c-b457-5a3f9c5cbdd5) in the Humanity workspace.

Railway envNango serverNango DBRedisPublic URL
dev (0303be2d-a70d-4cbc-8f01-e80b6c855440)nango-server (f3f28f14-8212-4295-8b4e-db7bddc3874c)nango-db (1c0a05b5-b0ab-4f77-9ef2-9de9f1a4ea81)redis-devhttps://nango-server-dev-e568.up.railway.app
staging (e1e7f4d9-3819-4607-be1f-0c23b87cea59)nango-server-staging (bfd6ed57-1ee6-4ff1-8fe2-55f0d03ae569)nango-db-staging (fb8fcf1a-a18f-4812-adfb-ba6d2dbde824)redis-staginghttps://nango-server-staging-staging.up.railway.app
production (caa0a321-13be-4ad6-a6ef-ad7a0a82f57b)nango-server-production (a061cc92-107c-4ee5-b953-e0b94e76ed15)nango-db-production (b86e1b80-7a7e-4a9d-8e8c-d0da387d2766)redishttps://nango-server-production-production.up.railway.app

Each Nango DB service runs postgres:16-alpine with a persistent Railway volume mounted at /var/lib/postgresql/data and PGDATA=/var/lib/postgresql/data/pgdata.

The api service in each Railway environment has NANGO_SERVER_URL and NANGO_SECRET_KEY configured from the matching Nango instance: dev uses the Nango dev API key; staging and production use their instance's Nango prod API key.

Required nango-server variablesโ€‹

  • NANGO_ENCRYPTION_KEY: 32-byte random secret. Do not rotate without taking a nango-db backup and scheduling a maintenance window.
  • NANGO_DB_USER
  • NANGO_DB_PASSWORD
  • NANGO_DB_HOST
  • NANGO_DB_NAME
  • REDIS_URL: existing humanwork Redis URL
  • NANGO_DASHBOARD_USERNAME
  • NANGO_DASHBOARD_PASSWORD
  • NANGO_SERVER_URL: Railway internal/public service URL used by API services
  • NANGO_PUBLIC_SERVER_URL: externally reachable URL for OAuth redirects

If customer-facing OAuth screens are needed, put Cloudflare in front of nango-server and set NANGO_PUBLIC_SERVER_URL to that hostname.

Humanwork API variablesโ€‹

Set these on the API service:

  • NANGO_SERVER_URL
  • NANGO_SECRET_KEY

The NestJS NangoModule uses those when governed tool execution reaches a Nango-backed provider through the native MCP surface.

Connect flow & revokeโ€‹

Client admins start OAuth for Nango-backed tools with:

POST /v1/integrations/nango/session
Content-Type: application/json

{ "provider": "quickbooks" }

The API checks org role and tool permissions, then mints a Connect Session and returns:

{ "sessionToken": "<short-lived-token>" }

No connection id is returned. Nango's session-token flow forbids pinning connection_id (live-verified against v0.71 self-hosted Nango: "connection_id is forbidden when using session token") โ€” the mint call sends {end_user: {id, email?}, organization: {id, display_name?}, allowed_integrations: [provider]} and Nango generates the connection id itself once the user completes consent. The frontend Connect UI's connect event reports the generated id back to the client ({connectionId, providerConfigKey}).

The client then confirms with the Nango-reported id:

POST /v1/integrations/credentials/nango/confirm
Content-Type: application/json

{ "provider": "quickbooks", "nango_connection_id": "<nango-generated-id>" }

The API does NOT trust nango_connection_id from the request body as-is: it calls Nango's getConnection and verifies the connection's end_user.organization.id matches the calling org before persisting anything (connections with no end_user โ€” i.e. legacy rows created by the raw scripting flow below โ€” are accepted only when the id matches the old deterministic {org_slug}-{provider} shape). It then stores the verified nango_connection_id plus non-secret metadata such as scopes, connection status, and sync timestamp in IntegrationCredential. OAuth tokens stay in Nango. If a different connection id was previously stored for this (org, provider) โ€” i.e. this is a re-connect โ€” the API best-effort deletes the old Nango connection so re-connects don't leak orphaned connection+token pairs.

Revocation is upstream-first. DELETE /orgs/:orgId/integrations/:id calls Nango DELETE /connection/{id} before deleting the local credential row for QuickBooks and Xero. If Nango revoke fails, the API returns a 5xx with Could not revoke at provider โ€” please retry and preserves the local row so the revoke can be retried.

Catalog & SA enablementโ€‹

The SuperAdmin client Integrations tab searches the Nango provider catalog through the Humanwork API:

GET /v1/admin/integrations/catalog?q=<provider>

Catalog source (as shipped โ€” 2026-06-03). IntegrationsCatalogService queries GET ${NANGO_SERVER_URL}/providers with Authorization: Bearer ${NANGO_SECRET_KEY} as its primary source whenever both env vars are set โ€” which they are in all three Railway environments. It falls back to https://docs.nango.dev/providers.json (public, unauthenticated) only on a 404 from the self-hosted endpoint. Results are cached in-process for one hour. This means the catalog reflects the provider list pinned to your self-hosted Nango version; if your Nango image is older than the public catalog, providers added in newer Nango releases will not appear until you upgrade the image. NANGO_SECRET_KEY is also consumed by NangoClient for connect-session / connection-management API calls.

SuperAdmin catalog selection is metadata-only: selecting a provider writes enabled=true to the org's tool_permissions row for that provider key. It does not modify api/src/agent-api/tool-registry.ts or wire dispatch. Agent-call handlers remain engineer-curated in the registry; SuperAdmin enablement only controls whether the client can see and start the provider connection flow.

Health checksโ€‹

From another Railway service in the same project:

curl -f "$NANGO_SERVER_URL/health"

Dashboard smoke:

  1. Open the Railway-assigned domain or Cloudflare hostname.
  2. Log in with NANGO_DASHBOARD_USERNAME / NANGO_DASHBOARD_PASSWORD.
  3. Confirm the dashboard can list/create provider configs.

Database smoke from nango-server shell:

psql "postgresql://${NANGO_DB_USER}:${NANGO_DB_PASSWORD}@${NANGO_DB_HOST}/${NANGO_DB_NAME}" -c 'select 1'

Backup and rotationโ€‹

Before changing NANGO_ENCRYPTION_KEY:

  1. Stop OAuth write traffic.
  2. Take a Railway Postgres backup of nango-db.
  3. Export the current NANGO_ENCRYPTION_KEY into the incident/change record.
  4. Rotate the key only with a tested Nango re-encryption procedure.
  5. Validate existing connections by triggering a harmless action per provider.

No key rotation is allowed without a backup because encrypted connection credentials can become unrecoverable.

Open operational questionsโ€‹

  • OQ-203: ratify Nango Elastic License v2 before production rollout.
  • OQ-207: finalize tested encryption-key rotation and disaster-recovery procedure.

Open GitHub Issuesโ€‹

#TitlePriority
#176Domain tools & integrations โ€” Finance/IR tools, OAuth flowsP2

Recently Resolvedโ€‹

#TitleClosed
#421Backend โ€” Nango Connect Session + revoke wiring (PR #427)2026-05-24

Workspace-wide Slack search for the Specialist AI, via a Nango-brokered Slack user token (scope search:read). Separate from the bespoke Slack bot channel credential โ€” the two coexist per org (integration_type slack_search vs slack).

One-time HP setup (per environment's Nango)โ€‹

  1. Create ONE Slack app (https://api.slack.com/apps). Add User Token Scope search:read.
  2. Add the Nango callback URL ({NANGO_PUBLIC_SERVER_URL}/oauth/callback) to the app's Redirect URLs.
  3. Enable public distribution (so client workspaces can install via OAuth). No Slack App Directory review is needed for direct OAuth installs.
  4. In the Nango dashboard, create an integration with unique key slack_search based on the slack provider template; set the app's client id/secret and leave the integration's scopes field EMPTY. Integration-level scopes land in the authorize URL's BOT scope= param โ€” the wrong grant class (search:read is a USER scope; a bot-scoped request either fails at consent or yields a token that 403s on search.messages). The user scope is requested per-session by our backend (integrations_config_defaults[slack_search].user_scopes = "search:read" โ†’ user_scope= on the authorize URL) โ€” verified live 2026-07-23.

Per-client enablementโ€‹

  1. SuperAdmin: enable the tool for the org โ€” PUT /orgs/:orgId/tool-permissions/slack_search { "enabled": true } (Nango-kind tools are deny-by-default without this row).
  2. Set feature flag slack_search_enabled = true for the org โ€” skip this. The flag is RESERVED, NOT ENFORCED on runtime v2 (#5423): its only reader was tool-resolution.util.ts (pre-runtime-v2 P4.7 toolset resolution), which had no non-spec caller and was deleted. Setting it does nothing today; the tool works from step 1 + 3 alone.
  3. Publish a Specialist skill listing slack_search in tools[] + a published slack_search tool binding (the P4.7 read intersection).
  4. Client admin connects in the UI: /client/settings/sources โ†’ Slack Search โ†’ Connect (Nango Connect popup โ†’ Slack consent). Requires TWO browser-reachable origins per environment: NEXT_PUBLIC_NANGO_CONNECT_URL โ†’ the Connect UI app (served on its own port, 3009, via a dedicated Railway domain targeting that port; no X-Frame-Options, embeddable) and NEXT_PUBLIC_NANGO_API_URL โ†’ the main Nango server (port 3003; sends X-Frame-Options: SAMEORIGIN, so it can never host the iframe). Also set NANGO_PUBLIC_CONNECT_URL on the nango-server service to the Connect UI origin so Nango's own connect_link is correct. The raw API flow (POST /v1/integrations/nango/session โ†’ consent โ†’ POST /v1/integrations/credentials/nango/confirm) remains available for scripting, but the connect flow now reports a Nango-GENERATED connection id (the Connect UI's connect event, or the equivalent SDK/API response after consent) โ€” the scripted confirm call must send that generated id, not a hand-built {org_slug}-slack-search one; pinning a connection id is forbidden with a session token. Connections imported directly against the Nango API outside this flow (no session, no end_user) keep working via the legacy deterministic-id fallback. Recommend connecting a dedicated service user so search visibility is broad and stable (a personal token searches only that person's channels and dies with their account).

Search results are bounded server-side (max 20, 500-char messages) and the executor derives the connection strictly from the run's org โ€” the token never reaches the agent runtime.

SA Catalog & Tool Permissions (live as of v0.3.5+)โ€‹

The SuperAdmin UI on /ops/clients/:id (Tools tab) now shows:

  • A tool-permissions toggle per client org (PR #422)
  • Nango catalog autocomplete with link-out (PR #423)

The SA can enable/disable specific integrations per org. Governed native MCP dispatch resolves enabled Nango + Jumio registry integrations for that organization.

AWS ECS (Terraform) โ€” provisioned but gated off (#1755, restored after #5123 wiped it)โ€‹

Status (2026-08-11): the Railway staging and production environments in the table above no longer exist โ€” they were deleted during the AWS cutover, together with the staging/prod Nango servers and their databases (health URLs 404). Only dev Nango (Railway) is alive. There is currently no Nango in staging or prod, and the API's NANGO_SERVER_URL / NANGO_SECRET_KEY secrets in those envs hold stale values. Enabling the ECS Nango below is therefore a fresh stand-up, not a cutover โ€” there are no existing connections to orphan.

The ECS stack can provision a self-hosted Nango per env (infra/terraform/modules/stack), gated on enable_nango in infra/terraform/envs/<env>/main.tf. Both envs set enable_nango = false until the enablement checklist below runs. What enable_nango = true creates:

  • nango ECS Fargate service running nangohq/nango-server:hosted on port 3003. Pulled from the public Docker Hub image โ€” no ECR repo, no CI deploy workflow. To roll a new image, bump nango_image, terraform apply (registers a new task-definition revision), then aws ecs update-service --cluster humanwork-<env> --service humanwork-<env>-nango --task-definition humanwork-<env>-nango --force-new-deployment. The --task-definition (family name โ†’ latest ACTIVE revision) is required: the service ignores task-definition drift so CI on the other services doesn't fight it, which also means a bare --force-new-deployment would restart the OLD revision.
  • Dedicated RDS Postgres humanwork-<env>-nango (db.t4g.micro), SG-locked to the Nango tasks, in private subnets. Connection string in the humanwork-<env>/NANGO_DATABASE_URL secret, injected as NANGO_DATABASE_URL.
  • Shared Redis (the stack's ElastiCache) via REDIS_URL / NANGO_REDIS_URL.
  • Internal addressing: the API reaches Nango at http://nango.humanwork.internal:3003 (Cloud Map). Terraform then sets the API's NANGO_SERVER_URL env to this internal URL and drops the external NANGO_SERVER_URL secret from the API task def for that env.
  • Public addressing: ALB host rules on the wildcard cert โ€” nango.<domain> (priority 15) โ†’ Nango API target group (3003), and nango-connect.<domain> (priority 16) โ†’ Connect UI target group (3009). NANGO_SERVER_URL / NANGO_PUBLIC_SERVER_URL on the Nango server are https://nango.<domain> so OAuth callback URLs are correct; NANGO_PUBLIC_CONNECT_URL is https://nango-connect.<domain> so connect_link and the embeddable Connect UI origin are correct (the main server sends X-Frame-Options: SAMEORIGIN and can never host the iframe โ€” see ยง "Per-client enablement").

Secrets to populate (Secrets Manager, REPLACE_ME until set):

  • NANGO_ENCRYPTION_KEY โ€” openssl rand -base64 32. Never rotate without a DB re-encryption procedure โ€” encrypted connection credentials become unrecoverable.
  • NANGO_DASHBOARD_USERNAME / NANGO_DASHBOARD_PASSWORD โ€” dashboard login.
  • NANGO_SECRET_KEY โ€” not an input to the Nango server (it generates its own per-env key). After first deploy, copy it from the dashboard at https://nango.<domain> into the API's NANGO_SECRET_KEY secret, then redeploy the API.

ECS enablement checklist (per env)โ€‹

  1. Set NANGO_ENCRYPTION_KEY + dashboard creds in Secrets Manager.
  2. enable_nango = true โ†’ terraform apply โ†’ CNAMEs nango.<domain> and nango-connect.<domain> โ†’ ALB (alb_dns_name) in Cloudflare (DNS is not Terraform-managed); wait for /health green.
  3. Create the dashboard account and recreate provider integrations (client ids/secrets) in the new dashboard โ€” for slack_search follow ยง "Slack search" exactly (empty integration scopes; user scope comes from the backend). Add https://nango.<domain>/oauth/callback to each provider app's redirect allowlist.
  4. Copy the new environment secret key into the API NANGO_SECRET_KEY secret; redeploy the API.
  5. Set the frontend repo vars FRONTEND_NANGO_API_URL_<ENV> = https://nango.<domain> and FRONTEND_NANGO_CONNECT_URL_<ENV> = https://nango-connect.<domain> (currently unset โ†’ the Sources page bakes empty origins), then redeploy the frontend.
  6. Client orgs connect from scratch via the Connect-session flow (nothing survives from the deleted Railway instances).