Skip to content

Self-Hosting Guide

Contentrain Studio supports self-hosting of the AGPL core. This guide covers everything you need to deploy and operate Studio in your own environment; managed Pro/Enterprise operation may also be available separately.

Core Principles

  • The application is stateless -- no local writable state beyond normal container runtime behavior
  • Git repositories remain external and authoritative -- Studio never stores content in its database
  • Auth/data are supplied through provider-backed integrations (swappable)
  • Enterprise features degrade safely when ee/ functionality or external services are absent

Prerequisites

Required

ComponentPurpose
Public URLA reachable HTTPS endpoint for the application
One auth + database provider pairEither a Supabase-compatible deployment, or any plain PostgreSQL 14+ instance with the managed auth provider. See Choosing a Provider Pair.
GitHub AppRepository access, installation lifecycle, commit operations
NUXT_SESSION_SECRETMinimum 32 characters, used for AES-256 cookie encryption

Optional

ComponentPurposeWhen Needed
Anthropic API keyOperator-managed AI agentIf users should not need BYOA keys
Resend API keyEmail notifications (invites, reminders); magic-link + invite auth emails on the managed pairFor team invite workflows; required on the managed + postgres pair
Polar or Stripe keysBilling / subscription management (Polar is the default provider; Stripe is legacy)For plan-gated deployments
Cloudflare R2Object storage for CDN and mediaFor CDN delivery and media library
RedisDistributed rate limitingFor multi-instance deployments

Choosing a Provider Pair

Studio's auth and database providers ship as matched pairs, selected by environment variables and enforced at boot. Two pairs are supported; mixing them (e.g. Supabase auth with a plain-Postgres database) is rejected at startup.

PairEnvAuthDatabase
supabase + supabase (default)NUXT_AUTH_PROVIDER=supabase, NUXT_DATABASE_PROVIDER=supabaseSupabase Auth (GitHub/Google OAuth, magic link)Supabase PostgreSQL
managed + postgresNUXT_AUTH_PROVIDER=managed, NUXT_DATABASE_PROVIDER=postgresBuilt-in JWT auth: HS256 access/refresh tokens, GitHub/Google OAuth run in-app, magic link via ResendAny plain PostgreSQL 14+

The managed + postgres pair removes the Supabase dependency entirely -- bring any PostgreSQL (RDS, Railway, a container, your own cluster). Both pairs run the same RLS migration lineage, so row-level isolation is identical.

Supabase pair (default) requires

  • NUXT_SUPABASE_URL, NUXT_SUPABASE_SERVICE_ROLE_KEY, NUXT_SUPABASE_ANON_KEY

Managed + postgres pair requires

  • NUXT_POSTGRES_URL -- the connection string
  • NUXT_AUTH_JWT_SECRET -- minimum 32 characters, signs access/refresh tokens
  • NUXT_RESEND_API_KEY -- magic-link + invite emails (required on this pair)
  • NUXT_OAUTH_GITHUB_CLIENT_ID / NUXT_OAUTH_GITHUB_CLIENT_SECRET -- a GitHub OAuth App for login (callback {NUXT_PUBLIC_SITE_URL}/api/auth/oauth/github); Google OAuth is optional
  • NUXT_SESSION_PASSWORD -- minimum 32 characters, session store for the OAuth module (dev auto-generates it; deployed builds must set it)

INFO

The managed auth provider and the managed deployment profile are unrelated concepts that happen to share a name. Either provider pair can run under any deployment profile (Community or On-Premise EE).

See the Environment Variables reference for the full per-variable detail.

Deployment Shapes

Recommended options:

  • Single container behind a reverse proxy (simplest)
  • Railway / Render / Fly.io Docker deployment
  • VM or bare-metal Docker deployment
  • Kubernetes with one web deployment plus external backing services

Docker Deployment

Official container images are published to GHCR on every v* release tag. For production, pull an exact tag rather than a floating one so upgrades are explicit:

bash
docker pull ghcr.io/contentrain/studio:vX.Y.Z

Available tags per release:

  • :vX.Y.Z (and :vX.Y.Z-prerelease.N) -- exact release pin (recommended for production)
  • :sha-<shortsha> -- immutable commit pin
  • :X.Y, :X, :latest -- floating tags, stable releases only (prereleases never publish :latest)

Deploy a tagged release, not main HEAD, and avoid :latest in production.

Build the Image (Fork / Dev)

For forks, local iteration, or patched builds, build the image yourself:

bash
docker build -t contentrain-studio .

