Deployment

This guide is for operators self-hosting Nodaro. It walks through prerequisites, a full Community-edition setup, reverse proxy and HTTPS, admin promotion, the three-edition matrix, updates, scaling, backups, and common failure modes.

For a “just paste these commands” version, see the Community Edition Quickstart. This file is the same flow with explanations.

1. Prerequisites

You need:

Optional:

2. Setup walkthrough — Community edition

2a. Clone and configure

git clone https://github.com/nodaroai/app.nodaro.ai.git nodaro
cd nodaro
cp .env.example .env

On the community compose stack, .env is optional — the compose file bundles Supabase, MinIO and Redis with working defaults, so the two-command install in the Community Edition quickstart needs nothing configured at all. Set values here only to point at your OWN managed services (an external Supabase project, S3-compatible storage, …):

EDITION=community
PUBLIC_URL=http://localhost:3000

# Only when using a managed Supabase project instead of the bundled stack:
SUPABASE_URL=https://YOUR-PROJECT.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJ...
SUPABASE_ANON_KEY=eyJ...

# At least one of these:
KIE_API_KEY=
REPLICATE_API_TOKEN=
ANTHROPIC_API_KEY=
ELEVENLABS_API_KEY=

# Optional — Google Gemini API key (https://aistudio.google.com/apikey).
# Enables the direct-Google lane for Gemini models. Without it, every Gemini
# model is served through KIE, which is the default for all but the premium
# tier. See "Gemini routing" below before setting it: the direct lane is
# billed at Google's list price, which is materially higher per token.
# NOT the same as GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET (OAuth sign-in).
GEMINI_API_KEY=

# Optional — enables fal.ai-hosted models (e.g. Sync Lipsync v3).
# Without it, fal models are inert; the rest of the app is unaffected.
FAL_KEY=

# Required in Cloud edition for character LoRA training callbacks.
# Get from `replicate.webhooks.default.secret` or the Replicate dashboard.
# When unset, the webhook fast-fails 503 webhook_not_configured.
REPLICATE_WEBHOOK_SECRET=

# Storage — leave ALL of these unset to use the MinIO bundled in the
# community compose (see 2d). For Cloudflare R2, set the four R2_* values
# and keep R2_ENDPOINT / R2_FORCE_PATH_STYLE empty.
R2_ENDPOINT=
R2_FORCE_PATH_STYLE=
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET_NAME=nodaro-assets
R2_PUBLIC_URL=https://pub-….r2.dev    # or your custom domain

Every backend variable — reference

Everything backend/src/lib/config.ts reads. Required means the API refuses to boot without it (the community compose sets or generates all three). Everything else has a working default. A guard test (backend/src/lib/__tests__/config-docs.test.ts) fails CI if a variable is added to config.ts without being named anywhere in this file — keep a row here for each one anyway.

