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

Open .env and set the required values:

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

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 — 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=

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

2b. Generate internal secrets

Both required, both 32 bytes hex:

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

INTERNAL_ORCHESTRATOR_SECRET authenticates the orchestrator process to the API server within a Nodaro container. SOCIAL_ENCRYPTION_KEY is AES-256-GCM key material used to encrypt social-OAuth tokens at rest.

2c. Apply database migrations

In the Supabase dashboard for your project, 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

Cloudflare R2 example:

  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.

For MinIO or AWS S3, use the same env vars — the SDK is S3-compatible. Set R2_PUBLIC_URL to the bucket’s public URL.

2e. Start the stack

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

First boot takes 5–10 minutes (Node deps, Remotion bundling, frontend build). Subsequent boots are seconds. 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

  1. 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 on next request — Nodaro caches admin status for 30 seconds.

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
Markup on AI provider cost 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

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.

6. Updating

Pull, rebuild, restart:

git pull
docker compose -f docker-compose.community.yml down
docker compose -f docker-compose.community.yml build
docker compose -f docker-compose.community.yml up

If new files appear under supabase/migrations/, apply them in filename order before restarting (same flow as §2c). The backend won’t crash on a missing migration, but specific routes will 500 until their schema lands.

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.

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.

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

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

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

“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.

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, double-check VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY were set at Docker build time (Vite inlines them into the bundle — runtime env vars don’t help).

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.

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.

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).

See also