The Docker image contains:

  • Built Nuxt/Nitro server output
  • Generated .contentrain assets for UI strings
  • git binary for repository operations
  • Non-root studio user (security best practice)

Run the Container

bash
docker run \
  --name contentrain-studio \
  --env-file .env \
  -p 3000:3000 \
  contentrain-studio

The container listens on port 3000.

Docker Compose Example

yaml
version: '3.8'

services:
  studio:
    image: contentrain-studio
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    env_file:
      - .env
    environment:
      - NODE_ENV=production
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  # If using self-hosted Supabase:
  # supabase:
  #   image: supabase/postgres:latest
  #   ...

Health Check

The application exposes a health endpoint:

bash
curl http://localhost:3000/api/health
# Returns 200 OK

Editions and Deployment Profiles

Self-hosting behavior is driven by two orthogonal concepts:

  • Edition -- whether the proprietary ee/ directory is present and loaded at runtime. Community Edition runs the AGPL core alone; Enterprise Edition adds ee/ under a separate commercial license.
  • Deployment profile -- a named preset (managed / dedicated / on-premise / community) that configures billing mode and plan source coherently. Set NUXT_DEPLOYMENT_PROFILE explicitly, or leave it unset to auto-detect at boot.

Self-hosting typically means one of two profiles:

ProfileEditionBillingPlan sourceResult
communityAGPL only (ee/ absent)OffFixedEvery workspace resolves to the fixed community tier with unlimited core usage; requires_ee features are force-disabled. Zero-license path.
on-premiseAGPL core + licensed ee/OffOperator-setFull feature set on your own infra; operator sets each workspace's plan (default enterprise) via the DB. Requires an executed On-Premises Deployment License.

Auto-detection when NUXT_DEPLOYMENT_PROFILE is unset: ee/ missing -> community; ee/ loaded + no billing env -> on-premise; ee/ loaded + Polar/Stripe configured -> managed.

Community Edition (community profile)

Use this for the AGPL core without the ee/ proprietary implementations. No agreement with Contentrain required.

Configure:

  • One provider pair (Supabase, or plain PostgreSQL via the managed pair)
  • GitHub App
  • NUXT_ANTHROPIC_API_KEY (operator-provided; the Studio-hosted key is ee/-only)
  • ee/ directory absent; no Polar / Stripe env vars
  • Redis only if running multiple instances

This gives you:

  • Authenticated app, workspace/project management, repository connection
  • Full content operations (all 4 model kinds, all 27 field types)
  • Branch / diff / merge and auto-merge workflows, forms (storage + captcha + notifications)
  • AI chat with the operator-provided Anthropic key
  • Every workspace at the fixed community tier with unlimited usage

Features that require ee/ (media/CDN, BYOA UI, Conversation API, outbound webhooks, reviewer/viewer roles, spam filter, SSO, white-label) are disabled in Community Edition. See Enterprise Edition for the full matrix.

On-Premise Enterprise (on-premise profile)

Use this with an executed On-Premises Deployment License to run the full feature set on your own infrastructure. Billing is off; the operator sets the workspace plan directly.

Additionally configure:

  • ee/ directory present (matching the core release tag)
  • NUXT_DEPLOYMENT_PROFILE=on-premise (optional -- auto-detected from ee/ + empty billing env)
  • Redis (multi-instance), Resend or internal SMTP, Cloudflare R2 (CDN/media)
  • Set the plan per workspace, e.g. UPDATE workspaces SET plan = 'enterprise' WHERE id = '...';

See Enterprise Edition for how editions map to plan tiers and which features require ee/.

Operational Surfaces (either profile)

Delivery and automation surfaces layer on top of either profile:

  • Redis -- distributed rate limiting (multi-instance)
  • Resend (or internal SMTP) -- email notifications
  • Cloudflare R2 -- CDN and media delivery (ee/ feature)
  • Polar or Stripe -- only if you want managed billing on-prem (uncommon; promotes the profile to dedicated with subscription-driven plans)

External Service Setup

Complete the setup steps for your chosen provider pair only.

Database + Auth -- Supabase pair (default)

  1. Create a Supabase project (or self-host Supabase)
  2. Apply all database migrations before first production traffic (see Migrations)
  3. Configure environment variables:
bash
NUXT_SUPABASE_URL=https://your-project.supabase.co
NUXT_SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
NUXT_SUPABASE_ANON_KEY=your-anon-key
  1. Configure OAuth providers in Supabase Dashboard:
    • GitHub OAuth: for workspace owners (needs repo access)
    • Google OAuth: for invited team members
    • Email/Magic Link: for passwordless auth