Variable Default What it does
SUPABASE_URL required Supabase project URL (bundled: http://rest:3000 behind the compose network)
SUPABASE_SERVICE_ROLE_KEY required Service-role JWT the backend uses (bundled: generated on first boot)
INTERNAL_ORCHESTRATOR_SECRET required, ≥ 32 chars Shared secret between the orchestrator and the API (bundled: generated on first boot)
SUPABASE_ANON_KEY "" Anon key handed to the frontend and GoTrue
FRONTEND_SUPABASE_URL "" (bundled: derived PUBLIC_URL/supabase) Supabase URL the browser uses — written into /config.js at boot; set it when auth lives on a managed Supabase project
DEFAULT_LOCALE "" (browser detection) The locale a fresh visitor starts in — e.g. he, ar, de, fr, es, hi, ja, ko, pt-BR, ru, zh-CN, en. Written into /config.js at boot; a user’s own saved choice always wins, and an unset/blank/unrecognised value falls back to the visitor’s browser language. Restart to apply
NODARO_TUTORIAL_PACKS "" (built-in tutorials only) Business / self-host — comma-separated directories of extra tutorial packs (each: a manifest.json + one *.json per tutorial), mounted read-only into the container. Additive; a malformed pack is skipped and logged, never corrupting the built-in tutorials. Restart to apply. See tutorials.md for the pack format
NODARO_SEED_MARKETPLACE_TEMPLATES off Cloud only — true (or 1) also seeds the built-in marketplace templates (the podcast editing workflows) under the system account so they appear in the marketplace (GET /v1/templates/browse). Off by default: on Cloud the seeder otherwise makes no database write at all. INSERT-if-missing and idempotent — a reboot is a no-op and an admin’s later edits are never overwritten. Community / Business already seed the full built-in set, so the flag is inert there. Restart to apply
NODARO_UPDATE_CHECK on off disables the update check entirely — no request leaves the install, GET /v1/version answers with the running version only and the sidebar shows it as plain text. For air-gapped installs. Restart to apply
NODARO_UPDATE_CHECK_TOKEN "" (anonymous) Optional GitHub token for the update check’s two reads of the public release list. It needs no scopes — its only job is to take the reads off the anonymous quota, which GitHub counts per outbound address (60 an hour). An install with its own address never needs it; a hosted deployment that shares its outbound address with other tenants can find that quota spent by strangers, and then the version label falls back to the built-in one and the release notes stay empty until a retry gets through. The log line [update-check] … read failed: HTTP 403 (rate limit: 0 of 60 left…) is the sign. Sent to api.github.com only, never logged. Restart to apply
EDITION community community · business · cloud — see §5
PUBLIC_URL http://localhost:3000 The install’s public origin: OAuth callbacks, media URLs, CORS
PUBLIC_URL_SAME_ORIGIN "" (unset = apiUrl is PUBLIC_URL) Set to true when the service answers on more than one hostname and the API shares the origin of the app (the default single-container layout, where the bundled proxy fronts both the SPA and /v1). /config.js then carries apiUrl: "/" instead of PUBLIC_URL, so the browser’s SSE streams stay on whichever hostname the visitor is actually on rather than being pinned to the one PUBLIC_URL names. Leave unset for a genuine split origin — an API served from a different host than the app. Only the exact value true enables it. Restart to apply
CORS_ORIGIN "" Extra allowed browser origins, comma-separated (PUBLIC_URL is always allowed). These are also the hosts the SSO landing redirect is emitted relative on — same-origin by construction — where a request arriving on any other host gets the absolute PUBLIC_URL form
RESEND_API_KEY "" Cloud, organizations: API key for sending invitation emails through Resend. Unset = invitations are not emailed; the API returns a copy-and-paste link instead
EMAIL_FROM "" Cloud, organizations: the From address for invitation emails (a verified sender on the Resend account)
LOOPS_API_KEY "" Cloud: API key for syncing marketing-email consent to Loops (loops.so). Unset = the consent-to-contact sync is inert; consent is still recorded locally and reconciled once a key is set
VITE_ORGS_ENABLED "" Cloud, organizations: true shows the organization surfaces in the browser. Build-time (Vite inlines it), so it needs the ARG+ENV pair in the Dockerfile — and it must match the backend’s ORGS_ENABLED, or the UI offers something the API refuses
PORT / HOST 8000 / 0.0.0.0 Where the API listens (in the image the API sits on 9000 behind Caddy on 3000)
NODE_ENV development production in every image
REDIS_URL redis://localhost:6379 BullMQ queues + caches (bundled: redis://redis:6379)
SCENE3D_ADVANCED_ENABLED disabled Enables an installed Advanced scene-authoring engine in Cloud, and with it the 3D Render Pro node. Only true or 1 enables it; an absent engine — or one that does not implement the Pro operation — remains unavailable regardless. Community and Business have no engine to enable, so the flag changes nothing there: GET /v1/nodes omits pro-3d-render, GET /v1/3d-scene/capabilities reports advanced: null and pro.available: false, and a request sent anyway is refused with 503 SCENE_CAPABILITY_UNAVAILABLE rather than quietly served by the Basic lane. The Basic 3D nodes are unaffected on every edition. 3D Render Pro additionally requires a configured credit price for pro-3d-render; without one the route answers 503 price_not_configured before reserving anything.
SCENE3D_LOCAL_ENABLED disabled Allows an installed local scene-authoring engine when Advanced is also enabled. Does not expose a desktop port. While it is off, blender-local never appears in the capabilities document, so no client can offer it.
SCENE3D_STAGE_REDIS_URL unset Dedicated storage for durable scene stages. Requires persistent disk, appendonly yes, appendfsync always, no-appendfsync-on-rewrite no, and maxmemory-policy noeviction. The host verifies these settings before journal operations and never falls back to the shared queue.
SCENE3D_PRIVATE_BUCKET unset Separate private bucket for retained scene geometry, cameras, native source files, posters and validation reports. Must differ from the public media bucket. Revision and render-delivery pins retain their artifacts independently. Required for scene asset reads; existing revisions remain readable when Advanced authoring is disabled.
SCENE3D_PRIVATE_S3_ENDPOINT / SCENE3D_PRIVATE_S3_REGION existing R2 settings S3-compatible endpoint and region for the private scene bucket. No public bucket URL is used.
SCENE3D_PRIVATE_S3_ACCESS_KEY_ID / SCENE3D_PRIVATE_S3_SECRET_ACCESS_KEY existing R2 credentials Server-side credentials for the private scene bucket. Prefer credentials restricted to that bucket.
SCENE3D_PRIVATE_S3_FORCE_PATH_STYLE existing R2 setting Enable with true or 1 for an S3-compatible store that requires path-style addressing.
RUNTIME_ENV RAILWAY_ENVIRONMENT_NAME, else local Names this deployment. Only matters when two installs share ONE database but have SEPARATE Redis instances (a staging + production pair): each install’s stale-execution sweeps then reconcile only the runs its own orchestrator claimed, instead of marking the other install’s healthy executions “orphaned”. On Railway, RAILWAY_ENVIRONMENT_NAME already supplies it — set RUNTIME_ENV yourself only elsewhere. Every container of one install (API, workers, orchestrator) must use the SAME value
DATABASE_URL "" Direct Postgres URL — used only to apply migrations on boot
RUN_MIGRATIONS_ON_BOOT false (compose: true) Apply supabase/migrations before the API starts; false on a managed Supabase project (see 2c)
KIE_API_KEY "" KIE.ai — broadest media/LLM coverage (or paste it on Install health)
KIE_API_BASE_URL https://api.kie.ai Where KIE traffic goes. Point it at an egress proxy — see 12a. Also moves the Claude/Gemini LLM lanes
REPLICATE_API_TOKEN "" Replicate — Flux 2 family, LoRA training
ANTHROPIC_API_KEY "" Direct Anthropic lane for Claude LLM nodes
GEMINI_API_KEY "" Direct Google lane for Gemini models — see “Gemini routing”
ELEVENLABS_API_KEY "" Speech, voices, dubbing
ELEVENLABS_BASE_URL https://api.elevenlabs.io Where ElevenLabs traffic goes — see 12a
FAL_KEY "" fal.ai-hosted models
HEYGEN_API_KEY "" AI Avatar / Cinematic Avatar (or run them on the nodaro.ai connection)
BEEBLE_API_KEY "" Relight & Switch
APIFY_API_TOKEN "" Web Scrape, Meta Ads, Instagram (or run them on the nodaro.ai connection)
NODARO_API_KEY "" The nodaro.ai connection by key instead of OAuth (§11)
NODARO_CLOUD_URL https://app.nodaro.ai Where the connection talks to; CI points it at an unreachable host
NODARO_ENCRYPTION_KEY "" (compose: generated) 64-char hex; encrypts pasted provider keys and social connections at rest
HEYGEN_CATALOG_REFRESH_HOURS 24 How often the shared HeyGen preset catalog is refreshed
REPLICATE_WEBHOOK_SECRET "" Cloud edition — LoRA training callbacks; unset = webhook fast-fails 503
R2_ENDPOINT · R2_FORCE_PATH_STYLE · R2_ACCOUNT_ID · R2_ACCESS_KEY_ID · R2_SECRET_ACCESS_KEY · R2_BUCKET_NAME · R2_PUBLIC_URL bundled MinIO (compose); outside compose R2_BUCKET_NAME defaults to scenenode-assets — always set it to your bucket Object storage — see 2d
R2_REGION auto S3 region. auto suits Cloudflare R2 and MinIO ignores it; set a real one for Supabase-local (local), DO Spaces (nyc3, …) or AWS — they reject auto
STORAGE_OBJECT_ACL "" (header omitted) Canned ACL stamped on every uploaded object. For S3-compatible stores that cannot take a bucket policy — e.g. DO Spaces refuses PutBucketPolicy to a bucket-scoped key. See 2d
R2_PUBLIC_FALLBACK_DOMAIN "" A second public host for assets (e.g. the raw pub-<id>.r2.dev beside a CDN domain)
R2_SHARED_WITH_RELAY_TARGET false Set true ONLY when R2_PUBLIC_URL names the same bucket the instance’s relay target (NODARO_CLOUD_URL) writes to. It keys on the SOURCE URL, not on the lane: any source URL that is already an object in this bucket is then referenced in place instead of being copied under a second key. That covers relayed outputs — which this instance also stops deleting and stops counting against its quota, because another instance created them — and it covers non-relayed sources too: the save-to-storage node stores a reference rather than an independent copy when its input is already in the bucket, so deleting that input’s library item removes the object the save node points at. Strict parse: only true / 1 enable it
MAX_CONCURRENT_NODES_PER_EXECUTION 6 (max 20) Nodes one workflow run may execute at once — the self-host parallelism ceiling
VIDEO_WORKER_CONCURRENCY 50 BullMQ concurrency of the media worker (I/O-bound)
RAILWAY_DEPLOYMENT_DRAINING_SECONDS unset (Railway’s default window) Railway only: the SIGTERM → SIGKILL window of a replaced container. The media worker reads the same variable and drains for that long minus 5 seconds, so a long model call already in progress (a 3D Render Pro planning or review step can take up to 6 minutes) finishes and is saved before the job moves to the new container. Set it to 420 on a service that runs 3D Render Pro. Unset, the worker keeps its 25-second drain. The container only stays up as long as work is still running
ORCHESTRATOR_CONCURRENCY 20 BullMQ concurrency of the orchestrator (I/O-bound)
RENDER_WORKER_CONCURRENCY 2 (max 10) Remotion renders in parallel — each is a headless Chrome
REMOTION_CONCURRENCY 2 for 3D scenes; Remotion default (50 % of cores) for other compositions Browser tabs per render. An explicit value overrides both paths. Keep this low when running multiple 3D jobs: each WebGL tab uses additional threads and counts toward the container process limit.
FFMPEG_CONCURRENCY 4 (max 32) Concurrent ffmpeg processes across every ffmpeg node
MCP_PUBLIC_URL "" = the Nodaro Cloud host Public base of the MCP host when it differs from PUBLIC_URL; self-hosters serving MCP on their main host set it equal to PUBLIC_URL
MCP_DYNAMIC_REGISTRATION · MCP_DCR_ALLOWLIST allowlist · 14 known clients (Claude, Claude Code, Cursor, Cline, Continue, Goose, ChatGPT, OpenAI, Lovable, Gemini, Gemini CLI, Codex, MCP Inspector, mcp-inspector) RFC 7591 dynamic client registration for MCP clients (allowlist · open · off), and the client_name allowlist consulted in allowlist mode — see §10
FIGMA_PLUGIN_OAUTH_CLIENT_ID "" = plugin connect off client_id of the developer app the Figma plugin connects through; the app must list <PUBLIC_URL>/v1/oauth/plugin/callback in its redirect URIs and request jobs:read, assets:read, assets:write, credits:read — see the plugin connect handshake
COMMUNITY_CONNECT_ENABLED off Cloud side only — accept community-instance connections
PLATFORM_OWNER_EMAIL "" Business/Cloud — the super_admin no other admin can demote; empty = none
PLATFORM_OPERATOR_EMAILS "" Comma-separated emails allowed to reach the money admin routes (credit grants, tier/role changes, model pricing and cost settings) on a deployment that sets billing.payerAccount. Those routes additionally require a non-federated account, so an identity the deployment’s own SSO provider asserts can never reach them — and on such a deployment an SSO assertion for an address on this list is refused rather than newly linked, so the operator account cannot become federated and lock itself out (an operator account already linked to the provider is unaffected). Empty falls back to PLATFORM_OWNER_EMAIL; empty with no owner closes the money routes to everyone. Inert on deployments with no payer account.
EXTERNAL_SSO_PROVIDERS "" (SSO off) Trusted external identity providers, as inline JSON or @/path/to/file.json. Unset ⇒ no SSO button (GET /v1/sso/providers answers an empty list) and every per-provider route answers 404 unknown_provider. A malformed value fails the boot loud (never silently disables auth). Shape + linking rules: External SSO
EXTERNAL_SSO_LINK_EXISTING false Whether a verified-email assertion may link to a pre-existing account not already SSO-linked. Default false is takeover-safe; true links only when the IdP also asserts a verified email. One account is outside the flag: a deployment’s own billing.payerAccount links on its first verified assertion either way. See External SSO
KIE_UNIQUE_ID "" Cloud — KIE account id for the credit audit
STRIPE_SECRET_KEY · STRIPE_WEBHOOK_SECRET "" Cloud only — billing; ignored on community/business
PAYG_WEB_BLOCK_ENABLED · PAYG_WEB_BLOCK_EXEMPT_USER_IDS off · "" Cloud only — pay-as-you-go web block and its grandfathered accounts, comma-separated. Not combinable with a billing.payerAccount whose effective grade is free or payg: the pool is resolved at the payer’s tier, so the API refuses to boot (PAYG_WEB_BLOCK_ENABLED is on and the deployment payer's effective tier is …) rather than refuse every run — put the payer on a paid subscription or leave the flag off
AUTO_RECHARGE_ENABLED off Cloud only — auto-recharge kill switch: it guards the trigger + charge path only, while webhook provisioning stays on so in-flight payments still settle
ORGS_ENABLED off Cloud only — multi-tenant organizations (schools / teams) rollout gate. Ships dark; the schema migrations run in every edition regardless
MCP_ENABLED off Serve the MCP endpoint (§10)
COPILOT_ENABLED off Cloud only — the in-app Workflow Copilot. Needs ANTHROPIC_API_KEY; admins can also pause it at runtime from Settings
CHARACTER_LORA_ROUTING_ENABLED on Route generations that mention a trained character through its LoRA; off = plain reference-image injection
JOB_HOLD_TTL_HOURS "" (holds never expire) Only matters on a deployment that registers a job policy (see “Job policy” under Surface profile). Hours a job may wait in pending_review before the platform auto-rejects it: the reservation is refunded, the withheld output is deleted, and the decision is recorded with policy_id = "platform", reason = "hold-expired". The message the owner is left with is checked against what the refund actually moved — if nothing was still reserved it says so rather than promising credits back, and an operator report is filed. Unset = a held job waits for a human indefinitely, with its credits reserved the whole time. This is the one sweep allowed to touch a pending_review row. Auto-approve is deliberately not an option — it would publish exactly the output a human declined to look at
META_APP_IDDISCORD_CLIENT_SECRET "" Social network OAuth apps — see 2b-2

2b. Generate internal secrets

On the community compose stack both are generated on first boot and persist in the app-data volume — skip this step. On a managed deployment (Railway, your own orchestration), set both, 32 bytes hex each:

echo "INTERNAL_ORCHESTRATOR_SECRET=$(openssl rand -hex 32)" >> .env
echo "NODARO_ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env

INTERNAL_ORCHESTRATOR_SECRET authenticates the orchestrator process to the API server within a Nodaro container. NODARO_ENCRYPTION_KEY is AES-256-GCM key material used to encrypt stored credentials at rest — social-OAuth tokens and in-app provider keys (§12). SOCIAL_ENCRYPTION_KEY is the older name for the same variable and still works (see §8).

2b-2. Social network apps (optional, per network)

Social publishing works per-network: a network becomes connectable once its OAuth app credentials are set. Unconfigured networks still appear in Settings → Integrations (and in GET /v1/social/providers) as unavailable with the missing variable names — nothing breaks without them.

Network Required env vars
Instagram META_APP_ID, META_APP_SECRET (optional META_INSTAGRAM_CONFIG_ID for Facebook Login for Business)
Instagram (no Facebook Page) INSTAGRAM_APP_ID, INSTAGRAM_APP_SECRET — Meta issues these separately from the Facebook app. Connects an Instagram account directly, with no linked Page, and its tokens refresh on their own (~60 days)
Facebook META_APP_ID, META_APP_SECRET (optional META_FACEBOOK_CONFIG_ID)
TikTok TIKTOK_CLIENT_KEY, TIKTOK_CLIENT_SECRET
YouTube GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
LinkedIn LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET
X (Twitter) X_CLIENT_ID, X_CLIENT_SECRET
Telegram none — users paste their own bot token
Reddit REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET
Pinterest PINTEREST_CLIENT_ID, PINTEREST_CLIENT_SECRET
Discord DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN
Twitch TWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET
Threads THREADS_APP_ID, THREADS_APP_SECRET
Mastodon MASTODON_CLIENT_ID, MASTODON_CLIENT_SECRET (optional MASTODON_URL, default mastodon.social)
Bluesky none — users connect with a handle + app password
Dev.to / Hashnode / Medium none — users connect with their own API key / token
WordPress none — users connect with site URL + application password
Lemmy none — users connect with instance + login + community

Each OAuth app must whitelist the redirect URI https://YOUR-DOMAIN/v1/social/callback/{network}.

When a Facebook/Instagram login manages more than one Page or Instagram account, the connect popup shows an account picker — the user chooses which account to connect (single-account logins connect directly, as before).

2c. Apply database migrations

Bundled stack (compose default): automatic. The app container applies supabase/migrations/ on boot (gated by RUN_MIGRATIONS_ON_BOOT=true + DATABASE_URL, both defaulted in the compose file), tracks applied files in public._nodaro_migrations, and refuses to start against a half-migrated schema. Skip to 2d.

Managed Supabase project: set RUN_MIGRATIONS_ON_BOOT=false and apply them yourself. In the Supabase dashboard, open SQL editor and paste each file from supabase/migrations/ in filename order (zero-padded prefixes are intentional — 001_…sql, 002_…sql, …).

Faster path with the Supabase CLI:

supabase link --project-ref YOUR-REF
supabase db push

Migrations are idempotent except where they explicitly aren’t (e.g. seeded data); re-running them on a fresh DB is fine.

2d. Configure object storage

Default: the bundled MinIO — nothing to configure. docker-compose.community.yml ships a MinIO service with working defaults: R2_ENDPOINT=http://minio:9000, path-style addressing, and R2_PUBLIC_URL=http://localhost:3000/storage/nodaro-assets (media is proxied through the app’s own origin by Caddy, so the browser and the backend read the same URL). The bucket is auto-created with a public-read policy on first boot. Media lives in the minio-data Docker volume. Change the default credentials before exposing the stack to a network; when serving on a real domain, set R2_PUBLIC_URL to https://<your-domain>/storage/nodaro-assets.

Cloudflare R2 (recommended for real deployments — zero egress):

  1. Create a bucket called nodaro-assets (or anything; match R2_BUCKET_NAME).
  2. Under the bucket → Settings, expose a public r2.dev subdomain or attach a custom domain. Copy that URL into R2_PUBLIC_URL.
  3. Under Manage R2 API tokens, mint an access key with Object Read & Write on this bucket. Copy R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / R2_ACCOUNT_ID.
  4. Set R2_ENDPOINT= and R2_FORCE_PATH_STYLE= (empty) so the MinIO compose defaults don’t apply — with them empty, the endpoint is derived from R2_ACCOUNT_ID.

For any other S3-compatible store (AWS S3, Backblaze B2, DigitalOcean Spaces, Supabase Storage, self-managed MinIO), set R2_ENDPOINT to its S3 API URL, R2_FORCE_PATH_STYLE=true for most self-hosted servers, and R2_PUBLIC_URL to the bucket’s public URL.

Also set R2_REGION unless the store is Cloudflare R2 or MinIO. It defaults to auto, which is R2’s own value and which MinIO ignores — but AWS, DO Spaces (nyc3, fra1, …) and Supabase-local (local) validate the region and reject auto, so every request fails with an authorization or endpoint error that does not mention the region at all.

Making media publicly readable. There are two mechanisms, and most installs need only the first:

  1. A bucket policy — the default. On a custom R2_ENDPOINT the app creates the bucket at boot and applies an anonymous-read policy to it, so objects are readable without any per-object header. Cloudflare R2 does not need this (its public bucket setting covers it), and Nodaro Cloud deliberately sends no ACL at all.
  2. STORAGE_OBJECT_ACL — for stores that cannot take a bucket policy. DigitalOcean Spaces is the usual case: it refuses PutBucketPolicy to a bucket-scoped key, so per-object ACLs are the only way. Set STORAGE_OBJECT_ACL=public-read and every object this app writes carries that ACL.

Leave it empty unless you need mechanism 2. Empty means the header is omitted entirely, which is the behaviour every existing install already has — setting it on a store that is already public via policy is redundant, and setting it on a store whose keys lack s3:PutObjectAcl will make every upload fail. Accepted values are the standard canned ACLs (private, public-read, public-read-write, authenticated-read, aws-exec-read, bucket-owner-read, bucket-owner-full-control); anything else is rejected at boot rather than on the first upload.

If the Cloud Recast plugin is enabled, its revisioned audio player loads audio-only layer files directly in Web Audio. The public storage origin must allow anonymous cross-origin GET from the Recast web origin (and HEAD when your player or CDN uses it), expose the headers needed for media reads, and honor byte-range requests (Range / 206 Partial Content) so seeking works. Configure this on the bucket or CDN, and verify it against an actual generated audio-layer URL; API CORS_ORIGIN does not configure object-storage CORS.

2e. Start the stack

docker compose -f docker-compose.community.yml up

The app image is pulled prebuilt (ghcr.io/nodaroai/nodaro-community), so first boot downloads ~2.4 GB rather than compiling for 5–10 minutes; subsequent boots are seconds. (Set NODARO_IMAGE or use docker compose build to build from source instead.) Besides latest (which tracks main), every release is also tagged v<X.Y.Z> and v<X.Y> — the version the app sidebar shows — so NODARO_IMAGE=ghcr.io/nodaroai/nodaro-community:v1.23.0 pins a reproducible install. You’ll see logs from Redis and the nodaro service interleaving.

When you see:

nodaro-1  | server listening on http://0.0.0.0:9000

…the backend is live. Caddy fronts it on port 3000. Open http://localhost:3000.

2f. First login

Sign up via the UI (email + password). Supabase Auth creates the user; your Nodaro instance creates a row in profiles automatically. Community edition users are unrestricted — there’s no credit ledger and no admin panel.

That’s it. The next sections cover production hardening.

3. Reverse proxy + HTTPS

The container already runs Caddy internally on port 3000 — it serves the frontend statics and proxies /v1/* to the Fastify backend on port 9000.

Caddy accepts the X-Forwarded-* headers only from peers in the private ranges (127.0.0.1/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fd00::/8, ::1), and rewrites X-Forwarded-For to the single client address it derives — the rightmost entry that is not itself a trusted proxy, so an address a client put in the header is skipped. A proxy reaching it from a public address has those headers replaced with the values Caddy itself observed. One consequence worth knowing if you serve an intranet: the same rule skips your users’ own addresses when those are private too, so a client on the LAN can choose the address the backend records.

For HTTPS you have two options:

Option A — Front Caddy with another reverse proxy. Recommended if you already run nginx or another proxy.

server {
  listen 443 ssl http2;
  server_name nodaro.example.com;
  ssl_certificate     /etc/letsencrypt/live/nodaro.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/nodaro.example.com/privkey.pem;

  client_max_body_size 100M;
  proxy_buffering off;          # important for SSE

  location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
  }
}

Option B — Caddy on the host with auto-HTTPS.

nodaro.example.com {
  reverse_proxy 127.0.0.1:3000 {
    flush_interval -1
  }
}

Caddy will obtain a Let’s Encrypt cert automatically. Make sure ports 80 and 443 are open and that the domain’s A/AAAA records point at the host.

After any of these, update .env:

PUBLIC_URL=https://nodaro.example.com
CORS_ORIGIN=https://nodaro.example.com

…and restart the stack so the frontend’s Vite build picks up the new PUBLIC_URL.

4. First user + admin promotion

Community edition has no admin panel, but Business and Cloud do. To mark a user as admin (after they’ve signed up), open the Supabase dashboard → SQL editor → run:

UPDATE profiles
   SET role = 'admin'
 WHERE id = '<user_uuid>';

The user UUID is visible in Authentication → Users. The change takes effect within five minutes — Nodaro caches admin status for 5 minutes (CACHE_TTL_MS in backend/src/lib/admin-check.ts); restart the API to apply it at once.

5. Three editions

  Community Business Cloud
Self-hostable yes yes no — managed only
Admin panel no yes yes
User management UI no yes yes
Credit ledger no no yes
Stripe billing webhooks no no yes
Admin-configurable credit pricing no no yes

Switch by changing EDITION=community|business|cloud and restarting. Edition is read at startup; there is no migration cost moving Community → Business (no DB schema changes between them). Moving to Cloud requires Stripe wiring — see backend/CLAUDE.md and the subscriptions/credit_transactions/stripe_customers tables.

The frontend reads its edition from the VITE_EDITION env var at build time (Vite inlines it). When switching editions, rebuild the frontend image:

docker compose -f docker-compose.community.yml build --no-cache nodaro
docker compose -f docker-compose.community.yml up

The build refuses an empty or unknown VITE_EDITION. Because Vite inlines the value, an unset one would otherwise build cleanly and silently fall back to community — a Business or Cloud image would ship with no admin panel and no billing, and nothing would say so until someone went looking for them. The compose file passes it for you; a hand-rolled docker build needs --build-arg VITE_EDITION=community|business|cloud.

Three frontend values are not frozen at build time: the API origin, the browser-facing Supabase URL and the anon key. At boot the container writes them from its env into /config.js (PUBLIC_URL, FRONTEND_SUPABASE_URL — defaulting to PUBLIC_URL/supabase on the bundled stack — and SUPABASE_ANON_KEY), and the browser reads that file before the app starts. So the published image serves any port or domain after a restart; the VITE_* build args are only the fallbacks when a runtime value is unset.

Other build-time frontend env vars (all VITE_*, all inlined by Vite at build time):

Var Description
VITE_STUDIO_URL Base URL of the external Studio app (studio.nodaro.ai) for “Open in Studio” deep links. Default https://studio.nodaro.ai.

Surface profile (NODARO_SURFACE_PROFILE)

Business and Cloud editions only. The Community edition ignores NODARO_SURFACE_PROFILE entirely and always serves the stock surface — set EDITION=business (or cloud) to use it.

Set NODARO_SURFACE_PROFILE to inline JSON or @/path/to/profile.json to narrow the UI without a rebuild: hide nav entries and dashboard tabs, deny node and model types, set the product name, sibling-app links and login methods, pin a default locale, and force outputs private. It rides the same /config.js channel as the values above (env → validated at boot → mirrored to the browser); a malformed field degrades element-wise to that field’s default with a warning, but a profile that is set and does not load at all — an unreadable @file, invalid JSON, or a value that fails validation as a whole — refuses to boot on Business/Cloud ([surface-profile] FATAL … Refusing to boot a narrowing deployment mainline-open, exit 1): a narrowing deployment must never come up serving the full surface. Unset = the full default surface. The profile can only narrow — it never turns on a surface the edition gates off. Fields (all optional; each array empty = “keep the default”):

Example:

NODARO_SURFACE_PROFILE={"nav":{"hide":["gallery"]},"brand":{"productName":"Studio"},"outputs":{"allowPublic":false},"voice":{"allowedGenders":["male"]},"billing":{"unitLabel":"credits","unitRate":2000,"selfServe":false,"defaultAllowanceUnits":100000,"allowances":"off"}}

Prompt policy (modesty / content clauses). A deployment that needs to fold a fixed clause into every image / video / audio prompt (or force the Suno vocal gender) registers a backend PromptPolicy at the composition root — it is applied server-side, after prompt assembly, so a client cannot bypass it. This is code the deployment owns, not an environment variable; with none registered, prompt assembly is byte-identical to stock. (There is deliberately no env-var switch for the clause — the clause text is the deployment’s own, kept out of the shipped packages rather than read from the environment.) The clause lives in the deployment’s own registered PromptPolicy module, applied by the backend registry in backend/src/lib/prompt-policy.ts; the published @nodaro/prompts package stays content-free, enforced by its content-free-contract guard test (the package source may not read process.env).

Job policy (generation gates). A deployment that must judge generations — before they run, or before their output is published — registers a backend JobPolicy at the composition root (backend/src/lib/job-policy.ts). Like the prompt policy it is code the deployment owns, not an environment variable, and with none registered the platform is byte-identical: the funnels short-circuit before any check and before any audit write. One policy object carries two optional checks. The request gate (checkRequest) sits at the single job-insert funnel and is asked before a row or a credit reservation exists; its verdict is { verdict: "allow" } or { verdict: "block", reason, userMessage? }, and a block answers HTTP 422 { error: { code: "job_blocked", message } } with nothing to refund. The result gate (checkResult) sits at the completion funnels, asked after the output is written to storage and before the completion write, the asset row and the credit commit; its verdicts are allow, flag, block and hold. A block fails the job with a full refund, deletes the produced object and writes a structured error_hint (kind: "policy-block"); a hold parks the job in the pending_review status — an in-flight status, exempt from the reconcile and timeout sweeps, output withheld, credits still reserved — until an admin approves it onto the normal completion path or rejects it, from Admin → Content Review (/admin/review, backed by /v1/admin/review/jobs…). Both gates are fail-closed once a policy is registered: a check that throws never publishes — the request gate blocks, the result gate holds when the job is hold-eligible and blocks otherwise, recorded with reason: "policy-unavailable" and a platform-owned user message, never the policy’s own wording. A job row the result gate cannot read is treated the same way: the read is retried once and then blocks (never holds — eligibility is a property of the row it could not read), while only a confirmed missing row answers allow. Every decision, allow included, is recorded in job_policy_decisions keyed by (job_id, hook_point, payload_hash), which is also the idempotency key: queue retries and the reconcile cron reuse a recorded result-gate verdict instead of re-asking the deployment’s gate. (job_id is NULL for a request-gate block — no row exists to point at — so the reuse guarantee is a result-gate one; a repeated request is a repeated decision.) JOB_HOLD_TTL_HOURS bounds how long a hold may wait. Design note: docs/design/job-policy-seam.md.

Brand assets (favicon, logos) are overridden by a Docker static-asset layer, not this JSON.

6. Updating

Pull the newer image and restart:

git pull
docker compose -f docker-compose.community.yml pull
docker compose -f docker-compose.community.yml up -d

(Building from source instead? Swap pull for build.)

Rolling back is pinning the previous version: releases are tagged v<X.Y.Z> / v<X.Y> alongside latest, so point NODARO_IMAGE at the last good tag and up -d again.

On the bundled stack, migrations apply automatically on boot (RUN_MIGRATIONS_ON_BOOT, §2c) — there is nothing to run by hand. Only when pointing at your own managed Supabase project with boot migrations disabled do new files under supabase/migrations/ need applying in filename order before restarting; the backend usually won’t crash on a missing migration — specific routes 500 until their schema lands — with one exception: a deployment that names a billing.payerAccount writes deployment_payer_settings at boot and refuses to start until 381_deployment_payer_identity.sql (and the allowance tables of 382) are applied, so land those before restarting onto an image that carries a payer.

We aim to keep migrations forward-compatible (new tables, additive columns) — if anything changes destructively, it’ll be called out in the changelog. Pin to a specific commit/tag if you need to be cautious.

VM deploy lane

To keep a self-hosted VM install updated over SSH, copy examples/deploy-host.yml into your own repository’s .github/workflows/, set the DEPLOY_HOST, DEPLOY_USER and DEPLOY_SSH_KEY secrets, and dispatch it. It reuses the published community image tag, runs docker compose pull && up -d, health-gates on /health, then prunes old layers. Never trigger production deploys on a branch push, and use separate secret names per environment. It ships as an example, not a workflow in this repo — there are no deploy secrets where the public mirror runs.

7. Scaling

The stock docker-compose.community.yml runs everything in a single container: API server + video worker + render worker + orchestrator + Redis + Caddy. That’s fine up to ~5 active users.

For more scale, split the workers into separate containers. Inspect /app/start.sh (baked into the image) — it launches five Node processes side by side:

Process What it does CPU/mem profile
node dist/server.js Fastify HTTP API low CPU, moderate memory
node dist/worker.js Video worker (per-node BullMQ jobs, calls AI providers) I/O-bound, high concurrency
node dist/render-worker.js Remotion renderer (headless Chrome) CPU-bound, 1–2 per box
node dist/orchestrator.js Workflow orchestrator (DAG executor) I/O-bound, low CPU
node dist/pipeline-worker.js Story-to-Video pipeline orchestration (all editions; exits cleanly on non-cloud) I/O-bound, low CPU

A typical split:

All containers share the same Redis + Supabase + R2. They don’t talk to each other directly — Redis (BullMQ) is the only coordination point.

Two installs, one database: if you point a second install (a staging copy, a preview environment) at the SAME Supabase project but give it its OWN Redis, name each install with RUNTIME_ENV — on Railway RAILWAY_ENVIRONMENT_NAME already does this for you. Workflow executions record the name of the install whose orchestrator claimed them, and each install’s stale-execution sweeps only reconcile its own. Without distinct names, each install looks for the other’s orchestration jobs in its own Redis, fails to find them, and marks perfectly healthy runs failed with “Execution orphaned”. Rows already running at the moment you upgrade carry no name yet; the install named production reconciles those.

Redis HA: BullMQ supports Redis cluster mode out of the box. Set REDIS_URL to a cluster endpoint or a Sentinel URL.

Shared caches on Redis (multi-instance API): besides the queues, the API keeps small shared snapshots in Redis so N API containers do not each redo the same slow provider work — today the HeyGen avatar / voice catalogs (heygen:catalog:v1:*, ≈4 MB, published once a fill completes and adopted by every instance at boot; one instance per environment refreshes it under a lock every HEYGEN_CATALOG_REFRESH_HOURS, default 24; the others notice a newer snapshot within about half a minute and adopt it). Everything there is a cache: with Redis unreachable each instance falls back to its own memory, and a flushed key is simply refilled from the provider on the next boot.

Object storage: configure bucket-level lifecycle rules on R2/S3 to expire old assets (e.g. 90 days). Nodaro never deletes assets itself — it only references them by key. One exception: on Cloud, a daily cron reaps transient video-analysis-tmp/ intermediates (analysis working files, orphaned after a worker crash). Self-hosted (Community/Business) deployments have no such cron, so include the video-analysis-tmp/ prefix in your bucket lifecycle rule.

8. Backups

Compose (community quickstart) installs — one command each way:

tools/community-backup.sh              # -> backups/nodaro-backup-<date>-<version>.tar.gz
tools/community-restore.sh <archive>   # DESTRUCTIVE; asks for confirmation

The backup holds the Postgres dump, the MinIO media, the instance encryption key and .env — everything the stack cannot regenerate. Restore is also the downgrade path: migrations are forward-only, so going back a version means restoring the backup taken before the upgrade. Take one before every major-version update.

Managed deployments — three things are stateful:

If you take Postgres down for migration or recovery, the backend will crash-loop until it’s reachable. That’s fine — once Postgres is back, restart the Nodaro container and it’ll pick up.

9. Troubleshooting

Start at /setup. Self-hosted (community/business) installs serve a live health screen at http://<your-host>/setup (backed by GET /v1/setup/status, both public — no login needed, presence booleans only). It shows green/red cards for the database (including a dedicated “Migrations missing” state), Redis, storage, and provider keys, with a hint per failing card, and polls every 5 seconds. Most of the issues below are visible there at a glance. The route does not exist on the Cloud edition.

“Missing or invalid env vars” on startup. The error message lists which Zod-validated vars are wrong. Common culprits: SUPABASE_SERVICE_ROLE_KEY empty, INTERNAL_ORCHESTRATOR_SECRET shorter than 32 chars.

port is already allocated on docker compose up. Only two host ports are published — 3000 (app) and loopback 9001 (MinIO console); Redis and the database never bind one. Change the host side of the conflicting mapping ("3001:3000") and, for the app port, set PUBLIC_URL to match — same fix as the quickstart.

Frontend renders, but the editor stays blank or “Loading…” forever. Open the browser console. If you see CORS errors, set CORS_ORIGIN to your real public URL and restart. If you see Supabase auth errors, open /config.js on your install: it must name the Supabase URL your browser can reach (on the bundled stack PUBLIC_URL/supabase) and the anon key. It is written at boot from PUBLIC_URL / FRONTEND_SUPABASE_URL / SUPABASE_ANON_KEY — fix those and restart.

Migration failure: “relation … does not exist”. A migration ran out of order. Apply migrations from supabase/migrations/ in filename order via the Supabase SQL editor. Each is idempotent against an already-applied state.

OAuth callback returns 500. Confirm migration 093_developer_apps.sql ran. Without it, the developer_apps/developer_app_authorizations/ developer_app_tokens tables don’t exist and the OAuth route handler errors when it tries to insert.

R2 upload returns 401 / 403. Recheck the API token has Object Read & Write on the bucket. If you front R2 with a custom domain, also check the bucket’s public access setting — Nodaro returns public R2 URLs to the browser, so reads must work without auth.

Workflows enqueue but never start running. Check the worker logs (docker compose logs nodaro in the single-container layout). The orchestrator only picks up jobs from Redis — if Redis is unreachable, nothing runs. Confirm REDIS_URL is correct and Redis is healthy (docker compose exec redis redis-cli ping should return PONG).

A specific node type 500s with Missing API key. That node calls a provider whose env var is unset. Add KIE_API_KEY / REPLICATE_API_TOKEN / ANTHROPIC_API_KEY / ELEVENLABS_API_KEY / FAL_KEY per your needs and restart.

Gemini routing (GEMINI_API_KEY)

Gemini models can be served two ways, and which lane each model uses is declared per model in the registry (packages/shared/src/llm-models.ts), not by a global switch:

Model Default lane Direct lane used when
gemini-3-flash KIE KIE fails
gemini-3.6-flash KIE KIE fails
gemini-3.7-flash KIE KIE fails
gemini-3.8-flash KIE KIE fails
gemini-3.1-pro Direct Google always (KIE is the fallback)
video-analysis (any tier) Direct Google — ONLY always; there is no fallback

GEMINI_API_KEY is REQUIRED if you use video-analysis. That node is pinned to the direct lane with no KIE fallback, so without the key every analysis job fails with ... is pinned to the direct lane but GEMINI_API_KEY is not set. Set the key before deploying a build that includes this behaviour.

For everything else the key is optional: leave it unset and the remaining Gemini models are served through KIE exactly as before.

Video-analysis is direct-only on purpose. KIE reaches Gemini by smuggling media URLs through an image_url field rather than sending real media parts, and its response_format silently drops record-shaped schema fields. A fallback would therefore not degrade gracefully — it would return differently-grounded analysis with no signal that anything changed. A hard error is the honest outcome.

Two things to know before changing a model’s lane:

To move a model between lanes, set or remove preferDirect on its registry entry — no client code changes.

Running on arm64? The image ships a distinct arm64 build of the same pinned ffmpeg source. Rendered-output parity between the amd64 and arm64 builds is verified against the same characterization baseline (54/54 operations within tolerance as of the current pin); re-verify after any ffmpeg pin bump with CHARACTERIZE_ARCH=arm64 backend/scripts/characterize-in-image.sh check.

Docker build fails downloading or checksum-verifying the ffmpeg tarball. ffmpeg is deliberately pinned in the Dockerfile to an exact static build (ARG FFMPEG_TARBALL_URL_* + ARG FFMPEG_TARBALL_SHA256_*, per architecture): rendered audio/video output differs between ffmpeg versions (filter gain/behavior changes — the 5.1→8 jump alone changed a convolution filter’s gain semantics), so an unpinned install would let a rebuild silently change what renders sound and look like. A download failure or checksum mismatch fails the build loudly instead of silently changing output. Fix: pick a newer dated release from https://github.com/BtbN/FFmpeg-Builds/releases, update BOTH the URL and SHA256 for BOTH architectures, and treat it as a real ffmpeg upgrade — verify rendered output afterwards rather than assuming parity.

Film Director pipelines (Cloud) stall at “running” and never resume. A pipeline’s orchestration job can be lost — a re-drive that arrives while the previous drive is still active is deduped away by BullMQ, or a restart lands between drives — leaving the row at status='running' with no worker scheduled. A periodic reconciler can re-drive these automatically. It is off by default; enable it with PIPELINE_RECONCILE_CRON_ENABLED=true on the API service. The reconciler only re-drives pipelines with no pending user action, so manual-mode runs paused at an approval gate are left untouched.

Recast interactive runs stop when the user closes the tab. Recast’s interactive lane — buying each round of image candidates, waiting out a gate’s deadline, dispatching the render — historically ran in the browser, so a paid run went nowhere once every tab was closed. A server-side driver takes it over: a 5-second tick asks the recast plugin which runs owe a step and makes one. It is off by default; enable it with RECAST_DRIVER_CRON_ENABLED=true on the API service, and confirm [recast-driver] started in the boot log.

Off by default deliberately: this cron spends users’ credits with no request from them, so enabling it is a per-environment decision rather than a side effect of deploying. It also needs the recast plugin loaded — on an edition without it the route 404s and the cron disables itself after one logged warning.

If you’re still stuck, file an issue with the Docker logs at https://github.com/nodaroai/app.nodaro.ai/issues.

10. MCP integration (optional)

The MCP (Model Context Protocol) server lets Claude.ai, Cursor, Cline, Continue.dev, Goose, and any MCP-compatible client drive Nodaro tools on a user’s behalf via OAuth. It is gated behind MCP_ENABLED (default false) and lives at the mcp.nodaro.ai/mcp subdomain.

To enable on a hosted instance:

  1. Add a custom subdomain for mcp.<your-domain> pointing at the same backend service. On Railway:
    railway domain add mcp.your-domain.com --service backend
    

    Or in the Railway dashboard: Project → backend service → Settings → Domains → Add custom domain. Add the CNAME at your DNS provider (no Cloudflare proxy — proxies break long-lived SSE connections).

  2. Set env vars on the backend service.
    MCP_ENABLED=true                              # required (default: false)
    MCP_PUBLIC_URL=https://mcp.your-domain.com    # the domain from step 1
    

    MCP_PUBLIC_URL is what the discovery endpoints advertise as the protected-resource identity (RFC 9728) and what upload links point at — without it your instance advertises the Nodaro Cloud MCP host. If you serve MCP from your main domain instead of a subdomain, set it to the same value as PUBLIC_URL.

    Optional overrides (safe defaults you typically don’t need to change):

    MCP_DYNAMIC_REGISTRATION=open                 # default: "allowlist" (recommended)
    MCP_DCR_ALLOWLIST=Claude,Cursor,Cline,Continue,Goose,YourCustomClient
                                                  # default already includes 14 clients: Claude, Claude Code, Cursor,
                                                  # Cline, Continue, Goose, ChatGPT, OpenAI, Lovable, Gemini,
                                                  # Gemini CLI, Codex, MCP Inspector, mcp-inspector
    
  3. Verify discovery endpoints are reachable:
    curl https://mcp.your-domain.com/.well-known/oauth-protected-resource
    curl https://your-domain.com/.well-known/oauth-authorization-server
    

    Both should return JSON with 200 status.

  4. Add the connector in your MCP client. In Claude.ai: Settings → Connectors → Add custom connector → URL https://mcp.your-domain.com/mcp.

The MCP server is fully shipped with 123+ tools across ~20 tool files, covering all generation verbs (image, video, audio, character, location, object), gallery, workflows, apps, saved components, characters, locations, objects, pipelines, models, and more. Authentication is via OAuth (Dynamic Client Registration for supported clients).

11. Cloud-connect for self-hosted instances

Two sides, two variables — see Connect your instance to Nodaro Cloud for the flow itself.

Where Variable Meaning
Your instance (community / business, or a self-hosted copy of a dedicated deployment) NODARO_CLOUD_URL The cloud host this instance relays to — where the Connect nodaro.ai button registers, and where every call made with NODARO_API_KEY goes (relayed generations, uploads, the LLM proxy). Default https://app.nodaro.ai. A self-hosted copy of a dedicated deployment points it at that deployment’s own hosted instance; set https://next.nodaro.ai for a staging soak. Read at boot.
Your instance (community / business, or a self-hosted copy of a dedicated deployment) NODARO_API_KEY nodaro.ai as a provider like KIE or Replicate: a personal API token (ndr_…) minted on the host named by NODARO_CLOUD_URL under Settings → API, billed to that account. The key alone arms the relay — no cloud-side flag and no OAuth registration are needed. What it is: a credential with no scope, no spend cap and no expiry (at most 10 per account; revoke it by deleting or deactivating it on the same page). What it cannot do: money routes are session-only — a key can spend, never allocate allowances, buy credits or administer (payer_balance_jwt_only). On a deployment that names a billing.payerAccount, only the billing account may create one (403 api_tokens_payer_only), and the Settings card is hidden for everyone — the billing account opens /settings/api directly. Alternative to the OAuth connect flow — both light the same nodaro.ai tile on /setup; if both exist the OAuth connection is used (it carries per-instance spend caps and Connected Instances visibility, and needs COMMUNITY_CONNECT_ENABLED on the cloud side).
The cloud (a cloud-edition deployment) COMMUNITY_CONNECT_ENABLED Master switch for accepting self-hosted registrations (software_id: nodaro-community at /v1/oauth/register) and for the account’s Connected Instances page. Default false. Enabled on app.nodaro.ai since 2026-08-16. Requires migration 312 (developer_apps.kind = community_instance). Read at boot — redeploy after changing.

When the cloud has it off, the instance’s POST /v1/nodaro-connect/start answers 503 cloud_connect_unavailable and the setup screen says so in place, pointing at your own provider keys instead.

12. Provider keys: paste on /setup, or set in the environment

Self-host editions take provider keys two ways, and both are live at once:

Way Where it lives Takes effect Who may change it
Paste in the app/setup → Install health (setup time, pre-login) or Integrations → Model providers (in the app); both use PUT /v1/setup/provider-keys/:id provider_credentials table, AES-256-GCM with the instance key; never returned by any route Immediately in the API process. The worker re-reads the store on a poll (~30 s) — and a Run that lands before the poll is not refused: a route that finds no provider re-reads once and re-routes (the router self-heal), so “paste, then Run” works. No restart. Community: any signed-in user (single operator by design). Business: admins. Always a first-party session — never an API/app token.
Environment (KIE_API_KEY, REPLICATE_API_TOKEN, …) .env / the platform’s variables On start Whoever manages the deployment

Precedence: environment wins. A key set in the environment is read-only on the screen — the tile shows set (env) and names the variable to remove. Pasted keys fill in only where the environment is empty.

The full list of provider keys is PROVIDER_KEY_IDS in backend/src/lib/provider-keys-runtime.ts (nodaro.ai, KIE.ai, Replicate, Anthropic, Google Gemini, ElevenLabs, fal.ai, HeyGen, Beeble, Apify) — the tiles, the .env template and GET /v1/setup/status all derive from it. Provider code reads keys per call, so a change is picked up everywhere; tools/check-provider-key-captures.mjs (CI) fails on a construction-time capture that would freeze a key.

Requires the instance encryption key (section 8). Without one, tiles report missing and /setup shows a red Encryption card with the fix.

12a. Sending provider traffic through your own proxy

Two providers let you move the host, not just the key:

Variable Default Moves
KIE_API_BASE_URL https://api.kie.ai Every KIE call — media generation and the KIE-fronted LLM lanes
ELEVENLABS_BASE_URL https://api.elevenlabs.io Every ElevenLabs call — TTS, STT, voices, cloning, dubbing, forced alignment

Leave both unset and nothing changes: the defaults are the vendors’ own hosts, so an install that never touches these makes byte-identical requests to the ones it made before the variables existed.

Set one and Nodaro talks to your host instead. The usual reasons are key custody (the real vendor key lives on the proxy, never in the app’s environment), audit logging of every outbound generation, and regional routing. Your proxy is expected to be transparent — same paths, same request and response bodies — because Nodaro only substitutes the origin. Trailing slashes are stripped, so https://proxy.example.com/kie/ and https://proxy.example.com/kie behave identically.

KIE_API_BASE_URL also reroutes LLM traffic. This is the part that surprises people. KIE is not only a media provider here — the Claude and Gemini lanes that power prompt enhancement, script generation, the workflow copilot and the pipeline stages are served over the same host. Overriding it therefore sends those through your proxy too, which is usually what you want (one audit point for everything) but means the proxy must handle more than the media API: it needs /api/v1/... (task creation and polling — this also covers the /api/v1/chat/credit balance probe), /claude/v1/messages, /<family>/v1/chat/completions, /<family>/v1/responses, and /client/v1/userRecord/... (the per-task credit lookup behind the admin credit audit). A proxy that forwards only the media paths will leave every LLM-backed feature failing while image and video generation keep working — a confusing state worth ruling out first.

Direct-lane keys are unaffected: set ANTHROPIC_API_KEY or GEMINI_API_KEY and those models leave KIE entirely, proxy or no proxy (see “Gemini routing”).

Managed Supabase through the studio origin

For networks that filter every browser request by hostname, the studio can proxy its managed Supabase project. Set SUPABASE_MANAGED_PROXY=true, FRONTEND_SUPABASE_URL=/supabase and SUPABASE_PROXY_UPSTREAM=https://YOUR-PROJECT.supabase.co together. The proxy upstream must be an origin without a path. Keep SUPABASE_URL set to the managed project’s HTTPS URL; backend connections continue to use it directly. The separate upstream variable also keeps bundled deployments’ path-bearing SUPABASE_URL out of Caddy’s upstream parser when this feature is disabled.

The browser resolves /supabase against the page’s origin. Caddy forwards auth, REST, storage and realtime requests, including WebSocket upgrades, to the configured project. Bearer tokens and API keys pass through unchanged; the proxy supplies no privileged credential. Multiple studio domains can use this configuration without another custom domain on Supabase.

Unset, the managed proxy is disabled and the bundled auth and REST routes keep their existing behavior. To test the gateway locally with Caddy installed, run node --test tools/__tests__/managed-supabase-proxy.test.mjs.

See also

CI build preparation

The CI workflow builds shared workspace packages once per run and compiles the backend once for both cloud and community boot probes. Each consumer still installs dependencies with npm ci; only compiled outputs are transferred. The artifact receipt checks the commit, workflow run, lockfile, Node major, operating system, architecture and archive checksum before extraction.

Preparation failures explicitly fail dependent required checks. Frontend and backend tests may still skip when the diff gate confirms they are unrelated; post-merge production CI runs both suites. Coverage, cross-tree parity checks, real database migration proofs and both edition probes retain their assertions. Independent source guards run on lightweight runners.

Build artifacts remain available for seven days. “Re-run failed jobs” reuses successful preparation from the same workflow run. After artifact expiry, choose “Re-run all jobs” to regenerate the outputs. Full reruns replace the artifacts for that run; artifacts are never reused across workflow runs.

A private-repository runner pilot can route the two main test suites to runners labelled self-hosted,linux,x64,nodaro-ci by setting the repository variable CI_RAILWAY_PILOT_BRANCH to a same-repository PR branch. After verifying the pool’s capacity and unattended scheduling, CI_RAILWAY_ENABLED=true enables routing for trusted PRs and post-merge production tests. Public forks and mirrors stay hosted. Clear both variables and rerun to return tests to hosted runners. Docker build contexts exclude local session state, reports and test files; the full CI test and typecheck jobs continue to use the checkout.

The optional disposable Railway runner pool is documented in tools/ci-runner/README.md. Its controller holds the administrative credentials; each runner receives only a single-use job identity and exits after one job. Deploying the pool does not enable CI routing. Railway test runners restore npm downloads from the hosted preparation cache without saving a second copy. Cache misses still use a clean npm ci.