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-serverfrom Docker imagenangohq/nango-server:hostednango-dbas 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 env | Nango server | Nango DB | Redis | Public URL |
|---|---|---|---|---|
dev (0303be2d-a70d-4cbc-8f01-e80b6c855440) | nango-server (f3f28f14-8212-4295-8b4e-db7bddc3874c) | nango-db (1c0a05b5-b0ab-4f77-9ef2-9de9f1a4ea81) | redis-dev | https://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-staging | https://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) | redis | https://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 anango-dbbackup and scheduling a maintenance window.NANGO_DB_USERNANGO_DB_PASSWORDNANGO_DB_HOSTNANGO_DB_NAMEREDIS_URL: existing humanwork Redis URLNANGO_DASHBOARD_USERNAMENANGO_DASHBOARD_PASSWORDNANGO_SERVER_URL: Railway internal/public service URL used by API servicesNANGO_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_URLNANGO_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:
- Open the Railway-assigned domain or Cloudflare hostname.
- Log in with
NANGO_DASHBOARD_USERNAME/NANGO_DASHBOARD_PASSWORD. - 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:
- Stop OAuth write traffic.
- Take a Railway Postgres backup of
nango-db. - Export the current
NANGO_ENCRYPTION_KEYinto the incident/change record. - Rotate the key only with a tested Nango re-encryption procedure.
- 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โ
| # | Title | Priority |
|---|---|---|
| #176 | Domain tools & integrations โ Finance/IR tools, OAuth flows | P2 |
Recently Resolvedโ
| # | Title | Closed |
|---|---|---|
| #421 | Backend โ Nango Connect Session + revoke wiring (PR #427) | 2026-05-24 |
Slack search (slack_search)โ
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)โ
- Create ONE Slack app (https://api.slack.com/apps). Add User Token Scope
search:read. - Add the Nango callback URL (
{NANGO_PUBLIC_SERVER_URL}/oauth/callback) to the app's Redirect URLs. - Enable public distribution (so client workspaces can install via OAuth). No Slack App Directory review is needed for direct OAuth installs.
- In the Nango dashboard, create an integration with unique key
slack_searchbased on theslackprovider 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 BOTscope=param โ the wrong grant class (search:readis a USER scope; a bot-scoped request either fails at consent or yields a token that 403s onsearch.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โ
- 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). Set feature flagโ skip this. The flag is RESERVED, NOT ENFORCED on runtime v2 (#5423): its only reader wasslack_search_enabled = truefor the orgtool-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.- Publish a Specialist skill listing
slack_searchintools[]+ a publishedslack_searchtool binding (the P4.7 read intersection). - 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; noX-Frame-Options, embeddable) andNEXT_PUBLIC_NANGO_API_URLโ the main Nango server (port 3003; sendsX-Frame-Options: SAMEORIGIN, so it can never host the iframe). Also setNANGO_PUBLIC_CONNECT_URLon the nango-server service to the Connect UI origin so Nango's ownconnect_linkis 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'sconnectevent, or the equivalent SDK/API response after consent) โ the scriptedconfirmcall must send that generated id, not a hand-built{org_slug}-slack-searchone; pinning a connection id is forbidden with a session token. Connections imported directly against the Nango API outside this flow (no session, noend_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
stagingandproductionenvironments 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'sNANGO_SERVER_URL/NANGO_SECRET_KEYsecrets 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:
nangoECS Fargate service runningnangohq/nango-server:hostedon port 3003. Pulled from the public Docker Hub image โ no ECR repo, no CI deploy workflow. To roll a new image, bumpnango_image,terraform apply(registers a new task-definition revision), thenaws 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-deploymentwould 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 thehumanwork-<env>/NANGO_DATABASE_URLsecret, injected asNANGO_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'sNANGO_SERVER_URLenv to this internal URL and drops the externalNANGO_SERVER_URLsecret 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), andnango-connect.<domain>(priority 16) โ Connect UI target group (3009).NANGO_SERVER_URL/NANGO_PUBLIC_SERVER_URLon the Nango server arehttps://nango.<domain>so OAuth callback URLs are correct;NANGO_PUBLIC_CONNECT_URLishttps://nango-connect.<domain>soconnect_linkand the embeddable Connect UI origin are correct (the main server sendsX-Frame-Options: SAMEORIGINand 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 athttps://nango.<domain>into the API'sNANGO_SECRET_KEYsecret, then redeploy the API.
ECS enablement checklist (per env)โ
- Set
NANGO_ENCRYPTION_KEY+ dashboard creds in Secrets Manager. enable_nango = trueโterraform applyโ CNAMEsnango.<domain>andnango-connect.<domain>โ ALB (alb_dns_name) in Cloudflare (DNS is not Terraform-managed); wait for/healthgreen.- Create the dashboard account and recreate provider integrations (client ids/secrets) in the new dashboard โ for
slack_searchfollow ยง "Slack search" exactly (empty integration scopes; user scope comes from the backend). Addhttps://nango.<domain>/oauth/callbackto each provider app's redirect allowlist. - Copy the new environment secret key into the API
NANGO_SECRET_KEYsecret; redeploy the API. - Set the frontend repo vars
FRONTEND_NANGO_API_URL_<ENV>=https://nango.<domain>andFRONTEND_NANGO_CONNECT_URL_<ENV>=https://nango-connect.<domain>(currently unset โ the Sources page bakes empty origins), then redeploy the frontend. - Client orgs connect from scratch via the Connect-session flow (nothing survives from the deleted Railway instances).