WARNING

The service role key has admin access to your database. Keep it private and never expose it to the client. Studio rejects an anon key pasted into the service-role slot at boot.

Database + Auth -- managed + postgres pair

Use this to run Supabase-free on any plain PostgreSQL 14+ instance.

  1. Provision a PostgreSQL 14+ database (RDS, Railway, a container, your own cluster)
  2. Create a GitHub OAuth App for login (callback {NUXT_PUBLIC_SITE_URL}/api/auth/oauth/github); optionally a Google OAuth client
  3. Apply migrations against the database (see Migrations)
  4. Configure environment variables:
bash
NUXT_AUTH_PROVIDER=managed
NUXT_DATABASE_PROVIDER=postgres
NUXT_POSTGRES_URL=postgres://user:password@host:5432/contentrain
NUXT_AUTH_JWT_SECRET=your-random-32-character-string
NUXT_SESSION_PASSWORD=your-random-32-character-string
NUXT_OAUTH_GITHUB_CLIENT_ID=your-github-oauth-app-client-id
NUXT_OAUTH_GITHUB_CLIENT_SECRET=your-github-oauth-app-client-secret
# Google OAuth is optional (sign-in button hidden when unset):
# NUXT_OAUTH_GOOGLE_CLIENT_ID=...
# NUXT_OAUTH_GOOGLE_CLIENT_SECRET=...
NUXT_RESEND_API_KEY=re_your-resend-api-key   # required: magic-link + invite emails

WARNING

NUXT_RESEND_API_KEY is required on the managed pair -- the managed auth provider sends magic-link and invite emails through Resend. Rotating NUXT_AUTH_JWT_SECRET invalidates all outstanding tokens (users re-login); NUXT_SESSION_PASSWORD stores no durable state and can be rotated freely.

Migrations

Both pairs run the same RLS migration lineage; apply migrations before first production traffic.

Supabase pair -- uses the Supabase CLI:

bash
pnpm db:migrate            # supabase migration up (reads supabase/migrations/*)

Managed + postgres pair -- uses the bundled plain-PG runner:

bash
pnpm db:migrate:pg         # applies the lineage (reads NUXT_POSTGRES_URL, or pass --url)
pnpm db:verify:pg          # verifies schema, trigger chain, and RLS isolation

Both plain-PG commands are idempotent -- re-runs skip already-applied files via public.schema_migrations.

GitHub App

  1. Create a GitHub App at https://github.com/settings/apps/new
  2. Configure permissions:
    • Repository contents: Read & Write
    • Pull requests: Read & Write
    • Metadata: Read-only
  3. Set the callback URL to {SITE_URL}/auth/callback
  4. Set the webhook URL to {SITE_URL}/api/webhooks/github
  5. Generate a private key (.pem file)
  6. Base64-encode the private key:
bash
base64 -i your-app.private-key.pem | tr -d '\n'
  1. Configure environment variables:
bash
NUXT_GITHUB_APP_ID=your-app-id
NUXT_GITHUB_CLIENT_ID=your-client-id
NUXT_GITHUB_CLIENT_SECRET=your-client-secret
NUXT_GITHUB_PRIVATE_KEY=base64-encoded-pem-content
NUXT_GITHUB_WEBHOOK_SECRET=your-hmac-webhook-secret
NUXT_PUBLIC_GITHUB_APP_SLUG=your-app-slug

Email (Resend)

bash
NUXT_RESEND_API_KEY=re_your-resend-api-key
NUXT_EMAIL_SENDER_ADDRESS=[email protected]
NUXT_EMAIL_SENDER_NAME=Contentrain Studio

If using local Supabase auth with SMTP, also set:

bash
RESEND_API_KEY=re_your-resend-api-key

Billing (Polar / Stripe)

Billing is off by default and is uncommon for self-hosts. Polar is the default payment provider; Stripe is supported as a legacy plugin. When both are configured, Polar wins. Configuring a payment plugin requires the licensed ee/ bridge.

Polar (default):

bash
NUXT_POLAR_ACCESS_TOKEN=polar_oat_your-organization-access-token
NUXT_POLAR_WEBHOOK_SECRET=your-polar-webhook-signing-secret
NUXT_POLAR_STARTER_PRODUCT_ID=uuid-of-starter-product
NUXT_POLAR_PRO_PRODUCT_ID=uuid-of-pro-product
NUXT_POLAR_SERVER=production

Stripe (legacy / optional):

bash
NUXT_STRIPE_SECRET_KEY=sk_live_your-stripe-secret-key
NUXT_STRIPE_WEBHOOK_SECRET=whsec_your-webhook-signing-secret
NUXT_STRIPE_STARTER_PRICE_ID=price_starter-monthly-id
NUXT_STRIPE_PRO_PRICE_ID=price_pro-monthly-id

NUXT_PUBLIC_BILLING_ENABLED is auto-derived at boot from the configured payment plugins; set it explicitly only to override.

When no payment plugin is configured (the usual self-host case), Studio runs in no-billing mode and the subscription UI is hidden. The resulting plan depends on the deployment profile:

  • community profile (ee/ absent) -- every workspace resolves to the fixed community tier: unlimited core usage, with all requires_ee features force-disabled.
  • on-premise profile (ee/ loaded, no billing) -- the operator sets each workspace's plan directly (default enterprise).

CDN / Object Storage (Cloudflare R2)

bash
NUXT_CDN_R2_ACCOUNT_ID=your-cloudflare-account-id
NUXT_CDN_R2_ACCESS_KEY_ID=your-r2-access-key-id
NUXT_CDN_R2_SECRET_ACCESS_KEY=your-r2-secret-access-key
NUXT_CDN_R2_BUCKET=contentrain-cdn

Redis

For multi-instance production deployments:

bash
REDIS_URL=redis://localhost:6379
# Use REDIS_CA_CERT for custom CA trust with rediss://

Without Redis, rate limiting degrades to in-memory and is only suitable for single-instance setups.

Production Checklist

Base

  • [ ] Set NODE_ENV=production
  • [ ] Set NUXT_PUBLIC_SITE_URL to your public application URL
  • [ ] Set a strong NUXT_SESSION_SECRET (minimum 32 characters)
  • [ ] Run behind TLS (reverse proxy or platform load balancer)

Database / Auth

  • [ ] Choose a provider pair via NUXT_AUTH_PROVIDER / NUXT_DATABASE_PROVIDER
  • [ ] Supabase pair: provide all three Supabase variables (URL, SERVICE_ROLE_KEY, ANON_KEY)
  • [ ] Managed pair: provide NUXT_POSTGRES_URL, NUXT_AUTH_JWT_SECRET, NUXT_SESSION_PASSWORD, NUXT_OAUTH_GITHUB_*, and NUXT_RESEND_API_KEY
  • [ ] Apply all database migrations before first production traffic (pnpm db:migrate for Supabase, pnpm db:migrate:pg + pnpm db:verify:pg for the managed pair)
  • [ ] Configure OAuth callback URLs correctly

GitHub

  • [ ] Create and configure GitHub App
  • [ ] Provide all five GitHub variables
  • [ ] Test webhook delivery to /api/webhooks/github

Reverse Proxy

Your reverse proxy should:

  • Forward HTTPS traffic to port 3000
  • Preserve Host and standard forwarding headers (X-Forwarded-For, X-Forwarded-Proto)
  • Set a sane request body limit for media uploads (50MB+)
  • Not cache authenticated application routes
  • Support Server-Sent Events (SSE) for chat streaming

Secret Rotation

For session/encryption secret rotation:

  1. Set the new secret in NUXT_SESSION_SECRET
  2. Keep the old value in NUXT_SESSION_SECRET_PREVIOUS
  3. Deploy the new version
  4. Allow existing sessions to refresh (token auto-refresh handles this)
  5. Remove NUXT_SESSION_SECRET_PREVIOUS after the migration window

Backup Strategy

Studio itself does not require local writable state. Back up these external components:

ComponentStrategy
DatabaseSupabase automated backups or pg_dump
Object StorageR2 bucket versioning / cross-region replication
Git RepositoriesAlready distributed (GitHub)
Deployment SecretsSecure vault (AWS Secrets Manager, Vault, etc.)

Do not treat the Studio container filesystem as durable state.

Post-Deploy Smoke Checks

After first deploy, verify:

  1. /api/health returns 200
  2. Login page loads
  3. OAuth callback URLs are correct
  4. A workspace can be created and listed
  5. GitHub App installation flow completes
  6. A repository can be scanned and connected
  7. At least one chat / content flow works end-to-end
  8. Email, CDN, media, and billing surfaces work (if configured)

Release Validation

Before cutting a release image:

bash
pnpm release:check

This runs lint, typecheck, and build verification.

Released under the AGPL-3.0 License.