diff --git a/.claude/rules/sim-url-state.md b/.claude/rules/sim-url-state.md index 1c45eae5416..25034257a33 100644 --- a/.claude/rules/sim-url-state.md +++ b/.claude/rules/sim-url-state.md @@ -179,7 +179,7 @@ const { sort, dir, activeSort, onSort, onClear } = useUrlSort(thingsSortParams, Two modes, chosen by whether you pass a default: - **Defaulted (the common case)** — pass the list's existing default sort; it must match exactly. A clean URL means the default ordering; explicitly selecting the default collapses back to a clean URL (`clearOnDefault`), and "clear sort" writes the defaults back. `useUrlSort` derives `activeSort: null` for the default state. -- **Nullable** — omit the default when "no active sort" is behaviorally distinct from explicitly sorting by the fallback column (e.g. files: with no sort, files order by updated/desc but folders by name/asc). The params carry no defaults, explicit selections always persist in the URL, and "clear sort" strips both params (`useUrlSort` writes `null`s). +- **Nullable** — omit the default when "no active sort" is behaviorally distinct from explicitly sorting by the fallback column (e.g. document chunks: with no sort the query omits `sortBy` entirely and the server's own order applies). The params carry no defaults, explicit selections always persist in the URL, and "clear sort" strips both params (`useUrlSort` writes `null`s). Sort params live alongside — not inside — the feature's grouped filter parser map (one definition per param; `useUrlSort` owns its own `useQueryStates`, and nuqs keeps hooks on the same keys in sync). Both params carry the shared filter options (`{ history: 'replace', clearOnDefault: true }`). Free-form user-defined columns (e.g. `tables/[tableId]`) can't use `parseAsStringLiteral` and stay hand-rolled with `parseAsString` — reuse the shared `SORT_DIRECTIONS` there. diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index a41a49e628b..988256441c9 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -165,6 +165,15 @@ After running this command, open [http://localhost:3000/](http://localhost:3000/ git clone https://github.com//sim.git cd sim +# Generate the required secrets. The stack refuses to start without them +# rather than booting with empty values. +cat > .env << EOF +BETTER_AUTH_SECRET=$(openssl rand -hex 32) +ENCRYPTION_KEY=$(openssl rand -hex 32) +INTERNAL_API_SECRET=$(openssl rand -hex 32) +CRON_SECRET=$(openssl rand -hex 32) +EOF + # Start Sim docker compose -f docker-compose.prod.yml up -d ``` diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 00000000000..b8e56f708d9 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,33 @@ +name: Sim CodeQL config + +# Trims the extraction surface. CodeQL parses every matching file into a +# database before a single query runs, and that phase dominates runtime on a +# ~12.7k-file JS/TS tree. Test and fixture code is not attacker-reachable, so +# excluding it costs no real coverage. +# +# paths-ignore applies to analysis. The workflow's `on.pull_request.paths` +# filter is separate and decides whether the run happens at all. +paths-ignore: + - '**/*.test.ts' + - '**/*.test.tsx' + - '**/*.test.js' + - '**/*.spec.ts' + - '**/*.spec.tsx' + - '**/__tests__/**' + - '**/__mocks__/**' + - '**/__fixtures__/**' + - '**/e2e/**' + # Deliberately no '**/test/**' or '**/tests/**'. A directory named `test` is a + # routable Next.js path segment, not necessarily test code: those globs + # excluded the real endpoint + # apps/sim/app/api/organizations/[id]/data-drains/[drainId]/test/route.ts, + # which authorizes, decrypts destination credentials, and makes an outbound + # request. CodeQL's paths-ignore has no `!` negation to carve it back out + # ("The filter pattern characters ?, +, [, ], and ! are not supported and will + # be matched literally"), and the globs only covered 76 of 12,716 files, so + # the naming convention above is the safer filter. + - '**/*.d.ts' + - '**/node_modules/**' + - '**/dist/**' + - '**/.next/**' + - 'apps/docs/content/**' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e91c1026469..42faa142a79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -294,6 +294,12 @@ jobs: ecr_repo_secret: ECR_PII gh_runner: ubuntu-latest bs_runner: blacksmith-4vcpu-ubuntu-2404 + # No ECR repo is provisioned for cron, so it publishes to GHCR only. + # The tag step below omits the ECR tag when the repo name is empty. + - dockerfile: ./docker/cron.Dockerfile + ghcr_image: ghcr.io/simstudioai/cron + gh_runner: ubuntu-latest + bs_runner: blacksmith-2vcpu-ubuntu-2404 steps: - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -338,15 +344,33 @@ jobs: ECR_REPO="${{ steps.ecr-repo.outputs.name }}" GHCR_IMAGE="${{ matrix.ghcr_image }}" - TAGS="${ECR_REGISTRY}/${ECR_REPO}:${{ github.sha }}" + TAGS="" + if [ -n "$ECR_REPO" ]; then + TAGS="${ECR_REGISTRY}/${ECR_REPO}:${{ github.sha }}" + fi if [ "${{ github.ref }}" = "refs/heads/main" ] && [ -n "$GHCR_IMAGE" ]; then - TAGS="${TAGS},${GHCR_IMAGE}:${{ github.sha }}-amd64" + if [ -n "$TAGS" ]; then + TAGS="${TAGS},${GHCR_IMAGE}:${{ github.sha }}-amd64" + else + TAGS="${GHCR_IMAGE}:${{ github.sha }}-amd64" + fi + fi + + # An entry can legitimately resolve to no tags — e.g. the cron image has + # no ECR repo, so on staging/dev (where GHCR tags are not applied) there + # is nothing to push. Skip that build instead of failing the job. + if [ -z "$TAGS" ]; then + echo "No ECR repo and no GHCR tag for this entry on ${{ github.ref }} — skipping push." + echo "skip=true" >> $GITHUB_OUTPUT + else + echo "skip=false" >> $GITHUB_OUTPUT fi echo "tags=${TAGS}" >> $GITHUB_OUTPUT - name: Build and push images + if: steps.meta.outputs.skip != 'true' uses: ./.github/actions/docker-build with: provider: ${{ vars.CI_PROVIDER }} @@ -470,6 +494,10 @@ jobs: image: ghcr.io/simstudioai/pii gh_runner: ubuntu-24.04-arm bs_runner: blacksmith-4vcpu-ubuntu-2404-arm + - dockerfile: ./docker/cron.Dockerfile + image: ghcr.io/simstudioai/cron + gh_runner: ubuntu-24.04-arm + bs_runner: blacksmith-4vcpu-ubuntu-2404-arm steps: - name: Checkout code @@ -515,6 +543,7 @@ jobs: - image: ghcr.io/simstudioai/migrations - image: ghcr.io/simstudioai/realtime - image: ghcr.io/simstudioai/pii + - image: ghcr.io/simstudioai/cron steps: - name: Login to GHCR diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000000..51b709d5330 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,94 @@ +name: CodeQL + +# Advanced setup, replacing the repo-settings "default setup". +# +# Default setup pinned every scan to a 4-vCPU GitHub-hosted runner with no +# cancel-in-progress, which put PR scans at 30-125 min and re-ran them on every +# push (PR #6183 burned six overlapping runs). None of that is configurable from +# the settings UI, so the config moves into the repo. +# +# Before enabling this, disable default setup or the two will both run: +# gh api -X PATCH repos/:owner/:repo/code-scanning/default-setup -f state=not-configured +# +# The runs-on expression is the same CI_PROVIDER escape hatch as ci.yml and must +# change together with it. + +on: + # Pushes to main are infrequent (merges only), so a full scan per push is + # affordable and is what GitHub recommends pairing with the PR trigger: + # "Scanning code when someone pushes a change, and whenever a pull request is + # created, prevents developers from introducing new vulnerabilities." + push: + branches: [main] + pull_request: + branches: [main, staging] + # `ready_for_review` is not a default activity type, so it has to be listed + # alongside the defaults it replaces. Without it, a PR opened as a draft and + # then marked ready is skipped by the job-level draft guard and never + # rescanned until the next push. + types: [opened, synchronize, reopened, ready_for_review] + paths: + - '**/*.ts' + - '**/*.tsx' + - '**/*.js' + - '**/*.jsx' + - '**/*.mjs' + - '**/*.cjs' + - '.github/workflows/**' + - '.github/actions/**' + - '.github/codeql/**' + schedule: + # Safety net behind the push trigger, and the thing that keeps the + # default-branch alert view fresh when main is quiet. Only fires once this + # file is on the default branch — schedule events ignore other branches. + - cron: '17 8 * * *' + workflow_dispatch: + +# Scheduled main scans must run to completion — only PR pushes supersede. +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + analyze: + name: Analyze ${{ matrix.language }} + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }} + timeout-minutes: 60 + if: github.event.pull_request.draft != true + permissions: + security-events: write + contents: read + actions: read + + strategy: + fail-fast: false + matrix: + # One entry covers both JS and TS — `javascript`, `typescript` and + # `javascript-typescript` all resolve to the same extractor + # (github/codeql-action src/languages/builtin.json), so the three + # entries default setup listed were one analysis, not three. + # `javascript-typescript` is the documented spelling. Python dropped: + # 7 files in the tree. + language: [javascript-typescript, actions] + + steps: + - name: Checkout repository + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@18420e3271f74589575af831a523c833acda327f # codeql-bundle-v2.26.2 + with: + languages: ${{ matrix.language }} + config-file: ./.github/codeql/codeql-config.yml + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@18420e3271f74589575af831a523c833acda327f # codeql-bundle-v2.26.2 + env: + NODE_OPTIONS: --max-old-space-size=8192 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml index 11944217283..ea9ac3f5221 100644 --- a/.github/workflows/helm.yml +++ b/.github/workflows/helm.yml @@ -32,6 +32,17 @@ jobs: with: version: v3.16.4 + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.13 + + # Docker Compose and Kubernetes must run the same background jobs on the + # same schedules; this fails the build if the two drift apart. The script + # imports only node builtins, so this job installs no dependencies. + - name: Scheduler parity (docker/crontab vs helm cronjobs) + run: bun run scripts/check-cron-parity.ts + - name: Helm lint run: helm lint helm/sim --values helm/sim/ci/default-values.yaml diff --git a/README.md b/README.md index 7f51bd8f7ac..1586cbd165a 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Ask DeepWiki - Set Up with Cursor + Set Up with Cursor

diff --git a/apps/docs/content/docs/en/platform/self-hosting/architecture.mdx b/apps/docs/content/docs/en/platform/self-hosting/architecture.mdx new file mode 100644 index 00000000000..af526cb8465 --- /dev/null +++ b/apps/docs/content/docs/en/platform/self-hosting/architecture.mdx @@ -0,0 +1,112 @@ +--- +title: Architecture +description: Every service Sim runs, what it depends on, and where state lives +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { FAQ } from '@/components/ui/faq' + +Understanding what runs where makes every other operational decision — scaling, backup, network policy, upgrades — straightforward. + +## Services + +| Service | Image | Port | Stateless | Required | +|---|---|---|---|---| +| **app** | `ghcr.io/simstudioai/simstudio` | 3000 | Only with object storage configured | Yes | +| **realtime** | `ghcr.io/simstudioai/realtime` | 3002 | Yes | Yes | +| **migrations** | `ghcr.io/simstudioai/migrations` | — | Yes (runs once) | Yes | +| **postgresql** | `pgvector/pgvector:pg17` | 5432 | **No** | Yes | +| **redis** | `redis:7-alpine` | 6379 | Mostly | Bundled by both; swap for a managed instance in production | +| **cron** | `ghcr.io/simstudioai/cron` (Compose) / `curlimages/curl` (CronJobs) | — | Yes | Yes | +| **pii** | `ghcr.io/simstudioai/pii` | 5001 | Yes | Optional | +| **ollama** | `ollama/ollama` | 11434 | **No** (model cache) | Optional | +| **telemetry** | `otel/opentelemetry-collector-contrib` | 4317/4318 | Yes | Optional | + +### app + +The Next.js application: the editor UI, every API route, and the workflow execution engine. Workflow runs happen **inside the app process** by default, using an isolated-vm sandbox, which is why memory rather than CPU is the constraining resource. Both the chart and the compose file request 4 Gi and cap the app at 8 Gi. Configuring a remote sandbox provider (E2B or Daytona) moves code execution out of the process; see [Security](/platform/self-hosting/security). + +Any replica can serve any request **once object storage is configured**. Until then the app writes uploads to its own container filesystem, which makes it stateful — see [Where state lives](#where-state-lives). Scale it horizontally only after reading [Scaling & HA](/platform/self-hosting/scaling) for the Redis, storage, and connection-pool prerequisites. + +### realtime + +A Bun Socket.IO server handling collaborative editing, live execution updates, and collaborative documents. Clients connect at `/socket.io`. + +It shares the database and `BETTER_AUTH_SECRET` with the app (Better Auth's shared-database-session pattern), so it authenticates the same users without a separate login. + + + Scaling realtime past one replica **requires** `REDIS_URL` — the Socket.IO Redis adapter is what carries events between pods. Without it, two users on different pods silently stop seeing each other's edits. + + +### migrations + +Applies Drizzle schema migrations, then exits. In Docker Compose it is a one-shot service; in Kubernetes it is an **init container on the app Deployment**, so migrations run before any app pod becomes ready and re-run (as a no-op) on every rollout. + +Migrations are forward-only. See [Upgrades](/platform/self-hosting/upgrades). + +### postgresql + +PostgreSQL 17 with the **pgvector** extension, which is required — knowledge base embeddings are stored and searched as vectors. The `pgvector/pgvector:pg17` image ships it; a managed instance needs the extension enabled (Sim's migrations issue `CREATE EXTENSION` automatically where permissions allow). + +This holds essentially all durable state: workflows, runs, logs, users, organizations, credentials, knowledge base chunks and embeddings, and table data. + +### redis + +Backs pub/sub, the Socket.IO adapter, the idempotency store, execution progress markers, distributed execution limits, and the CLI-auth approval store. The storage-like uses fall back to Postgres or in-process state. Pub/sub falls back to a **process-local** emitter, which is fine on one replica and drops every cross-pod event on more than one. See [Redis](/platform/self-hosting/redis). + +### cron + +Eighteen scheduled jobs that call internal endpoints — schedule execution, polling triggers, webhook-subscription renewal, connector syncs, outbox processing, data drains, and sandbox-image cleanup. Kubernetes runs them as CronJobs; Docker Compose runs them from a single supercronic service. Same paths, same schedules. See [Background Jobs](/platform/self-hosting/background-jobs). + +## Where state lives + +Three places once the deployment is configured for production. Everything else is disposable. + +| Store | Contents | Backup | +|---|---|---| +| **PostgreSQL** | All application data | `pg_dump` / managed snapshots + PITR | +| **Object storage** | Uploaded files, KB documents, execution outputs, avatars, logos | Bucket versioning + lifecycle | +| **Secrets** | `ENCRYPTION_KEY`, `API_ENCRYPTION_KEY`, `BETTER_AUTH_SECRET`, `INTERNAL_API_SECRET`, `CRON_SECRET` | Secret manager | + + + **Object storage is not configured by default, and the fallback is not durable.** Sim only uses S3, Azure Blob, or GCS when the corresponding variables are set (`S3_BUCKET_NAME` + `AWS_REGION`, `AZURE_STORAGE_CONTAINER_NAME` + credentials, or `GCS_BUCKET_NAME`). With none set it writes uploads to a directory inside the app container — and neither `docker-compose.prod.yml` nor the Helm chart mounts a volume there. Files are lost when the container is recreated and are invisible to other replicas. Configure [object storage](/platform/self-hosting/object-storage) before storing anything you care about, and before scaling past one replica. + + + + `ENCRYPTION_KEY` is not recoverable and not derivable. It encrypts workspace and personal environment variables, stored provider API keys, MCP OAuth credentials, and deployment/chat secrets at rest — a database restore paired with a *different* key yields a working app in which none of that can be decrypted. Back it up separately from the database, and never rotate it casually. + + +Redis is a cache and message bus. Losing it drops in-flight live updates; it does not lose committed data. + +## Request paths + +**Editor / API** — browser → ingress/reverse proxy → app:3000 → Postgres, Redis, object storage. + +**Collaboration** — browser → ingress → realtime:3002 (`/socket.io`, WebSocket upgrade) → Redis pub/sub → other realtime pods. The proxy must pass upgrade headers and allow long-lived idle connections; see [Networking](/platform/self-hosting/networking). + +**File upload (object storage configured)** — browser asks app for a presigned URL → browser `PUT`s **directly to object storage** → app records metadata. This is why buckets need a CORS policy naming your Sim origin. Downloads are proxied back through the app. + +**File upload (local disk)** — the presigned endpoint reports `directUploadSupported: false` and the browser uploads through the app instead. No CORS configuration is involved, and no bucket is used. + +**Workflow execution** — trigger (manual, API, webhook, or schedule) → app enqueues or runs inline → isolated-vm sandbox → results and logs to Postgres, progress markers to Redis. + +**Background work** — CronJob → `Authorization: Bearer $CRON_SECRET` → app endpoint → same execution path. + +## Network boundaries + +| From | To | Purpose | +|---|---|---| +| Internet | app:3000, realtime:3002 | Users | +| app, realtime | postgresql:5432 | Data | +| app, realtime | redis:6379 | Pub/sub, cache | +| app | Object storage endpoint | Files (server side) | +| **Browser** | **Object storage endpoint** | Presigned uploads — must be publicly reachable | +| app | Model provider APIs, integration APIs, SMTP/email provider | Outbound | +| cron | app:3000 (internal Service / compose network) | Scheduled triggers | + +The chart's optional NetworkPolicy blocks cloud metadata endpoints (`169.254.169.254`) by default but allows ingress from any pod in the cluster unless you scope `networkPolicy.ingressFrom`. See [Security](/platform/self-hosting/security). + + diff --git a/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx b/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx new file mode 100644 index 00000000000..15bcd34c7f7 --- /dev/null +++ b/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx @@ -0,0 +1,147 @@ +--- +title: Authentication +description: Login providers, SSO, and controlling who can sign up +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { FAQ } from '@/components/ui/faq' + +## Required configuration + +```bash +BETTER_AUTH_SECRET= +BETTER_AUTH_URL=https://sim.yourdomain.com +NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com +``` + + + `BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` must be your exact public origin — correct scheme, no trailing slash. Leaving either as `localhost` in a deployed instance breaks sign-in, and the failure looks like a redirect loop rather than a configuration error. + + `BETTER_AUTH_SECRET` must be **identical** on the app and realtime services. They share sessions through the database; a mismatch means realtime rejects every authenticated socket connection. + + +If users reach Sim from more than one origin — an apex and `www`, or an alias domain — list the extras: + +```bash +TRUSTED_ORIGINS=https://www.example.com,https://app.example.com +``` + +## Email and password + +Enabled by default. Users sign up with an email address and password. + +```bash +EMAIL_VERIFICATION_ENABLED=true +``` + +Requires a configured email provider — see [Email](/platform/self-hosting/email). Without one the mailer no-ops silently, so users can never verify and never sign in. Do not enable this before email works. + +## Social login + +Three providers are supported for signing in to Sim itself. + +| Provider | Variables | Callback URL | +|---|---|---| +| Google | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | `https:///api/auth/callback/google` | +| GitHub | `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | `https:///api/auth/callback/github` | +| Microsoft | `MICROSOFT_CLIENT_ID` / `MICROSOFT_CLIENT_SECRET` | `https:///api/auth/callback/microsoft` | + +A provider appears on the login page once its credentials are set. Microsoft additionally requires both variables to be present before it is registered at all. + +Turn one off without removing its credentials — useful when the same Google or Microsoft app powers integrations but you do not want it as a login method: + +```bash +DISABLE_GOOGLE_AUTH=true +DISABLE_GITHUB_AUTH=true +DISABLE_MICROSOFT_AUTH=true +``` + + + `GOOGLE_CLIENT_ID` and `MICROSOFT_CLIENT_ID` are shared with the integration connectors. One app registration can serve both login and integrations — just register both sets of redirect URIs. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). + + +## SSO (SAML and OIDC) + +SAML and OIDC single sign-on is an enterprise feature, available on self-hosted deployments through configuration rather than billing: + +```bash +ENTERPRISE_ENABLED=true +NEXT_PUBLIC_ENTERPRISE_ENABLED=true +``` + +Or enable just SSO: + +```bash +SSO_ENABLED=true +NEXT_PUBLIC_SSO_ENABLED=true +``` + +Providers are then registered in the app under **Settings → Enterprise → Single Sign-On**. A provider can be scoped to an organization or registered without one. Most other enterprise features do read their settings from the organization that owns a workspace, so a deployment using them still needs an organization model — set `INSTANCE_ORG_NAME` to place every user in one shared organization, or provision organizations through the Admin API. + +See the [SSO guide](/platform/enterprise/sso) for identity-provider setup and the [self-hosted enterprise guide](/platform/enterprise/self-hosted) for the organization patterns. + +## Controlling who can sign up + +| Variable | Effect | +|---|---| +| `DISABLE_REGISTRATION=true` | Blocks email/password registration | +| `DISABLE_EMAIL_SIGNUP=true` | Blocks new email/password registrations; existing email login keeps working | +| `ALLOWED_LOGIN_DOMAINS` | Comma-separated domain allowlist, e.g. `acme.com,acme.co.uk`. Gates email sign-**in** as well as signup | +| `ALLOWED_LOGIN_EMAILS` | Comma-separated address allowlist, applied the same way | +| `BLOCKED_SIGNUP_DOMAINS` | Comma-separated domain blocklist | +| `SIGNUP_MX_VALIDATION_ENABLED=true` | Reject domains with no MX record or a denylisted mail backend | +| `BLOCKED_EMAIL_MX_HOSTS` | MX-host substrings to block; used only with the above | + + + These controls gate the **email/password** path. A first-time sign-in through Google, GitHub, or Microsoft creates an account through the social provider and is not filtered by them. If you need a hard boundary, disable the social providers you have not vetted (`DISABLE_GOOGLE_AUTH`, `DISABLE_GITHUB_AUTH`, `DISABLE_MICROSOFT_AUTH`) or restrict membership at the identity provider and use SSO. + + +For a company deployment, the usual pairing is domain-restricted signup plus SSO: + +```bash +ALLOWED_LOGIN_DOMAINS=acme.com +DISABLE_EMAIL_SIGNUP=true +SSO_ENABLED=true +NEXT_PUBLIC_SSO_ENABLED=true +``` + +Both SSO flags are needed: the server-side one grants access, and the `NEXT_PUBLIC_` one makes the login page render the SSO entry point. + +## Behind a load balancer + +Tell Better Auth which forwarding hops to trust when resolving the client IP: + +```bash +AUTH_TRUSTED_PROXIES=10.0.0.0/24,192.0.2.10 +``` + +Better Auth walks `X-Forwarded-For` right to left, skips these hops, and uses the first untrusted address as the client IP for session records and its own IP-based checks. Use your proxies' actual addresses — a broad private range that also covers client traffic defeats the purpose. See [Security](/platform/self-hosting/security). + +## Disabling authentication entirely + +```bash +DISABLE_AUTH=true +``` + +Bypasses authentication and creates an anonymous session for every request. + + + Everyone who can reach the instance becomes a fully privileged user — including anything that can reach it through an SSRF bug elsewhere on your network. Use this only for a single-user instance on a private network, never behind an internet-facing ingress. + + +## Other controls + +| Variable | Effect | +|---|---| +| `DISABLE_INVITATIONS=true` / `NEXT_PUBLIC_DISABLE_INVITATIONS=true` | Disable workspace invitations globally | +| `DISABLE_PUBLIC_API=true` / `NEXT_PUBLIC_DISABLE_PUBLIC_API=true` | Disable the public API globally | +| `ADMIN_API_KEY` | Enables the Admin API for GitOps operations and organization provisioning | + +The `NEXT_PUBLIC_` twin controls what the UI shows; the server-side variable enforces it. Set both. + + diff --git a/apps/docs/content/docs/en/platform/self-hosting/background-jobs.mdx b/apps/docs/content/docs/en/platform/self-hosting/background-jobs.mdx new file mode 100644 index 00000000000..f07088d6f51 --- /dev/null +++ b/apps/docs/content/docs/en/platform/self-hosting/background-jobs.mdx @@ -0,0 +1,149 @@ +--- +title: Background Jobs +description: Scheduled workflows, polling triggers, and the cron endpoints that drive them +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { Callout } from 'fumadocs-ui/components/callout' +import { FAQ } from '@/components/ui/faq' + +A large part of Sim runs on a schedule rather than in response to a user request: scheduled workflows, every polling trigger, connector syncs, the outbox, data drains, and retention. All of it is driven by **HTTP endpoints that something external must call on a timer**. + +Both deployments ship a scheduler and enable it by default: Kubernetes as CronJobs, Docker Compose as a `cron` service. Both authenticate with `CRON_SECRET`, and both use the same schedules. + +## Authentication + +Every endpoint is protected by `CRON_SECRET` and expects it as a bearer token: + +```bash +curl -f -s -S --max-time 60 \ + -H "Authorization: Bearer $CRON_SECRET" \ + https://sim.yourdomain.com/api/schedules/execute +``` + +Generate it like the other secrets: + +```bash +openssl rand -hex 32 +``` + + + `CRON_SECRET` is **required** whenever background jobs are enabled — which is the Helm chart's default. The chart refuses to render without it. If it is unset, the endpoints reject every call and all scheduled work silently stops. + + +Point cron at an **internal** address where possible (the in-cluster Service, or `localhost` on a single node). These endpoints should not be reachable from the internet; if they are, `CRON_SECRET` is the only thing protecting them. + +## The jobs + +| Job | Endpoint | Schedule | Drives | +|---|---|---|---| +| Schedule execution | `/api/schedules/execute` | `*/1 * * * *` | **Scheduled workflows** | +| Gmail poll | `/api/webhooks/poll/gmail` | `*/1 * * * *` | Gmail trigger | +| Outlook poll | `/api/webhooks/poll/outlook` | `*/1 * * * *` | Outlook trigger | +| IMAP poll | `/api/webhooks/poll/imap` | `*/1 * * * *` | IMAP trigger | +| RSS poll | `/api/webhooks/poll/rss` | `*/1 * * * *` | RSS trigger | +| Google Sheets poll | `/api/webhooks/poll/google-sheets` | `*/1 * * * *` | Sheets trigger | +| Google Drive poll | `/api/webhooks/poll/google-drive` | `*/1 * * * *` | Drive trigger | +| Google Calendar poll | `/api/webhooks/poll/google-calendar` | `*/1 * * * *` | Calendar trigger | +| HubSpot poll | `/api/webhooks/poll/hubspot` | `*/1 * * * *` | HubSpot trigger | +| Time pause/resume | `/api/resume/poll` | `*/1 * * * *` | Workflows paused on a timer | +| Outbox processing | `/api/webhooks/outbox/process` | `*/1 * * * *` | Transactional-outbox retries for billing, membership, enterprise issuance, and workflow-deployment side effects | +| Connector sync | `/api/knowledge/connectors/sync` | `*/5 * * * *` | Knowledge base connector syncs | +| Workspace events poll | `/api/workspace-events/poll` | `*/15 * * * *` | Workspace event triggers | +| Data drains | `/api/cron/run-data-drains` | `0 * * * *` | Enterprise data drains | +| Renew subscriptions | `/api/cron/renew-subscriptions` | `0 */12 * * *` | Renews Microsoft Teams chat subscriptions (Graph caps them at ~3 days) | +| Reconcile billing seats | `/api/cron/reconcile-billing-seats` | `0 * * * *` | Billing only — safe to disable when self-hosted | +| Reconcile inbox entitlement | `/api/cron/reconcile-inbox-entitlement` | `0 3 * * *` | Inbox access reconciliation | +| Cleanup sandbox images | `/api/cron/cleanup-sandbox-images` | `30 4 * * *` | Reclaims sandbox images | + + + **Subscription renewal** covers Microsoft Teams chat triggers, whose Microsoft Graph subscriptions are hard-capped at about three days. Without it, Teams triggers work for a couple of days and then quietly stop. Gmail, Outlook, Drive, Calendar, and Sheets triggers are **polled** instead — they depend on the per-minute poll jobs above, not on this one. + + +## Kubernetes + +Enabled by default. Nothing to do beyond setting `CRON_SECRET`. + +```yaml +cronjobs: + enabled: true +``` + +Each job runs a small `curlimages/curl` pod that calls the app's **in-cluster Service** (not the ingress), with `concurrencyPolicy: Forbid` so a slow run never overlaps itself, and up to three retries. + +Disable individual jobs you do not need — billing reconciliation is the obvious one on a self-hosted install: + +```yaml +cronjobs: + jobs: + reconcileBillingSeats: + enabled: false +``` + +Check they are running: + +```bash +kubectl get cronjobs -n simstudio +kubectl get jobs -n simstudio --sort-by=.metadata.creationTimestamp | tail +kubectl logs -n simstudio job/ +``` + +A CronJob whose `LAST SCHEDULE` is stale, or whose jobs are failing, means the corresponding feature is dead. Alert on it — see [Observability](/platform/self-hosting/observability). + +## Docker Compose + +The `cron` service runs the same jobs on the same schedules, so nothing to configure beyond `CRON_SECRET`: + +```bash +CRON_SECRET=$(openssl rand -hex 32) +``` + +Without it the `cron` service logs exactly what to set — including a freshly generated value — and exits, leaving the rest of the stack running. Schedules live in `docker/crontab` and mirror `helm/sim/values.yaml` `cronjobs.jobs` one-for-one. + + + Upgrading a deployment created before the scheduler existed? Your `.env` has no `CRON_SECRET`, so the stack comes up as before and `cron` exits with instructions. Add the value and re-run `up -d` to turn background jobs on. + + +The service runs [supercronic](https://github.com/aptible/supercronic) rather than the app image: it logs each job's output to the container log, forwards `SIGTERM` so `docker compose stop` is graceful, and will not start an iteration while the previous one is still running. + +```bash +docker compose -f docker-compose.prod.yml logs -f cron +``` + +A healthy log line looks like: + +``` +level=info msg=starting iteration=0 job.schedule="*/1 * * * *" +level=info msg="job succeeded" iteration=0 +``` + +To drop a job you do not need, comment out its line in `docker/crontab` and restart the service. + +## Verifying + +Create a workflow with a Schedule trigger set to every minute, deploy it, and watch the Logs view. An execution should appear within ~2 minutes. If nothing appears: + +1. Check the scheduler's own logs — `docker compose logs cron`, or `kubectl get cronjobs -n simstudio` for a recent `LAST SCHEDULE`. +2. Confirm the app and the scheduler share the same `CRON_SECRET`. A mismatch shows up as `401` in the scheduler log. +3. A `202` means the endpoint accepted the run; it does not confirm a schedule was due, so check the Logs view. + +## Concurrency + +Scheduled execution volume is bounded per app instance by: + +| Variable | Default | Applies to | +|---|---|---| +| `SCHEDULE_EXECUTION_CONCURRENCY_LIMIT` | `30` | Scheduled workflows in flight, on every install | + + + `WORKFLOW_EXECUTION_CONCURRENCY_LIMIT`, `WEBHOOK_EXECUTION_CONCURRENCY_LIMIT`, and `RESUME_EXECUTION_CONCURRENCY_LIMIT` are `concurrencyLimit` settings on Trigger.dev task definitions. They have **no effect** unless `TRIGGER_DEV_ENABLED` is set, and neither the Helm chart nor Docker Compose configures Trigger.dev — so on a default self-host they are inert. + + +Raise the schedule limit only alongside memory headroom: concurrent executions run in the app process, so throughput is bounded by the pod's memory before it is bounded by this number. + +.enabled: false in Helm, or omit the crontab line. Billing-seat reconciliation is the usual candidate on a self-hosted install. Do not disable schedule execution, outbox processing, or subscription renewal unless you know you do not use the corresponding features."}, + { question: "Do the jobs run against every replica?", answer: "No. Each CronJob makes one HTTP call to the app Service, which load-balances to a single replica. concurrencyPolicy: Forbid prevents a slow run from overlapping the next tick." }, +]} /> diff --git a/apps/docs/content/docs/en/platform/self-hosting/docker.mdx b/apps/docs/content/docs/en/platform/self-hosting/docker.mdx index 2ec82d0ccd9..640a18d6448 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/docker.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/docker.mdx @@ -10,8 +10,15 @@ import { FAQ } from '@/components/ui/faq' ## Quick Start ```bash -# Clone and start git clone https://github.com/simstudioai/sim.git && cd sim + +cat > .env << EOF +BETTER_AUTH_SECRET=$(openssl rand -hex 32) +ENCRYPTION_KEY=$(openssl rand -hex 32) +INTERNAL_API_SECRET=$(openssl rand -hex 32) +CRON_SECRET=$(openssl rand -hex 32) +EOF + docker compose -f docker-compose.prod.yml up -d ``` @@ -22,85 +29,81 @@ Open [http://localhost:3000](http://localhost:3000) ### 1. Configure Environment ```bash -# Generate secrets cat > .env << EOF -DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio BETTER_AUTH_SECRET=$(openssl rand -hex 32) ENCRYPTION_KEY=$(openssl rand -hex 32) INTERNAL_API_SECRET=$(openssl rand -hex 32) +API_ENCRYPTION_KEY=$(openssl rand -hex 32) +CRON_SECRET=$(openssl rand -hex 32) + +# Your public origin. BETTER_AUTH_URL is derived from this automatically. NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com -BETTER_AUTH_URL=https://sim.yourdomain.com + +# Database credentials. DATABASE_URL is composed from these by the compose file. +POSTGRES_USER=postgres +POSTGRES_PASSWORD=$(openssl rand -hex 24) +POSTGRES_DB=simstudio EOF ``` + + Do not set `DATABASE_URL` or `BETTER_AUTH_URL` in `.env` — `docker-compose.prod.yml` composes both on the service definition, and a value set here is ignored. Change `POSTGRES_*` and `NEXT_PUBLIC_APP_URL` instead. + + + + Save `ENCRYPTION_KEY` and `API_ENCRYPTION_KEY` somewhere outside this server. `ENCRYPTION_KEY` encrypts workspace and personal environment variables, stored provider API keys, MCP OAuth credentials, and deployment/chat secrets; `API_ENCRYPTION_KEY` encrypts user-generated Sim API keys. Neither can be regenerated — a database restore paired with a different key leaves the data it protected permanently unreadable. + + +The compose file refuses to start if `BETTER_AUTH_SECRET`, `ENCRYPTION_KEY`, or `INTERNAL_API_SECRET` is missing, rather than booting with empty values. `CRON_SECRET` is treated more gently: without it the `cron` service prints what to set and exits, leaving the rest of the stack running — so upgrading from a compose file that predates the scheduler still works. + +Images track `latest` unless you pin them. For production, see [Upgrades](/platform/self-hosting/upgrades). + ### 2. Start Services ```bash docker compose -f docker-compose.prod.yml up -d ``` -### 3. Set Up SSL +Six services start: + +| Service | Port | Purpose | +|---|---|---| +| `simstudio` | 3000 | Main application (8 GB memory limit) | +| `realtime` | 3002 | WebSocket server (1 GB memory limit) | +| `db` | 5432 | PostgreSQL 17 with pgvector | +| `redis` | internal | Pub/sub and shared cache — not published to the host | +| `cron` | — | Runs the [background jobs](/platform/self-hosting/background-jobs) on a schedule | +| `migrations` | — | Applies schema migrations once, then exits | - - -Caddy automatically handles SSL certificates. +Confirm the five long-running services are up and that `migrations` has exited cleanly (it is a one-shot job with no healthcheck): ```bash -# Install Caddy -sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl -curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg -curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list -sudo apt update && sudo apt install caddy +docker compose -f docker-compose.prod.yml ps ``` -Create `/etc/caddy/Caddyfile`: +### 3. Put it behind TLS + +Caddy is the least-effort option — it obtains and renews certificates automatically. + ``` sim.yourdomain.com { - reverse_proxy localhost:3000 + request_body { + max_size 250MB + } handle /socket.io/* { reverse_proxy localhost:3002 } -} -``` -```bash -sudo systemctl restart caddy -``` - - -```bash -# Install -sudo apt install nginx certbot python3-certbot-nginx -y - -# Create /etc/nginx/sites-available/sim -server { - listen 80; - server_name sim.yourdomain.com; - - location / { - proxy_pass http://127.0.0.1:3000; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location /socket.io/ { - proxy_pass http://127.0.0.1:3002; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; + reverse_proxy localhost:3000 { + flush_interval -1 } } - -# Enable and get certificate -sudo ln -s /etc/nginx/sites-available/sim /etc/nginx/sites-enabled/ -sudo certbot --nginx -d sim.yourdomain.com ``` - - + +Three things in that config are Sim-specific and easy to get wrong: `/socket.io` must reach the realtime service on 3002, `flush_interval -1` stops Caddy buffering streamed agent output into one delayed block, and `max_size` has to clear the chat endpoint's 220 MB limit. + +For nginx, Traefik, or a cloud load balancer — and for the GKE websocket timeout — see [Networking](/platform/self-hosting/networking). ## Ollama @@ -112,9 +115,13 @@ docker compose -f docker-compose.ollama.yml --profile gpu --profile setup up -d docker compose -f docker-compose.ollama.yml --profile cpu --profile setup up -d ``` -Pull additional models: +Pull additional models — the service name differs by profile: ```bash +# GPU profile docker compose -f docker-compose.ollama.yml exec ollama ollama pull llama3.2 + +# CPU profile +docker compose -f docker-compose.ollama.yml exec ollama-cpu ollama pull llama3.2 ``` ### External Ollama @@ -136,26 +143,20 @@ OLLAMA_URL=http://192.168.1.100:11434 docker compose -f docker-compose.prod.yml ## Commands ```bash -# View logs -docker compose -f docker-compose.prod.yml logs -f simstudio +# Did migrations succeed? +docker compose -f docker-compose.prod.yml logs migrations -# Stop -docker compose -f docker-compose.prod.yml down +# Is the scheduler firing? +docker compose -f docker-compose.prod.yml logs -f cron -# Update +# Upgrade: bump SIM_VERSION in .env, then docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d - -# Backup database -docker compose -f docker-compose.prod.yml exec db pg_dump -U postgres simstudio > backup.sql ``` backup.sql. Restore with: docker compose -f docker-compose.prod.yml exec -T db psql -U postgres simstudio < backup.sql. The database data is persisted in a Docker volume named postgres_data." }, + { question: "Do scheduled workflows work on Docker Compose?", answer: "Yes. The cron service runs the same jobs the Helm chart schedules as Kubernetes CronJobs, using the schedules in docker/crontab. It needs CRON_SECRET — without it the service prints what to set and exits, and the rest of the stack keeps running."}, + { question: "Why is there a Redis container?", answer: "Redis backs pub/sub for live Chat task status and table events, plus shared caches. Pub/sub has no fallback that works across processes, so live status would not stream without it. The port is deliberately not published so it cannot collide with a local Redis."}, + { question: "How do I back up and restore the database?", answer: "Back up with: docker compose -f docker-compose.prod.yml exec db pg_dump -U postgres simstudio > backup.sql. Restore with: docker compose -f docker-compose.prod.yml exec -T db psql -U postgres simstudio < backup.sql. The database data is persisted in a Docker volume named postgres_data."}, { question: "Can I customize the PostgreSQL credentials?", answer: "Yes. The docker-compose.prod.yml uses environment variable defaults: POSTGRES_USER (default: postgres), POSTGRES_PASSWORD (default: postgres), POSTGRES_DB (default: simstudio), and POSTGRES_PORT (default: 5432). Set these in your .env file to override them." }, ]} /> diff --git a/apps/docs/content/docs/en/platform/self-hosting/email.mdx b/apps/docs/content/docs/en/platform/self-hosting/email.mdx new file mode 100644 index 00000000000..535397458ac --- /dev/null +++ b/apps/docs/content/docs/en/platform/self-hosting/email.mdx @@ -0,0 +1,174 @@ +--- +title: Email +description: Configure transactional email for invitations, verification, and notifications +--- + +import { Tabs, Tab } from 'fumadocs-ui/components/tabs' +import { Callout } from 'fumadocs-ui/components/callout' +import { FAQ } from '@/components/ui/faq' + +Sim sends workspace invitations, email verification, password resets, and notifications. Configure at least one provider. + + + With no provider configured, nothing is sent and nothing errors — the mailer logs a single line with the recipient, subject, and sender, then reports success. On a production deployment that means workspace invitations silently never arrive. The message body is **not** logged, so an email missed this way cannot be recovered from the logs. + + +## Provider selection + +There is no provider flag. Every provider whose variables are set becomes active, and the mailer tries them in this fixed order, falling through to the next one only when the previous **fails**: + +**Resend → AWS SES → SMTP → Azure Communication Services → Gmail** + +So the earliest configured provider handles normal traffic, and any others act as automatic failover. Configuring one is the simple case; configuring two gives you a fallback path at the cost of mail occasionally leaving from a different sender. + +## Shared settings + +| Variable | Description | +|---|---| +| `FROM_EMAIL_ADDRESS` | Sender address, e.g. `Sim ` | +| `EMAIL_DOMAIN` | Fallback domain when `FROM_EMAIL_ADDRESS` is unset — sends as `noreply@EMAIL_DOMAIN` | +| `EMAIL_VERIFICATION_ENABLED` | Set `true` to require email verification at signup | + +The sender address must be one your provider is authorized to send as. A mismatch is the most common cause of mail that is accepted by the provider and then silently dropped or spam-filtered downstream. + +## Providers + + + + +Simplest option if you have no existing mail infrastructure. + +```bash +RESEND_API_KEY=re_... +FROM_EMAIL_ADDRESS="Sim " +``` + +Verify your sending domain in the Resend dashboard and add the DNS records it gives you before sending in production. + + + + +```bash +AWS_SES_REGION=us-east-1 +FROM_EMAIL_ADDRESS="Sim " +``` + +Credentials resolve through the standard AWS provider chain — environment variables, shared config, ECS/EKS task role (IRSA), EC2 instance profile, or SSO. On EKS, attach an IRSA role with `ses:SendEmail` and `ses:SendRawEmail` and set no keys at all. + + + New SES accounts are in the **sandbox**, which only permits sending to verified addresses. Invitations to your team will fail until you request production access. Verify the sending domain and configure DKIM as well. + + + + + +Works with any relay — Postfix, SendGrid, Mailgun, Google Workspace SMTP relay, or MailHog for local testing. + +```bash +SMTP_HOST=smtp.example.com +SMTP_PORT=587 # 465 implicit TLS, 587 STARTTLS, 25 plain +SMTP_USER=apikey # omit for unauthenticated relays +SMTP_PASS=... # omit for unauthenticated relays +# SMTP_SECURE=true # only for implicit TLS. Leave unset on 587 — it is + # automatic on 465, and forcing it on a STARTTLS port fails to connect +FROM_EMAIL_ADDRESS="Sim " +``` + +For Google Workspace without a service account: + +```bash +SMTP_HOST=smtp-relay.gmail.com +SMTP_PORT=587 +``` + +The relay must be configured to accept mail from your deployment's egress IP in the Workspace admin console. + + + + +```bash +AZURE_ACS_CONNECTION_STRING=endpoint=https://...;accesskey=... +FROM_EMAIL_ADDRESS="Sim " +``` + +Provision an Email Communication Service, connect a verified domain, then link it to the Communication Service resource. The sender address must belong to the linked domain. + + + + +GCP has no first-party transactional email service, so the Google-native path is the Gmail API with a Google Workspace sender. + +```bash +GMAIL_CREDENTIALS_JSON='{"type":"service_account",...}' +GMAIL_SENDER=noreply@yourdomain.com +FROM_EMAIL_ADDRESS="Sim " +``` + +Setup: + +1. Create a service account and download its JSON key. +2. In the Workspace admin console → **Security → Access and data control → API controls → Domain-wide delegation → Add new**, add the service account's `client_id` with the scope `https://www.googleapis.com/auth/gmail.send`. +3. Set `GMAIL_SENDER` to the Workspace user the service account impersonates. + + + `FROM_EMAIL_ADDRESS` must match `GMAIL_SENDER` or one of its registered aliases. Gmail rewrites unrecognized From addresses, so a mismatch produces mail that sends successfully but arrives from the wrong address. + + + + Gmail caps sending at roughly 2,000 messages per day per user — ample for invitations and verification on most deployments. If you need more, switch to the Workspace SMTP relay or Resend; both are configuration-only changes. + + +Keep the JSON on one line when pasting it into a values file: + +```bash +jq -c . service-account-key.json +``` + + + + +## Kubernetes + +Email credentials are secrets. Supply them through your secret store rather than plain values: + +```yaml +app: + env: + FROM_EMAIL_ADDRESS: "Sim " + RESEND_API_KEY: "re_..." # via External Secrets or an existing Secret +``` + +In the default and External Secrets modes, `app.env` keys are written into a chart-managed Secret and mounted via `envFrom`, so values do not appear in pod specs. A secret committed to `values.yaml` is still a secret in your git history — pass it through External Secrets or a pre-created Secret instead. + +## Verifying + +Invite a user to a workspace from workspace settings and watch the app logs: + +```bash +kubectl logs -n simstudio -l app.kubernetes.io/component=app --tail=100 | grep -i mail +``` + +| What you see | Meaning | +|---|---| +| A single log line with recipient, subject, and sender, and no delivery | No provider is configured — the mailer no-opped | +| A provider API error | Credentials or sender address problem; the error names which | +| Success, but nothing arrives | Delivered to the provider — check the provider's dashboard, then spam filtering, SPF, and DKIM | + +## Troubleshooting + +**"Delegation denied" / `unauthorized_client` (Gmail)** — the domain-wide delegation entry is missing or has the wrong client ID or scope. Re-check the admin console entry against the `client_id` in the service-account JSON, and confirm the scope is exactly `https://www.googleapis.com/auth/gmail.send`. + +**Mail rejected with a From-address error** — the sender is not authorized for the provider's verified domain. Align `FROM_EMAIL_ADDRESS` with the verified domain (and with `GMAIL_SENDER` on the Gmail path). + +**SES rejects recipients** — the account is still in the SES sandbox. Request production access. + +**Mail lands in spam** — configure SPF, DKIM, and DMARC for your sending domain. This is on your DNS, not on Sim. + +**Nothing at all happens and no error appears** — no provider is configured. The logs will show one line naming the recipient and subject. + + diff --git a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx index 416779ba00b..8a2f01bf27f 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx @@ -10,31 +10,65 @@ import { Callout } from 'fumadocs-ui/components/callout' | Variable | Description | |----------|-------------| | `DATABASE_URL` | PostgreSQL connection string | -| `BETTER_AUTH_SECRET` | Auth secret (32 hex chars): `openssl rand -hex 32` | -| `BETTER_AUTH_URL` | Your app URL | +| `BETTER_AUTH_SECRET` | Auth secret (32 hex chars): `openssl rand -hex 32`. **Must be identical on the app and realtime services** | +| `BETTER_AUTH_URL` | Your app URL — must be the real public origin, not `localhost` | | `ENCRYPTION_KEY` | Encryption key (32 hex chars): `openssl rand -hex 32` | | `INTERNAL_API_SECRET` | Internal API secret (32 hex chars): `openssl rand -hex 32` | | `NEXT_PUBLIC_APP_URL` | Public app URL | -| `NEXT_PUBLIC_SOCKET_URL` | Optional. WebSocket URL — defaults to the page origin; set only if realtime is on a separate host. | +| `CRON_SECRET` | Bearer token for the background job endpoints (32 hex chars). **Required whenever background jobs are enabled** — the Helm chart's default, and the chart will not render without it. See [Background Jobs](/platform/self-hosting/background-jobs) | +| `API_ENCRYPTION_KEY` | Encrypts user-generated API keys at rest (32 hex chars). Required to create API keys | + + + `ENCRYPTION_KEY` and `API_ENCRYPTION_KEY` cannot be rotated or recovered. Losing either makes the data it protects permanently unreadable — workspace and personal environment variables, stored provider API keys, MCP OAuth credentials, and deployment/chat secrets in the first case, user-generated Sim API keys in the second. Back them up separately from the database. + + +## Strongly recommended + +| Variable | Description | +|----------|-------------| +| `REDIS_URL` | Redis connection string. Optional on a single replica; **required** past one app or realtime replica — see [Redis](/platform/self-hosting/redis) | +| `REDIS_TLS_SERVERNAME` | TLS SNI override. Required when `REDIS_URL` uses `rediss://` with a bare IP, or the app throws at startup | +| `NEXT_PUBLIC_SOCKET_URL` | WebSocket URL — defaults to the page origin; set only if realtime is on a separate host | +| `TRUSTED_ORIGINS` | Comma-separated additional origins to trust for auth (apex + `www`, alias domains) | +| `AUTH_TRUSTED_PROXIES` | Comma-separated reverse-proxy IPs/CIDRs so the client IP cannot be forged through `X-Forwarded-For` | +| `INTERNAL_API_BASE_URL` | Internal URL for server-side self-calls, e.g. `http://sim-app.simstudio.svc.cluster.local:3000`. Required for PII log redaction; defaults to `NEXT_PUBLIC_APP_URL` | +| `DATABASE_REPLICA_URL` | Read-replica connection string for log listing, audit logs, and dashboard aggregations. Falls back to the primary when unset | ## AI Providers | Variable | Provider | |----------|----------| -| `OPENAI_API_KEY` | OpenAI | +| `OPENAI_API_KEY` | OpenAI — also the default knowledge base embedding provider | | `ANTHROPIC_API_KEY_1` | Anthropic Claude | -| `GEMINI_API_KEY_1` | Google Gemini | +| `GEMINI_API_KEY` / `GEMINI_API_KEY_1` | Google Gemini | | `MISTRAL_API_KEY` | Mistral | +| `XAI_API_KEY_1` | xAI | +| `KIMI_API_KEY_1` | Moonshot Kimi | +| `ZAI_API_KEY_1` | Z.ai | +| `TOGETHER_API_KEY` | Together AI | +| `FIREWORKS_API_KEY` | Fireworks AI | +| `BASETEN_API_KEY` | Baseten | +| `COHERE_API_KEY` | Cohere — required for the Knowledge block reranker | | `OLLAMA_URL` | Ollama (default: `http://localhost:11434`) | + + **Knowledge bases require a hosted embedding provider.** Three are supported, selected with `KB_EMBEDDING_MODEL`: `text-embedding-3-small` (default) and `text-embedding-3-large` on OpenAI or Azure OpenAI, and `gemini-embedding-001` on Gemini. There is no local embedding backend — configuring Ollama or vLLM does not substitute, because embeddings do not route through the configured chat model. + + - For load balancing, add multiple keys with `_1`, `_2`, `_3` suffixes (e.g., `OPENAI_API_KEY_1`, `OPENAI_API_KEY_2`). Works with OpenAI, Anthropic, and Gemini. + For load balancing, add multiple keys with `_1`, `_2`, `_3` suffixes (e.g., `OPENAI_API_KEY_1`, `OPENAI_API_KEY_2`). Works with OpenAI, Anthropic, Gemini, xAI, Kimi, Z.ai, Cohere, and Fireworks. In Docker, use `OLLAMA_URL=http://host.docker.internal:11434` for host-machine Ollama. +### AWS Bedrock + +| Variable | Description | +|----------|-------------| +| `NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS` | Set `true` when using the AWS default credential chain (IAM roles, ECS task roles, IRSA). Hides credential fields in the Agent block UI | + ### Azure OpenAI | Variable | Description | @@ -43,33 +77,111 @@ import { Callout } from 'fumadocs-ui/components/callout' | `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint URL | | `AZURE_OPENAI_API_VERSION` | API version (e.g., `2024-02-15-preview`) | -### vLLM (Self-Hosted) +### Self-hosted OpenAI-compatible endpoints | Variable | Description | |----------|-------------| -| `VLLM_BASE_URL` | vLLM server URL (e.g., `http://localhost:8000/v1`) | +| `VLLM_BASE_URL` | vLLM server URL, **without** a `/v1` suffix (e.g. `http://localhost:8000`) — Sim appends `/v1` itself | | `VLLM_API_KEY` | Optional bearer token for vLLM | +| `LITELLM_BASE_URL` | LiteLLM proxy base URL | +| `LITELLM_API_KEY` | Optional bearer token for LiteLLM | -## OAuth Providers +## Login Providers | Variable | Description | |----------|-------------| -| `GOOGLE_CLIENT_ID` | Google OAuth client ID | -| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | -| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | -| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | +| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | Google — also powers all Google integrations | +| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | GitHub | +| `MICROSOFT_CLIENT_ID` / `MICROSOFT_CLIENT_SECRET` | Microsoft — also powers all Microsoft integrations | +| `DISABLE_GOOGLE_AUTH` / `DISABLE_GITHUB_AUTH` / `DISABLE_MICROSOFT_AUTH` | Hide a provider from the login page without removing its credentials | -## Optional +See [Authentication](/platform/self-hosting/authentication). + +## Integration Credentials + + + Integrations do not work on a self-hosted deployment until you register your own OAuth app with each service and set its `*_CLIENT_ID` / `*_CLIENT_SECRET`. There are around 27 of them covering 50 connectors. The full table, redirect-URI format, and setup steps are in [Integrations & OAuth](/platform/self-hosting/integrations-oauth). + + +## Access Control | Variable | Description | |----------|-------------| -| `API_ENCRYPTION_KEY` | Encrypts stored API keys (32 hex chars): `openssl rand -hex 32` | -| `COPILOT_API_KEY` | API key for Chat. Without it the Sim Chat block, scheduled prompt jobs, and Inbox cannot run | -| `NEXT_PUBLIC_CHAT_DISABLED` | Set to `true` to hide the Chat module: the workspace lands on your first workflow, with no chats list, scheduled tasks, or editor Chat panel. Chat is shown when unset; `bun run setup` sets it for you if you skip the chat key | -| `ADMIN_API_KEY` | Admin API key for GitOps operations | +| `DISABLE_REGISTRATION` | Set `true` to disable new user signups entirely | +| `DISABLE_EMAIL_SIGNUP` | Block new email/password registrations; existing email login keeps working | | `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) | | `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) | -| `DISABLE_REGISTRATION` | Set to `true` to disable new user signups | +| `BLOCKED_SIGNUP_DOMAINS` | Block specific domains from signing up (comma-separated) | +| `SIGNUP_MX_VALIDATION_ENABLED` | Reject domains with no MX record or a denylisted mail backend | +| `BLOCKED_EMAIL_MX_HOSTS` | MX-host substrings to block; used only with the above | +| `DISABLE_INVITATIONS` / `NEXT_PUBLIC_DISABLE_INVITATIONS` | Disable workspace invitations globally | +| `DISABLE_PUBLIC_API` / `NEXT_PUBLIC_DISABLE_PUBLIC_API` | Disable the public API globally | +| `DISABLE_AUTH` | Bypass authentication entirely, creating an anonymous session for every request | + + + `DISABLE_AUTH=true` makes everyone who can reach the instance a fully privileged user. Use it only for a single-user instance on a private network, never behind an internet-facing ingress. + + +## Code Execution + +| Variable | Description | +|----------|-------------| +| `SANDBOX_PROVIDER` | Remote sandbox provider: `e2b` (default) or `daytona` | +| `E2B_ENABLED` / `E2B_API_KEY` | Enable E2B remote execution | +| `DAYTONA_API_KEY` | Daytona API key (used when `SANDBOX_PROVIDER=daytona`) | +| `IVM_MAX_EXECUTIONS_PER_WORKER` | Executions before an isolated-vm worker is recycled | +| `IVM_MAX_BROKERS_PER_EXECUTION` | Host-call brokers per execution | +| `IVM_MAX_BROKER_ARGS_JSON_CHARS` | Max argument payload size | +| `IVM_MAX_BROKER_RESULT_JSON_CHARS` | Max result payload size | + +Without a remote provider, user code runs in an in-process V8 isolate inside the app container. See [Security](/platform/self-hosting/security). + +## Networking & Limits + +| Variable | Default | Description | +|----------|---------|-------------| +| `API_MAX_JSON_BODY_BYTES` | 50 MB | Max JSON body on contract-validated API routes | +| `CHAT_MAX_REQUEST_BYTES` | 220 MB | Max body on the public deployed-chat endpoint | +| `WEBHOOK_MAX_REQUEST_BYTES` | 10 MB | Max body on public webhook receiver endpoints | +| `WORKFLOW_EXECUTION_CONCURRENCY_LIMIT` | `75` | Workflow executions in parallel | +| `WEBHOOK_EXECUTION_CONCURRENCY_LIMIT` | `75` | Webhook-triggered executions in parallel | +| `SCHEDULE_EXECUTION_CONCURRENCY_LIMIT` | `30` | Scheduled executions in parallel | +| `RESUME_EXECUTION_CONCURRENCY_LIMIT` | `50` | Resumed executions in parallel | +| `ALLOW_PRIVATE_DATABASE_HOSTS` | unset | Let database/connector tools reach private, reserved, and loopback hosts. Loosens the SSRF boundary | + +Your reverse proxy's body-size limit must be at least as large as the app limits above. See [Networking](/platform/self-hosting/networking). + +## Observability + +| Variable | Description | +|----------|-------------| +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector endpoint | +| `OTEL_EXPORTER_OTLP_HEADERS` | Auth headers, `key=value` comma-separated | +| `OTEL_TRACES_SAMPLER_ARG` | Trace sampling ratio | +| `OTEL_DEPLOYMENT_ENVIRONMENT` | Environment label on emitted spans | +| `TELEMETRY_SAMPLING_RATIO` | Application-level sampling ratio | +| `TELEMETRY_ENDPOINT` | Where anonymous telemetry is sent. Defaults to `https://telemetry.simstudio.ai/v1/traces` — point it at your own collector to keep traces internal | +| `NEXT_TELEMETRY_DISABLED` | Set to `1` to disable anonymous telemetry entirely | +| `GRAFANA_OTLP_ENDPOINT` / `GRAFANA_OTLP_HEADERS` / `GRAFANA_DEPLOYMENT_ENVIRONMENT` | Grafana Cloud OTLP export | + +See [Observability](/platform/self-hosting/observability). + +## Knowledge Bases + +| Variable | Description | +|----------|-------------| +| `KB_EMBEDDING_MODEL` | Embedding model for new knowledge bases. Defaults to `text-embedding-3-small`; an unsupported value falls back to the default | +| `COHERE_API_KEY` | Enables the Knowledge block reranker | + +## Chat & PII + +| Variable | Description | +|----------|-------------| +| `COPILOT_API_KEY` | API key for Chat. Without it the Sim Chat block, scheduled prompt jobs, and Inbox cannot run | +| `NEXT_PUBLIC_CHAT_DISABLED` | Set to `true` to hide the Chat module: the workspace lands on your first workflow, with no chats list, scheduled tasks, or editor Chat panel. Chat is shown when unset; `bun run setup` sets it for you if you skip the chat key | +| `PII_REDACTION` | Redact PII from workflow logs via Data Retention rules; requires the PII service and a cluster-reachable `INTERNAL_API_BASE_URL` | +| `PII_GRANULAR_REDACTION` | Additionally expose the execution-altering redaction stages | +| `ADMIN_API_KEY` | Admin API key for GitOps operations and organization provisioning | ## Enterprise Features @@ -102,50 +214,16 @@ By default Sim writes uploads to local disk. For production, point it at AWS S3, ## Email Providers -Configure one provider — the mailer auto-detects in priority order: **Resend → AWS SES → SMTP → Azure Communication Services → Gmail**. If none are configured, emails are logged to the console instead. - -| Variable | Description | -|----------|-------------| -| `FROM_EMAIL_ADDRESS` | Sender address (e.g. `Sim `). Falls back to `noreply@EMAIL_DOMAIN`. | -| `EMAIL_DOMAIN` | Default domain when `FROM_EMAIL_ADDRESS` is unset | -| `EMAIL_VERIFICATION_ENABLED` | Set to `true` to require email verification on signup | - -**Resend** - -| Variable | Description | -|----------|-------------| -| `RESEND_API_KEY` | API key from [resend.com](https://resend.com) | - -**AWS SES** - -| Variable | Description | -|----------|-------------| -| `AWS_SES_REGION` | AWS region for SES (e.g. `us-east-1`). Credentials are resolved through the standard AWS SDK provider chain (env vars, IRSA, ECS/EC2 instance role, SSO). | - -**SMTP** (works with MailHog, Postfix, SendGrid SMTP, etc.) - -| Variable | Description | -|----------|-------------| -| `SMTP_HOST` | SMTP server hostname | -| `SMTP_PORT` | `465` for implicit TLS, `587` for STARTTLS, `25` for plain | -| `SMTP_USER` | Optional — omit for unauthenticated relays | -| `SMTP_PASS` | Optional — omit for unauthenticated relays | -| `SMTP_SECURE` | Set to `true` to force TLS on connect; auto-true on port 465 | - -**Azure Communication Services** - -| Variable | Description | -|----------|-------------| -| `AZURE_ACS_CONNECTION_STRING` | Azure Communication Services connection string | - -**Gmail** (Google-native — GCP has no first-party transactional email service, so the native path is the Gmail API with a Google Workspace sender) - -| Variable | Description | -|----------|-------------| -| `GMAIL_CREDENTIALS_JSON` | Inline service-account JSON. The service account needs [domain-wide delegation](https://developers.google.com/workspace/guides/create-credentials#delegate_domain-wide_authority_to_a_service_account) granted for the `https://www.googleapis.com/auth/gmail.send` scope in the Workspace admin console | -| `GMAIL_SENDER` | The Workspace user the service account impersonates when sending (e.g. `noreply@yourdomain.com`). `FROM_EMAIL_ADDRESS` should match this user or one of its registered aliases — Gmail rewrites unrecognized From addresses | +Configure at least one. Every configured provider stays active and is tried in order — **Resend → AWS SES → SMTP → Azure Communication Services → Gmail** — falling through only on failure. With none configured, mail is silently not sent. Setup, verification, and troubleshooting are in [Email](/platform/self-hosting/email). -Alternatively, the [Google Workspace SMTP relay](https://support.google.com/a/answer/2956491) works through the generic SMTP provider (`SMTP_HOST=smtp-relay.gmail.com`, port `587`) with no service account required. +| Provider | Variables | +|----------|-----------| +| Shared | `FROM_EMAIL_ADDRESS`, `EMAIL_DOMAIN`, `EMAIL_VERIFICATION_ENABLED` | +| Resend | `RESEND_API_KEY` | +| AWS SES | `AWS_SES_REGION` (credentials via the AWS provider chain) | +| SMTP | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_SECURE` | +| Azure ACS | `AZURE_ACS_CONNECTION_STRING` | +| Gmail | `GMAIL_CREDENTIALS_JSON`, `GMAIL_SENDER` | ## Limits @@ -167,19 +245,36 @@ Self-hosted deployments (billing disabled) run without plan limits: no rate limi - Deployments installed with the Helm chart ship these variables preset in `app.envDefaults`, so chart-based installs keep enforcement unless those keys are removed or overridden. + Neither deployment presets these. The Helm chart previously did, which enforced hosted-plan caps on self-hosted installs; chart 1.5.0 removed the presets so Compose and Kubernetes behave identically. ## Example .env ```bash +# Core DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio -BETTER_AUTH_SECRET= +NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com BETTER_AUTH_URL=https://sim.yourdomain.com + +# Secrets — generate each with `openssl rand -hex 32` +BETTER_AUTH_SECRET= ENCRYPTION_KEY= INTERNAL_API_SECRET= -NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com +API_ENCRYPTION_KEY= +CRON_SECRET= + +# Coordination (required past one replica) +REDIS_URL=redis://redis:6379 + +# Models — OPENAI_API_KEY also powers knowledge base embeddings OPENAI_API_KEY=sk-... + +# Email +RESEND_API_KEY=re_... +FROM_EMAIL_ADDRESS="Sim " + +# Access control +ALLOWED_LOGIN_DOMAINS=yourdomain.com ``` See `apps/sim/.env.example` for all options. diff --git a/apps/docs/content/docs/en/platform/self-hosting/index.mdx b/apps/docs/content/docs/en/platform/self-hosting/index.mdx index 1cecb325ac0..29723555709 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/index.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/index.mdx @@ -10,7 +10,7 @@ import { FAQ } from '@/components/ui/faq' Deploy Sim on your own infrastructure with Docker or Kubernetes.

- + Set Up with Cursor
@@ -29,18 +29,30 @@ Deploy Sim on your own infrastructure with Docker or Kubernetes. **Production**: Large teams (50+ users), high availability, heavy workflow execution -Resource requirements are driven by workflow execution (isolated-vm sandboxing), file processing (in-memory document parsing), and vector operations (pgvector). Memory is typically the constraining factor rather than CPU. Production telemetry shows the main app uses 4-8 GB average with peaks up to 12 GB under heavy load. +Resource requirements are driven by workflow execution (isolated-vm sandboxing), file processing (in-memory document parsing), and vector operations (pgvector). Memory is typically the constraining factor rather than CPU — both the Helm chart and the compose file request 4 Gi for the app and cap it at 8 Gi. ## Quick Start ```bash git clone https://github.com/simstudioai/sim.git && cd sim + +cat > .env << EOF +BETTER_AUTH_SECRET=$(openssl rand -hex 32) +ENCRYPTION_KEY=$(openssl rand -hex 32) +INTERNAL_API_SECRET=$(openssl rand -hex 32) +CRON_SECRET=$(openssl rand -hex 32) +EOF + docker compose -f docker-compose.prod.yml up -d ``` Open [http://localhost:3000](http://localhost:3000) + + The `.env` step is not optional — the compose file refuses to start without the first three rather than booting with empty values. `CRON_SECRET` is what the scheduler uses; without it the `cron` service exits with instructions and everything else still runs. See [Docker](/platform/self-hosting/docker) for the full production setup. + + ## Deployment Options @@ -55,34 +67,63 @@ Open [http://localhost:3000](http://localhost:3000) +### Which one to pick + +Both deploy the same feature set. Docker Compose is the fastest way to evaluate Sim and is fine for a single-node team install; Kubernetes is the path for high availability and managed secrets. + +| Capability | Docker Compose | Kubernetes (Helm) | +|---|---|---| +| App, realtime, migrations, Postgres, Redis | Yes | Yes | +| Scheduled workflows and polling triggers | Yes — `cron` service | Yes — 18 CronJobs | +| Horizontal scaling / HA | No (single node) | Yes (`replicaCount`, HPA, PDB) | +| Managed secrets (Vault, ESO, cloud KMS) | Manual `.env` | Yes | +| Network policy, Pod Security Standards | Host-level only | Yes | +| PII redaction, OpenTelemetry collector | Not bundled | Optional components | + +The remaining differences are inherent to the platform — Compose has no analogue of a HorizontalPodAutoscaler or a PodDisruptionBudget. Application behavior is the same on both. + +## Where to go next + + + + Required before any integration works — start here + + + Required before storing anything you care about + + + Ten-minute smoke test across every subsystem + + + +The sidebar covers the rest: architecture, email, Redis, authentication, background jobs, networking, security, scaling, observability, and upgrades. + ## Enterprise Features -Organizations, SSO, permission groups, audit logs, whitelabeling, session policies, data retention, and data drains all run on a self-hosted deployment — no billing or subscription required. One switch turns on the set: +Organizations, SSO, permission groups, audit logs, whitelabeling, session policies, data retention, and data drains all run on a self-hosted deployment with no billing or subscription. Turn the set on with: ```bash ENTERPRISE_ENABLED=true NEXT_PUBLIC_ENTERPRISE_ENABLED=true ``` -Most of these features read their settings from the organization that owns a workspace, so enabling the flags is only half of it — your deployment also needs an organization model. Set `INSTANCE_ORG_NAME` to put every user in one shared organization automatically, or provision organizations yourself through the Admin API. +Your deployment also needs an organization model for most of them to apply. The [self-hosted enterprise guide](/platform/enterprise/self-hosted) covers both patterns, the per-feature flags, and troubleshooting. -See the [self-hosted enterprise guide](/platform/enterprise/self-hosted) for both patterns, the per-feature flags, and troubleshooting. +## External dependencies to plan for -## Architecture +Sim is self-contained for the core editor and execution engine. A few features reach outside the deployment: -| Component | Port | Description | -|-----------|------|-------------| -| simstudio | 3000 | Main application | -| realtime | 3002 | WebSocket server | -| db | 5432 | PostgreSQL with pgvector | -| migrations | - | Database migrations (runs once) | +| Feature | Requires | Notes | +|---|---|---| +| **Knowledge bases** | An OpenAI, Azure OpenAI, or Gemini API key | Embeddings are generated by a hosted provider, selected with `KB_EMBEDDING_MODEL` (`text-embedding-3-small` by default). There is no local embedding backend — knowledge bases are unavailable without one of these keys. | +| **Agent blocks** | An API key for at least one model provider | Or a self-hosted OpenAI-compatible endpoint: Ollama, vLLM, or LiteLLM. | +| **Chat module** | `COPILOT_API_KEY` from sim.ai | Set `NEXT_PUBLIC_CHAT_DISABLED=true` to hide the module instead. | +| **Integrations** | Your own OAuth app per service | See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). | +| **Function / Pi blocks at scale** | Optional E2B or Daytona key | Without one, code runs in the in-process isolated-vm sandbox. See [Security](/platform/self-hosting/security). | diff --git a/apps/docs/content/docs/en/platform/self-hosting/integrations-oauth.mdx b/apps/docs/content/docs/en/platform/self-hosting/integrations-oauth.mdx new file mode 100644 index 00000000000..cbb33d9fc5f --- /dev/null +++ b/apps/docs/content/docs/en/platform/self-hosting/integrations-oauth.mdx @@ -0,0 +1,197 @@ +--- +title: Integrations & OAuth +description: Register OAuth apps so your users can connect Slack, Google, Jira, and every other integration +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { FAQ } from '@/components/ui/faq' + + + **On a self-hosted deployment, integrations do not work until you register your own OAuth application with each service.** Sim's hosted platform ships credentials for every integration; a self-hosted instance ships none. Users will see the connector in the UI, click "Connect", and get an error from the provider until the corresponding `*_CLIENT_ID` and `*_CLIENT_SECRET` are set. + + +You only need to register the services your team actually uses. One OAuth app covers every Sim connector that shares its credential — a single Google app serves Gmail, Drive, Sheets, Calendar, Docs, Forms, BigQuery, and more. + +## How it works + +Each connector has a **provider ID**. When a user connects an account, Sim redirects them to the provider, and the provider redirects back to: + +``` +https:///api/auth/oauth2/callback/ +``` + +That URL is derived from `NEXT_PUBLIC_APP_URL`, so set it correctly before registering anything — the redirect URI you register with the provider must match byte for byte, including scheme and the absence of a trailing slash. + + + Most providers let you register several redirect URIs on one app. Register your production URL and any staging URL together so one OAuth app serves both environments. + + +## Setup + + + + + +### Confirm your public URL + +```bash +NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com +BETTER_AUTH_URL=https://sim.yourdomain.com +``` + +Both must be your real public origin. If these are wrong, every OAuth round-trip fails with a redirect-URI mismatch. + + + + + +### Register an app with the provider + +In the provider's developer console, create an OAuth 2.0 application. Register the redirect URI(s) for every Sim connector you want from that provider — one line per provider ID from the tables below. + +For a Google app covering Gmail and Drive, for example, you register both: + +``` +https://sim.yourdomain.com/api/auth/oauth2/callback/google-email +https://sim.yourdomain.com/api/auth/oauth2/callback/google-drive +``` + +Scopes are requested by Sim at authorization time; you generally do not need to pre-declare them, but Google and Microsoft require you to enable the corresponding APIs on the project/app first (for example Gmail API, Drive API, Calendar API). + + + + + +### Set the credentials + +Add the client ID and secret to the app's environment. In Kubernetes they go under `app.env` — the chart writes every key there into a chart-managed Secret — but supply the values through External Secrets or a pre-created Secret rather than committing them to a values file: + +```yaml +app: + env: + GOOGLE_CLIENT_ID: "..." + GOOGLE_CLIENT_SECRET: "..." + SLACK_CLIENT_ID: "..." + SLACK_CLIENT_SECRET: "..." +``` + +Restart the app. Credentials are read at startup — a running pod will not pick up new ones. + + + + + +### Verify + +Open a workflow, add the integration's block, and connect an account. A successful round-trip returns you to Sim with the account listed. A redirect-URI mismatch is the failure you will hit most; compare the registered URI against `NEXT_PUBLIC_APP_URL` character by character. + + + + + +## Provider reference + +Every provider ID below maps to the redirect URI `https:///api/auth/oauth2/callback/`. + +### Google + +One OAuth client in [Google Cloud Console](https://console.cloud.google.com/apis/credentials) covers all of these. Enable the matching API for each connector you use. + +| Environment variables | Provider IDs | +|---|---| +| `GOOGLE_CLIENT_ID`
`GOOGLE_CLIENT_SECRET` | `google-email`, `google-drive`, `google-sheets`, `google-docs`, `google-calendar`, `google-contacts`, `google-forms`, `google-tasks`, `google-meet`, `google-groups`, `google-ads`, `google-bigquery`, `google-vault`, `vertex-ai` | + +The same variables also power "Sign in with Google". See [Authentication](/platform/self-hosting/authentication). + +### Microsoft + +One app registration in [Entra ID](https://entra.microsoft.com) covers all of these. + +| Environment variables | Provider IDs | +|---|---| +| `MICROSOFT_CLIENT_ID`
`MICROSOFT_CLIENT_SECRET` | `outlook`, `onedrive`, `sharepoint`, `microsoft-teams`, `microsoft-excel`, `microsoft-planner`, `microsoft-dataverse`, `microsoft-ad` | + +The same variables also power "Sign in with Microsoft". + +### Everything else + +| Service | Environment variables | Provider ID | +|---|---|---| +| Slack | `SLACK_CLIENT_ID` / `SLACK_CLIENT_SECRET` | `slack` | +| Notion | `NOTION_CLIENT_ID` / `NOTION_CLIENT_SECRET` | `notion` | +| Jira | `JIRA_CLIENT_ID` / `JIRA_CLIENT_SECRET` | `jira` | +| Confluence | `CONFLUENCE_CLIENT_ID` / `CONFLUENCE_CLIENT_SECRET` | `confluence` | +| Linear | `LINEAR_CLIENT_ID` / `LINEAR_CLIENT_SECRET` | `linear` | +| Asana | `ASANA_CLIENT_ID` / `ASANA_CLIENT_SECRET` | `asana` | +| ClickUp | `CLICKUP_CLIENT_ID` / `CLICKUP_CLIENT_SECRET` | `clickup` | +| Monday | `MONDAY_CLIENT_ID` / `MONDAY_CLIENT_SECRET` | `monday` | +| Airtable | `AIRTABLE_CLIENT_ID` / `AIRTABLE_CLIENT_SECRET` | `airtable` | +| HubSpot | `HUBSPOT_CLIENT_ID` / `HUBSPOT_CLIENT_SECRET` | `hubspot` | +| Salesforce | `SALESFORCE_CLIENT_ID` / `SALESFORCE_CLIENT_SECRET` | `salesforce` | +| Pipedrive | `PIPEDRIVE_CLIENT_ID` / `PIPEDRIVE_CLIENT_SECRET` | `pipedrive` | +| Attio | `ATTIO_CLIENT_ID` / `ATTIO_CLIENT_SECRET` | `attio` | +| Zoho Desk | `ZOHO_CLIENT_ID` / `ZOHO_CLIENT_SECRET` | `zoho-desk` | +| Wealthbox | `WEALTHBOX_CLIENT_ID` / `WEALTHBOX_CLIENT_SECRET` | `wealthbox` | +| Box | `BOX_CLIENT_ID` / `BOX_CLIENT_SECRET` | `box` | +| Dropbox | `DROPBOX_CLIENT_ID` / `DROPBOX_CLIENT_SECRET` | `dropbox` | +| DocuSign | `DOCUSIGN_CLIENT_ID` / `DOCUSIGN_CLIENT_SECRET` | `docusign` | +| Zoom | `ZOOM_CLIENT_ID` / `ZOOM_CLIENT_SECRET` | `zoom` | +| Cal.com | `CALCOM_CLIENT_ID` only — PKCE public client, no secret | `calcom` | +| Webflow | `WEBFLOW_CLIENT_ID` / `WEBFLOW_CLIENT_SECRET` | `webflow` | +| WordPress | `WORDPRESS_CLIENT_ID` / `WORDPRESS_CLIENT_SECRET` | `wordpress` | +| LinkedIn | `LINKEDIN_CLIENT_ID` / `LINKEDIN_CLIENT_SECRET` | `linkedin` | +| X | `X_CLIENT_ID` / `X_CLIENT_SECRET` | `x` | +| Reddit | `REDDIT_CLIENT_ID` / `REDDIT_CLIENT_SECRET` | `reddit` | +| Spotify | `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` | `spotify` | +| TikTok | `TIKTOK_CLIENT_ID` / `TIKTOK_CLIENT_SECRET` | `tiktok` | + +### Services with a different flow + +| Service | Configuration | Notes | +|---|---|---| +| **Instagram** | `INSTAGRAM_CLIENT_ID` / `INSTAGRAM_CLIENT_SECRET` | Instagram App ID/Secret from the Meta App Dashboard (Instagram → API setup with Instagram login). Redirect URI: `/api/auth/oauth2/callback/instagram`. **Publishing requires cloud object storage** — Meta fetches a public HTTPS URL, so local-disk storage will not work. | +| **Shopify** | `SHOPIFY_CLIENT_ID` / `SHOPIFY_CLIENT_SECRET` | Redirect URI: `/api/auth/oauth2/callback/shopify`. Per-shop install flow. | +| **Trello** | `TRELLO_API_KEY` | API-key based, not OAuth 2.0. Callback: `/api/auth/trello/callback`. | + +## Non-OAuth integration credentials + +Many blocks authenticate with an API key the user pastes into the block, and need nothing from you. + + + Sim also has a "hosted key" mechanism, configured with the `{PREFIX}_API_KEY_COUNT` + `{PREFIX}_API_KEY_1..N` variables below, that lets the platform supply a key so users do not have to. **The injection path is gated on the deployment being Sim's hosted platform** (`isHosted`, derived from the app hostname), so on a self-hosted instance these variables do not remove the need for users to bring their own key. Set them only if you are running a fork that has adapted that gate. + + +The variables, for reference: + +| Variable | Service | +|---|---| +| `EXA_API_KEY` (or `EXA_API_KEY_COUNT` + `EXA_API_KEY_1..N`) | Exa search | +| `SERPER_API_KEY` | Serper search | +| `BROWSERBASE_API_KEY` / `BROWSERBASE_PROJECT_ID` | Browserbase | +| `HUNTER_API_KEY_COUNT` + `HUNTER_API_KEY_1..N` | Hunter.io | +| `PEOPLEDATALABS_API_KEY_COUNT` + `PEOPLEDATALABS_API_KEY_1..N` | People Data Labs | +| `CONTEXT_DEV_API_KEY_COUNT` + `CONTEXT_DEV_API_KEY_1..N` | Context.dev | +| `FALAI_API_KEY` | fal.ai | +| `TWILIO_ACCOUNT_SID` / `TWILIO_AUTH_TOKEN` / `TWILIO_PHONE_NUMBER` | Twilio | +| `AGENTMAIL_API_KEY` / `AGENTMAIL_DOMAIN` | AgentMail | + +Providers that take a `_COUNT` plus numbered keys distribute requests round-robin across them. + +## Triggers that need extra configuration + +Webhook triggers receive callbacks from the provider and must be able to verify them: + +| Variable | Needed for | +|---|---| +| `SLACK_SIGNING_SECRET` | Verifying Slack event and slash-command signatures | +| `SLACK_EXTENDED_SCOPES` / `NEXT_PUBLIC_SLACK_EXTENDED_SCOPES` | Requesting the broader Slack scope set | + +Your deployment must also be reachable from the provider's servers for webhook triggers to fire — a Sim instance on a private network can use polling triggers but not webhook triggers. Polling triggers additionally require the scheduler; see [Background Jobs](/platform/self-hosting/background-jobs). + + diff --git a/apps/docs/content/docs/en/platform/self-hosting/kubernetes.mdx b/apps/docs/content/docs/en/platform/self-hosting/kubernetes.mdx index ab15e327d40..b4b56b1929d 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/kubernetes.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/kubernetes.mdx @@ -7,11 +7,18 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs' import { Callout } from 'fumadocs-ui/components/callout' import { FAQ } from '@/components/ui/faq' + + The chart's own [`helm/sim/README.md`](https://github.com/simstudioai/sim/tree/main/helm/sim) is the reference for every value, and goes deeper than this page on secret strategies, network policy, PII redaction, and per-error troubleshooting. This page covers the deployment path; read that alongside it. + + ## Prerequisites - Kubernetes 1.25+ - Helm 3.8+ -- PV provisioner support +- PV provisioner support (a default StorageClass supporting `ReadWriteOnce`) +- An ingress controller, if `ingress.enabled=true` +- `metrics-server`, if you enable autoscaling +- **Redis**, if you plan to run more than one replica — see [Redis](/platform/self-hosting/redis) ## Installation @@ -36,9 +43,19 @@ helm install sim ./helm/sim \ --namespace simstudio --create-namespace ``` + + Save all five values somewhere durable before moving on. `ENCRYPTION_KEY` in particular cannot be regenerated — losing it makes workspace environment variables and stored provider keys permanently unreadable. + + `CRON_SECRET` is required, not optional: the chart enables background jobs by default and will not render without it. + + + + This installs the chart's default image tag. For production, **pin `app`, `realtime`, and `migrations` to the same explicit release tag** — see [Upgrades](/platform/self-hosting/upgrades). + + ## Cloud-Specific Values -These are cloud-tuned **alternatives** to the generic install above — pick one path, don't run both. The commands reuse the `$BETTER_AUTH_SECRET`, `$ENCRYPTION_KEY`, `$INTERNAL_API_SECRET`, `$CRON_SECRET`, and `$POSTGRES_PASSWORD` variables generated in [Installation](#installation) above, so run that block's `openssl` lines first in the same shell. They use `helm upgrade --install`, so they work whether or not a release exists yet. Two caveats when converting an existing generic install rather than starting fresh: (1) **reuse the original secret values** — recover them with `helm get values sim -n simstudio` if your shell no longer has them; supplying a newly generated `ENCRYPTION_KEY` makes every previously encrypted credential (OAuth tokens, provider keys, environment variables) undecryptable. (2) The cloud values rename the bundled PostgreSQL database to `simstudio`, but Postgres only applies that setting on first initialization — add `--set postgresql.auth.database=sim` to keep your existing database. If you'd rather start clean, `helm uninstall sim -n simstudio`, delete its PVCs, and run the cloud command fresh. +These are cloud-tuned **alternatives** to the generic install above — pick one path, don't run both. The commands reuse the `$BETTER_AUTH_SECRET`, `$ENCRYPTION_KEY`, `$INTERNAL_API_SECRET`, `$CRON_SECRET`, and `$POSTGRES_PASSWORD` variables generated in [Installation](#installation) above, so run that block's `openssl` lines first in the same shell. They use `helm upgrade --install`, so they work whether or not a release exists yet. Two caveats when converting an existing generic install rather than starting fresh: (1) **reuse the original secret values** — recover them with `helm get values sim -n simstudio` if your shell no longer has them; supplying a newly generated `ENCRYPTION_KEY` makes every previously encrypted value (workspace environment variables, stored provider keys, MCP OAuth credentials) undecryptable. (2) The cloud values rename the bundled PostgreSQL database to `simstudio`, but Postgres only applies that setting on first initialization — add `--set postgresql.auth.database=sim` to keep your existing database. If you'd rather start clean, `helm uninstall sim -n simstudio`, delete its PVCs, and run the cloud command fresh. @@ -88,9 +105,22 @@ helm upgrade --install sim ./helm/sim \ # Custom values.yaml app: replicaCount: 2 + image: + tag: "v1.2.3" # a tag from the releases page env: NEXT_PUBLIC_APP_URL: "https://sim.yourdomain.com" + BETTER_AUTH_URL: "https://sim.yourdomain.com" OPENAI_API_KEY: "sk-..." + # Required once replicaCount > 1 + REDIS_URL: "redis://:@redis.internal:6379" + +realtime: + image: + tag: "v1.2.3" # a tag from the releases page + +migrations: + image: + tag: "v1.2.3" # a tag from the releases page postgresql: persistence: @@ -105,7 +135,43 @@ ingress: host: sim.yourdomain.com ``` -See `helm/sim/values.yaml` for all options. + + `NEXT_PUBLIC_APP_URL` and `BETTER_AUTH_URL` must both be your real public origin. Leaving either at `localhost` breaks sign-in. + + + + Keys set under `app.env` land on the realtime pod too — the chart writes them into one Secret that both Deployments consume via `envFrom`. Use `realtime.env` only for keys that must differ between the two, such as `ALLOWED_ORIGINS`. + + + + Setting `replicaCount: 2` **without** `REDIS_URL` silently breaks live collaboration and status updates — cross-pod events are dropped with no error anywhere. See [Redis](/platform/self-hosting/redis) and [Scaling & HA](/platform/self-hosting/scaling). + + +See `helm/sim/values.yaml` for all options, and the chart's [README](https://github.com/simstudioai/sim/tree/main/helm/sim) for the production checklist. + +## Background jobs + +The chart deploys 18 CronJobs by default, driving scheduled workflows, polling triggers, connector syncs, data drains, and outbox processing. They require `CRON_SECRET`. + +```bash +kubectl get cronjobs -n simstudio +``` + +A CronJob with a stale `LAST SCHEDULE` means the corresponding feature has stopped working. See [Background Jobs](/platform/self-hosting/background-jobs). + +## Ingress and TLS + +For nginx-style ingress controllers, raise the body-size and timeout limits — Sim's defaults allow large chat attachments and long-running executions: + +```yaml +ingress: + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: "250m" + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-buffering: "off" +``` + +On GKE, the load balancer's 30-second default backend timeout closes websockets and needs a `BackendConfig`, and the `ManagedCertificate` the chart references must be created by you. Both are covered in [Networking](/platform/self-hosting/networking). ## External Database @@ -140,11 +206,9 @@ helm uninstall sim --namespace simstudio ``` diff --git a/apps/docs/content/docs/en/platform/self-hosting/meta.json b/apps/docs/content/docs/en/platform/self-hosting/meta.json index 8ec1af87ec8..b2639411663 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/meta.json +++ b/apps/docs/content/docs/en/platform/self-hosting/meta.json @@ -2,11 +2,26 @@ "title": "Self-Hosting", "pages": [ "index", + "architecture", + "---Install---", "docker", "kubernetes", "platforms", - "object-storage", + "---Configure---", "environment-variables", + "object-storage", + "email", + "redis", + "integrations-oauth", + "authentication", + "background-jobs", + "networking", + "security", + "verify", + "---Operate---", + "observability", + "scaling", + "upgrades", "troubleshooting" ], "defaultOpen": false diff --git a/apps/docs/content/docs/en/platform/self-hosting/networking.mdx b/apps/docs/content/docs/en/platform/self-hosting/networking.mdx new file mode 100644 index 00000000000..47214f2582b --- /dev/null +++ b/apps/docs/content/docs/en/platform/self-hosting/networking.mdx @@ -0,0 +1,273 @@ +--- +title: Networking +description: Reverse proxies, TLS, websockets, timeouts, and request size limits +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { Callout } from 'fumadocs-ui/components/callout' +import { FAQ } from '@/components/ui/faq' + +Sim has three traffic patterns that trip up default proxy configurations: **long-lived websockets**, **server-sent event streams**, and **large uploads**. Most "it works locally but not in production" reports come from one of the three. + +## Topology + +Two services need to be reachable. You can put them on one hostname or two. + + + + +Simplest. Route `/socket.io` to realtime and everything else to the app. + +``` +sim.yourdomain.com/ → app:3000 +sim.yourdomain.com/socket.io → realtime:3002 +``` + +`NEXT_PUBLIC_SOCKET_URL` can be left unset — the client defaults to the page origin. + + + + +Required by ingress controllers that cannot cleanly split paths across backends, and preferred on GKE's built-in load balancer. + +``` +sim.yourdomain.com → app:3000 +sim-ws.yourdomain.com → realtime:3002 +``` + +Then tell the client where realtime lives, and tell realtime which origins to accept: + +```yaml +app: + env: + NEXT_PUBLIC_APP_URL: "https://sim.yourdomain.com" + BETTER_AUTH_URL: "https://sim.yourdomain.com" + NEXT_PUBLIC_SOCKET_URL: "https://sim-ws.yourdomain.com" + +realtime: + env: + ALLOWED_ORIGINS: "https://sim.yourdomain.com" +``` + +`ALLOWED_ORIGINS` is the CORS allowlist realtime enforces on socket connections; it must contain the app's origin. The URL keys only need to be set once under `app.env` — the chart writes them into a Secret both Deployments consume. + +Both hostnames need DNS records and TLS certificates. + + + + +## Reverse proxy configuration + + + + +Caddy handles certificates, websockets, and streaming correctly by default. + +``` +sim.yourdomain.com { + request_body { + max_size 250MB + } + + handle /socket.io/* { + reverse_proxy localhost:3002 + } + + reverse_proxy localhost:3000 { + flush_interval -1 + } +} +``` + +`flush_interval -1` disables response buffering, which keeps streamed agent output flowing token by token instead of arriving in one block at the end. + + + + +Nginx buffers responses and times out idle connections by default. Both need overriding. + +```nginx +server { + listen 443 ssl http2; + server_name sim.yourdomain.com; + + # Large file uploads (chat attachments can reach ~220 MB) + client_max_body_size 250M; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + 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; + + # Streamed responses must not be buffered + proxy_buffering off; + proxy_cache off; + + # Long-running workflow executions + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + location /socket.io/ { + proxy_pass http://127.0.0.1:3002; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } +} +``` + +For ingress-nginx, the equivalents are annotations: + +```yaml +ingress: + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: "250m" + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-buffering: "off" +``` + + + + +```yaml +ingress: + className: traefik + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" +``` + +Set the read/idle timeouts on the entrypoint, since they are static configuration rather than per-ingress: + +```yaml +entryPoints: + websecure: + address: ":443" + transport: + respondingTimeouts: + readTimeout: 3600s + idleTimeout: 3600s +``` + +Traefik streams responses by default and needs no buffering override. + + + + +## Cloud load balancers + +### GKE (GCE ingress) + + + The GCE load balancer defaults to a **30-second backend timeout**, which closes every websocket every 30 seconds. Clients reconnect, so this degrades rather than breaks — but collaboration feels unreliable and reconnect storms add load. Fix it with a `BackendConfig` on the realtime Service. + + +```yaml +apiVersion: cloud.google.com/v1 +kind: BackendConfig +metadata: + name: sim-realtime-backendconfig + namespace: simstudio +spec: + timeoutSec: 3600 + connectionDraining: + drainingTimeoutSec: 60 +``` + +Then annotate the realtime Service so the load balancer picks it up: + +```yaml +realtime: + service: + annotations: + cloud.google.com/backend-config: '{"default": "sim-realtime-backendconfig"}' +``` + +Confirm your chart version renders `realtime.service.annotations` onto the Service (`helm template ./helm/sim --values my-values.yaml | grep -A5 'kind: Service'`). If it does not, annotate the Service directly with `kubectl annotate`. + +TLS on GKE typically uses a **ManagedCertificate**, which the chart references by annotation but **does not create** — create it yourself before the first deploy: + +```yaml +apiVersion: networking.gke.io/v1 +kind: ManagedCertificate +metadata: + name: sim-ssl-cert + namespace: simstudio +spec: + domains: + - sim.yourdomain.com + - sim-ws.yourdomain.com +``` + +```yaml +ingress: + className: gce + annotations: + kubernetes.io/ingress.global-static-ip-name: "sim-ip" + networking.gke.io/managed-certificates: "sim-ssl-cert" + kubernetes.io/ingress.allow-http: "false" + # TLS comes from the ManagedCertificate — leaving the chart's secret-based + # TLS on makes the ingress reference a Secret that does not exist. + tls: + enabled: false +``` + +The certificate provisions once DNS resolves, typically 15–30 minutes after the first deploy. + +### AWS (ALB ingress) + +```yaml +ingress: + className: alb + annotations: + alb.ingress.kubernetes.io/scheme: internet-facing + alb.ingress.kubernetes.io/target-type: ip + alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]' + alb.ingress.kubernetes.io/certificate-arn: "arn:aws:acm:..." + alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=3600 +``` + +The ALB's default 60-second idle timeout also closes websockets. Raise it as shown. + +### Azure (Application Gateway / NGINX) + +Application Gateway's default request timeout is 30 seconds; raise it in the backend HTTP setting. Many AKS deployments use ingress-nginx instead — see the Nginx tab above. + +## Request size limits + +Sim enforces its own limits in addition to whatever your proxy allows. The proxy limit must be **at least** as large as the app limit, or the proxy rejects the request before Sim ever sees it. + +| Variable | Default | Applies to | +|---|---|---| +| `API_MAX_JSON_BODY_BYTES` | 50 MB | Contract-validated API routes | +| `CHAT_MAX_REQUEST_BYTES` | 220 MB | The public deployed-chat endpoint (covers ~15 base64 file attachments) | +| `WEBHOOK_MAX_REQUEST_BYTES` | 10 MB | Public webhook receiver endpoints | + +A proxy body limit of 250 MB accommodates all three defaults. If you lower the app limits, you can lower the proxy limit to match. + + + **With object storage configured**, regular file uploads do not flow through the proxy — the browser `PUT`s them directly to the bucket using a presigned URL, so the proxy limits matter only for chat attachments, API payloads, and webhook bodies. On the default local-disk storage there is no presigned path and every upload goes through the proxy, so its body limit applies to all of them. + + +## Outbound connectivity + +The app makes outbound calls to model providers, integration APIs, your email provider, and object storage. There is no global forward-proxy setting. Sim does not read `HTTP_PROXY` / `HTTPS_PROXY`, so model-provider calls, integration calls, and email delivery cannot be routed through a forward proxy. (The HTTP Request block accepts a per-request `proxyUrl`, but that covers only that one block, not the platform's own outbound traffic.) Environments with a mandatory egress proxy need a transparent proxy or NAT-based egress instead. + + diff --git a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx index b0dd2d1a03f..64716f9f0f3 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx @@ -40,7 +40,7 @@ If more than one backend is configured, the first match in that order wins. Set ### Create the buckets -Sim separates files into purpose-specific buckets. At minimum you need the general workspace bucket; the rest are created on demand based on which env vars you set. A bucket that isn't configured falls back to the general bucket where the code allows it, but the recommended setup is one bucket per purpose. +Sim separates files into purpose-specific buckets. **Sim never creates buckets** — create each one yourself before configuring it. Only `S3_OG_IMAGES_BUCKET_NAME` and `S3_WORKSPACE_LOGOS_BUCKET_NAME` fall back to the general bucket; the others resolve to their own literal defaults, so set every bucket you intend to use. ```bash # Set your region once @@ -66,6 +66,41 @@ Keep all buckets **private** (block public access). Sim serves files through sho +### Configure CORS on every bucket + +Uploads are sent **directly from the browser** to S3 via presigned `PUT` requests, so each bucket needs a CORS policy that allows your Sim origin. Without this, every upload fails with a CORS error in the browser console even though the server-side configuration is correct. + +```bash +cat > /tmp/cors.json <<'EOF' +{ + "CORSRules": [ + { + "AllowedOrigins": ["https://sim.yourdomain.com"], + "AllowedMethods": ["GET", "PUT"], + "AllowedHeaders": ["*"], + "ExposeHeaders": ["ETag"], + "MaxAgeSeconds": 3600 + } + ] +} +EOF + +for name in workspace-files knowledge-base execution-files chat-files \ + copilot-files profile-pictures og-images workspace-logos; do + aws s3api put-bucket-cors --bucket "myorg-sim-$name" --cors-configuration file:///tmp/cors.json +done +``` + + + `ExposeHeaders` **must** include `ETag`. Files larger than 50 MB use multipart uploads, and the browser reads each part's `ETag` to complete the upload — CORS hides the header otherwise and large uploads fail at the final step. + + +Set `AllowedOrigins` to your exact Sim origin (scheme + host, no trailing slash). Add every origin users reach Sim from, including an apex/`www` pair if both are live. + + + + + ### Grant access with an IAM policy Create an IAM policy scoped to your buckets and attach it to the user (or role) Sim runs as: @@ -80,7 +115,9 @@ Create an IAM policy scoped to your buckets and attach it to the user (or role) "s3:GetObject", "s3:PutObject", "s3:DeleteObject", - "s3:ListBucket" + "s3:ListBucket", + "s3:AbortMultipartUpload", + "s3:ListMultipartUploadParts" ], "Resource": [ "arn:aws:s3:::myorg-sim-*", @@ -136,13 +173,12 @@ Only `AWS_REGION` and `S3_BUCKET_NAME` are strictly required to switch Sim into | `AWS_SECRET_ACCESS_KEY` | Secret key | No (uses credential chain if unset) | | `S3_BUCKET_NAME` | General workspace files | **Yes** (enables S3) | | `S3_KB_BUCKET_NAME` | Knowledge base documents | Recommended | -| `S3_EXECUTION_FILES_BUCKET_NAME` | Workflow execution files (default: `sim-execution-files`) | Recommended | +| `S3_EXECUTION_FILES_BUCKET_NAME` | Workflow execution files. Falls back to the literal name `sim-execution-files`, which you almost certainly do not own — always set this explicitly | **Yes** | | `S3_CHAT_BUCKET_NAME` | Deployed chat assets | Recommended | | `S3_COPILOT_BUCKET_NAME` | Copilot attachments | Recommended | | `S3_PROFILE_PICTURES_BUCKET_NAME` | User avatars | Recommended | | `S3_OG_IMAGES_BUCKET_NAME` | OpenGraph preview images (falls back to `S3_BUCKET_NAME`) | Optional | | `S3_WORKSPACE_LOGOS_BUCKET_NAME` | Workspace logos (falls back to `S3_BUCKET_NAME`) | Optional | -| `S3_LOGS_BUCKET_NAME` | Stored logs | Optional | | `S3_ENDPOINT` | Custom endpoint for S3-compatible storage (R2, MinIO, B2) | Optional (AWS S3 if unset) | | `S3_FORCE_PATH_STYLE` | `true` for path-style addressing (MinIO/Ceph) | Optional (defaults `false`) | @@ -420,11 +456,5 @@ After restarting with the new configuration: If uploads fail, check the app logs for credential or permission errors (see [Troubleshooting](/platform/self-hosting/troubleshooting)). diff --git a/apps/docs/content/docs/en/platform/self-hosting/observability.mdx b/apps/docs/content/docs/en/platform/self-hosting/observability.mdx new file mode 100644 index 00000000000..acf751bb85e --- /dev/null +++ b/apps/docs/content/docs/en/platform/self-hosting/observability.mdx @@ -0,0 +1,188 @@ +--- +title: Observability +description: Health checks, logs, tracing, and what to alert on +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { FAQ } from '@/components/ui/faq' + +## Health endpoints + +| Service | Endpoint | Returns | +|---|---|---| +| app | `GET /api/health` | `{"status":"ok","timestamp":"..."}` | +| realtime | `GET /health` on port 3002 | `{"status":"ok","timestamp":"...","connections":0}` | + + + `/api/health` is a **liveness** signal only. It returns `200` as long as the process is serving HTTP — it does not check the database, Redis, or object storage. A healthy response does not mean the app can serve traffic successfully, so do not treat it as a dependency check. Verify dependencies with the [smoke test](/platform/self-hosting/verify) instead. + + +## Kubernetes probes + +The chart ships probes tuned for a Next.js cold start. Defaults for the app: + +| Probe | Path | Budget | +|---|---|---| +| `startupProbe` | `/` | 60 × 5s = **5 minutes** to become ready | +| `livenessProbe` | `/` | 6 × 30s = 180s of failure before restart | +| `readinessProbe` | `/` | 3 × 10s = ~30s to shift traffic | + +Realtime uses `/health` on port 3002 with a 150-second startup budget. + +The generous startup budget matters: a cold Next.js start on a large bundle can take minutes, and a tighter liveness probe will restart the pod mid-boot in a loop. If you customize probes, keep the startup budget well above your observed cold-start time. + +```yaml +app: + startupProbe: + httpGet: + path: / + port: 3000 + periodSeconds: 5 + failureThreshold: 60 +``` + +## Logs + +Both services log structured JSON to stdout. Collect them with whatever you already run — Fluent Bit, Vector, Datadog Agent, Loki. + + + In production builds the logger defaults to `ERROR`, and the Helm chart does not set `LOG_LEVEL` for the app or realtime. Until you raise it, the only thing in the logs is errors — which is why a healthy-looking deployment can appear to log nothing at all. Set `LOG_LEVEL: "info"` while commissioning a deployment or debugging. + + +```bash +kubectl logs -n simstudio -l app.kubernetes.io/component=app --tail=200 -f +kubectl logs -n simstudio -l app.kubernetes.io/component=realtime --tail=200 -f +kubectl logs -n simstudio deploy/sim-app -c migrations --tail=100 +``` + +```bash +docker compose -f docker-compose.prod.yml logs -f simstudio +``` + +Every API request carries a request ID that appears in all log lines for that request — the fastest way to reconstruct a failing call. + + + Workflow execution logs are a separate, product-level surface stored in the database and visible in the Logs view of the app. They are not the same as container logs: use container logs for infrastructure problems and the Logs view for workflow behavior. + + +### Redacting PII from logs + +Enable the PII service and log redaction if execution logs may contain sensitive data: + +```yaml +pii: + enabled: true +app: + env: + PII_REDACTION: "true" + INTERNAL_API_BASE_URL: "http://sim-app.simstudio.svc.cluster.local:3000" +``` + +See [Security](/platform/self-hosting/security) for the `INTERNAL_API_BASE_URL` requirement — the path fails closed without a cluster-reachable value. + +## Anonymous telemetry + + + **Sim sends anonymous usage telemetry by default.** OpenTelemetry traces are exported to `https://telemetry.simstudio.ai/v1/traces` unless you turn it off. Self-hosted deployments with an egress policy should decide about this explicitly. + + +What is collected, per `apps/sim/telemetry.config.ts`: feature-usage statistics, error rates, performance metrics (sampled at 10%), and AI/LLM operation traces. What is **not** collected: personal information, workflow content or outputs, API keys or tokens, and IP addresses or geolocation. + +Three ways to change it: + +```bash +# Disable entirely +NEXT_TELEMETRY_DISABLED=1 + +# Or redirect to your own OTLP collector instead of Sim's +TELEMETRY_ENDPOINT=http://otel-collector.observability.svc.cluster.local:4318/v1/traces +``` + +Users can also toggle it off individually under **Settings → Privacy → Allow anonymous telemetry**. + +## Tracing + +Sim emits OpenTelemetry traces. The chart can also deploy a collector for you: + +```yaml +telemetry: + enabled: true +``` + +Or point the app at a collector you already run: + +| Variable | Purpose | +|---|---| +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Collector endpoint (OTLP) | +| `OTEL_EXPORTER_OTLP_HEADERS` | Auth headers, `key=value` comma-separated | +| `OTEL_TRACES_SAMPLER_ARG` | Sampling ratio | +| `OTEL_DEPLOYMENT_ENVIRONMENT` | Environment label on emitted spans | +| `TELEMETRY_SAMPLING_RATIO` | Application-level sampling ratio | +| `TELEMETRY_ENDPOINT` | Custom telemetry endpoint | + +For Grafana Cloud specifically: + +| Variable | Purpose | +|---|---| +| `GRAFANA_OTLP_ENDPOINT` | Grafana OTLP endpoint | +| `GRAFANA_OTLP_HEADERS` | e.g. `Authorization=Basic ` | +| `GRAFANA_DEPLOYMENT_ENVIRONMENT` | Deployment tier label | + +If you enable the chart's Jaeger export, point `telemetry.jaeger.endpoint` at Jaeger's **OTLP gRPC port (4317)** — the collector exports over OTLP. + +## Metrics + + + The default app and realtime images **do not expose a `/metrics` endpoint**. The chart's `monitoring.serviceMonitor` option exists for builds that do — enabling it against the stock images produces a ServiceMonitor that scrapes nothing. + + +Until an application metrics endpoint ships, build alerting from the signals that do exist: + +- **Kubernetes state** — pod restarts, `CrashLoopBackOff`, OOMKills, replica count vs desired, PVC utilization (kube-state-metrics). +- **Ingress/load balancer** — request rate, 5xx rate, p99 latency, websocket connection count. +- **PostgreSQL** — connection count vs `max_connections`, replication lag, disk usage, long-running queries. +- **Redis** — memory usage, evictions, connected clients. +- **CronJobs** — last successful completion per job. + +## What to alert on + +| Alert | Why it matters | +|---|---| +| App pod restart loop / OOMKilled | Memory is the constraining resource; OOMKills mean executions are dying mid-run | +| A CronJob has not succeeded within ~3× its own schedule interval | Scheduled workflows and polling triggers are silently dead. Threshold per job — the per-minute jobs justify ~15 minutes; the hourly, twice-daily, and daily jobs need proportionally longer windows | +| Ingress 5xx rate above baseline | Broad user impact | +| Postgres connections above 80% of `max_connections` | Next replica or traffic spike will start failing | +| Postgres disk above 80% | Knowledge base embeddings grow steadily | +| Redis unreachable | Live collaboration and status updates stop, without app errors | +| Certificate expiry within 14 days | Especially with manually managed certs | +| Object storage 4xx/5xx rate | Broken uploads usually show here first | + + + The CronJob alert is the one most deployments lack and most need. Background job failures produce no user-visible error — schedules simply stop firing. Alert on `kube_cronjob_status_last_successful_time` lagging, with a per-job threshold derived from that job's schedule. + + +## Quick diagnosis + +```bash +# Overall state +kubectl get pods,cronjobs -n simstudio + +# Why is a pod unhealthy +kubectl describe pod -n simstudio + +# Did migrations succeed +kubectl logs -n simstudio deploy/sim-app -c migrations --tail=100 + +# Are background jobs running +kubectl get jobs -n simstudio --sort-by=.metadata.creationTimestamp | tail + +# Resource pressure +kubectl top pods -n simstudio +``` + + diff --git a/apps/docs/content/docs/en/platform/self-hosting/platforms.mdx b/apps/docs/content/docs/en/platform/self-hosting/platforms.mdx index c4bfcbdfc77..72152909258 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/platforms.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/platforms.mdx @@ -1,115 +1,52 @@ --- title: Cloud Platforms -description: Deploy Sim on cloud platforms +description: Provider-specific notes for running Sim on Railway, a VPS, or managed Kubernetes --- import { Tab, Tabs } from 'fumadocs-ui/components/tabs' import { Callout } from 'fumadocs-ui/components/callout' +This page covers what differs per provider. The deployment itself is the same everywhere — follow [Docker](/platform/self-hosting/docker) for a single node or [Kubernetes](/platform/self-hosting/kubernetes) for a cluster. + ## Railway One-click deployment with automatic PostgreSQL provisioning. [![Deploy on Railway](https://railway.app/button.svg)](https://railway.com/new/template/sim-studio) -After deployment, add environment variables in Railway dashboard: -- `BETTER_AUTH_SECRET`, `ENCRYPTION_KEY`, `INTERNAL_API_SECRET` (auto-generated) -- `OPENAI_API_KEY` or other AI provider keys -- Custom domain in Settings → Networking +After deployment, set in the Railway dashboard: -## VPS Deployment +- `BETTER_AUTH_SECRET`, `ENCRYPTION_KEY`, `INTERNAL_API_SECRET` (auto-generated by the template) +- An AI provider key such as `OPENAI_API_KEY` +- Your custom domain under **Settings → Networking**, then `NEXT_PUBLIC_APP_URL` to match -For DigitalOcean, AWS EC2, Azure VMs, or any Linux server: + + The Railway template deploys the app services but not the `cron` service, so scheduled workflows and polling triggers stay idle. Add a Railway cron service calling the endpoints in [Background Jobs](/platform/self-hosting/background-jobs), or deploy with Docker Compose instead. + - - -**Recommended:** 16 GB RAM Droplet, Ubuntu 24.04 +## VPS -```bash -# Create Droplet via console, then SSH in -ssh root@your-droplet-ip -``` - - -**Recommended:** t3.xlarge (16 GB RAM), Ubuntu 24.04 +DigitalOcean, EC2, Azure VM, Hetzner, or any Linux box. Size it from the [requirements table](/platform/self-hosting) — 16 GB RAM is the practical floor for a team install, because memory rather than CPU is what bounds concurrent workflow executions. -```bash -ssh -i your-key.pem ubuntu@your-ec2-ip -``` - - -**Recommended:** Standard_D4s_v3 (16 GB RAM), Ubuntu 24.04 +Install Docker via [get.docker.com](https://get.docker.com), then follow the [Docker guide](/platform/self-hosting/docker), which covers secrets, the compose stack, and TLS. -```bash -ssh azureuser@your-vm-ip -``` - - +## Managed Kubernetes -### Install Docker +EKS, AKS, and GKE each have a tuned example values file in the chart. See [Kubernetes](/platform/self-hosting/kubernetes) for the install and [Networking](/platform/self-hosting/networking) for the load-balancer specifics — notably GKE's 30-second websocket timeout and its `ManagedCertificate` requirement. -```bash -# Install Docker (official method) -curl -fsSL https://get.docker.com | sudo sh -sudo usermod -aG docker $USER +## Managed PostgreSQL -# Logout and reconnect, then verify -docker --version -``` +Recommended for any production deployment. The requirement is **pgvector**. -### Deploy Sim +| Service | Notes | +|---|---| +| AWS RDS / Aurora | Enable the `vector` extension | +| GCP Cloud SQL | Enable the `vector` extension | +| Azure Database for PostgreSQL | Enable the `vector` extension | +| Supabase / Neon | pgvector available by default | ```bash -git clone https://github.com/simstudioai/sim.git && cd sim - -# Create .env with secrets -cat > .env << EOF -DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio -BETTER_AUTH_SECRET=$(openssl rand -hex 32) -ENCRYPTION_KEY=$(openssl rand -hex 32) -INTERNAL_API_SECRET=$(openssl rand -hex 32) -NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com -BETTER_AUTH_URL=https://sim.yourdomain.com -EOF - -# Start -docker compose -f docker-compose.prod.yml up -d +DATABASE_URL="postgresql://user:pass@host:5432/simstudio?sslmode=require" ``` -### SSL with Caddy - -```bash -# Install Caddy -sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl -curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg -curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list -sudo apt update && sudo apt install caddy - -# Configure (replace domain) -echo 'sim.yourdomain.com { - reverse_proxy localhost:3000 - handle /socket.io/* { - reverse_proxy localhost:3002 - } -}' | sudo tee /etc/caddy/Caddyfile - -sudo systemctl restart caddy -``` - -Point your domain's DNS A record to your server IP. - -## Kubernetes (EKS, AKS, GKE) - -See the [Kubernetes guide](/platform/self-hosting/kubernetes) for Helm deployment on managed Kubernetes. - -## Managed Database (Optional) - -For production, use a managed PostgreSQL service: - -- **AWS RDS** / **Azure Database** / **Cloud SQL** - Enable pgvector extension -- **Supabase** / **Neon** - pgvector included - -Set `DATABASE_URL` in your environment: -```bash -DATABASE_URL="postgresql://user:pass@host:5432/db?sslmode=require" -``` +For the Helm chart, disable the bundled Postgres and use `externalDatabase` — see [Kubernetes](/platform/self-hosting/kubernetes#external-database). diff --git a/apps/docs/content/docs/en/platform/self-hosting/redis.mdx b/apps/docs/content/docs/en/platform/self-hosting/redis.mdx new file mode 100644 index 00000000000..99f55957ced --- /dev/null +++ b/apps/docs/content/docs/en/platform/self-hosting/redis.mdx @@ -0,0 +1,115 @@ +--- +title: Redis +description: When Redis is optional, when it is required, and how to configure it +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { Callout } from 'fumadocs-ui/components/callout' +import { FAQ } from '@/components/ui/faq' + +Sim uses Redis as a message bus and shared cache. Both deployments ship it by default — Docker Compose as a `redis` service, Helm as a `redis` Deployment — so this page is mostly about when to replace the bundled instance with a managed one, and what breaks if Redis is absent entirely. + +## What it backs + +| Use | Without Redis | +|---|---| +| **Pub/sub** — live Chat task status, table events, execution cancellation, MCP tool-change notifications, copilot tool confirmations | Falls back to a **process-local** emitter: events never leave the pod that produced them | +| **Socket.IO adapter** (realtime) | Collaboration events are not delivered across realtime pods | +| Idempotency store | Falls back to PostgreSQL | +| Execution progress markers | Falls back to PostgreSQL | +| Distributed execution limits | Enforced per-pod instead of per-deployment | +| CLI auth approval store | **No fallback** — CLI authentication requires Redis regardless of replica count | +| Collaborative document store (realtime) | Falls back to in-process state | + + + With more than one app or realtime replica and no `REDIS_URL`, users on different pods stop seeing each other's edits and live status updates. Beyond one startup log line noting single-pod mode, nothing is logged — the app looks healthy and quietly loses events. Treat Redis as mandatory the moment `replicaCount` exceeds 1. + + +## Configuration + +```bash +REDIS_URL=redis://:password@redis-host:6379 +# or, with TLS +REDIS_URL=rediss://:password@redis-host:6380 +``` + +Both the app and the realtime service need it — they use it for different things. + + + + +`docker-compose.prod.yml` already includes a `redis:7-alpine` service and wires `REDIS_URL=redis://redis:6379` into both the app and realtime containers. Nothing to configure. + +The port is deliberately not published to the host, so a Redis already running locally will not collide. Override `REDIS_URL` in `.env` to point at an external instance instead. + + + + +The chart deploys Redis by default, matching the Compose stack. Nothing to configure. + +For production, prefer a managed instance — disable the bundled one and supply a URL: + +```yaml +redis: + enabled: false + +app: + env: + REDIS_URL: "rediss://:@my-cache.internal:6380" +``` + +`app.env.REDIS_URL` takes over whenever it is set, and the chart skips the bundled Deployment so you do not get a stray pod. + +If the URL lives in a secret store instead — a pre-created Secret or one synced by External Secrets — it also wins, and there is nothing extra to configure. The bundled URL is delivered as a ConfigMap listed before the app Secret in `envFrom`, and Kubernetes lets the last source win for duplicate keys, so your value overrides it without the chart ever reading it. + +The bundled Redis is deliberately non-persistent (`--save ""`, `--appendonly no`) with a 512 MB cap: Sim stores coordination state and short-lived keys in it, so a restart costs in-flight live updates rather than committed data. + +If `networkPolicy.enabled=true`, egress to the bundled Redis is allowed automatically. An **external** Redis needs its own rule under `networkPolicy.egress` — the chart cannot know your host and port at render time. + + + + +Managed services work and are the recommended production choice: + +- **AWS** — ElastiCache for Redis or MemoryDB +- **GCP** — Memorystore for Redis +- **Azure** — Azure Cache for Redis + +Place the instance in the same VPC/VNet as the cluster and use its private endpoint. Enable TLS (`rediss://`) and auth. + +Sizing is modest: Sim uses Redis for coordination, not bulk storage. A 1–2 GB instance covers most deployments. Prefer a replicated/HA tier so a failover does not interrupt live collaboration. + + + + +## TLS to an IP address + +If `REDIS_URL` uses `rediss://` and the host is a **bare IP** — common with AWS PrivateLink endpoints — TLS hostname verification cannot match an IP against the certificate. Sim throws rather than connecting insecurely, the first time it opens a Redis connection. Set the SNI override to the DNS name the certificate was issued for: + +```bash +REDIS_URL=rediss://:password@10.0.12.34:6379 +REDIS_TLS_SERVERNAME=my-cluster.abc123.ng.0001.use1.cache.amazonaws.com +``` + +With a DNS hostname in `REDIS_URL`, default verification works and no override is needed. + +## Verifying + +```bash +# Kubernetes +kubectl exec -n simstudio deploy/sim-app -- printenv REDIS_URL + +# Docker Compose +docker compose -f docker-compose.prod.yml exec redis redis-cli ping # PONG +``` + +The functional test: open the same workflow in two browser windows served by different replicas and confirm edits appear in both. With a single replica this always passes, so scale to two before testing. + +Watch the app logs at startup for `Redis` connection errors — a wrong password or unreachable host is logged there. + + diff --git a/apps/docs/content/docs/en/platform/self-hosting/scaling.mdx b/apps/docs/content/docs/en/platform/self-hosting/scaling.mdx new file mode 100644 index 00000000000..81e9c81b232 --- /dev/null +++ b/apps/docs/content/docs/en/platform/self-hosting/scaling.mdx @@ -0,0 +1,169 @@ +--- +title: Scaling & High Availability +description: Replicas, connection pooling, autoscaling, and where the limits actually are +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { FAQ } from '@/components/ui/faq' + +## What scales how + +| Component | Scaling | Notes | +|---|---|---| +| **app** | Horizontal | Stateless. **Requires Redis** past one replica | +| **realtime** | Horizontal | **Requires Redis** past one replica (Socket.IO adapter) | +| **postgresql** | Vertical + read replicas | The eventual bottleneck | +| **redis** | Vertical / HA pair | Coordination only; small | +| **cronjobs** | Fixed | One call per tick regardless of replica count | + +## Prerequisites before scaling past one replica + + + Redis must be reachable before you raise `replicaCount`. Both deployments ship it by default, so this is already satisfied unless you set `redis.enabled: false` (Helm) or removed the `redis` service (Compose) without supplying `REDIS_URL`. Without Redis, pub/sub falls back to a process-local emitter and the Socket.IO adapter has no cross-pod transport — realtime logs one line at startup noting single-pod mode, then drops cross-pod events silently. See [Redis](/platform/self-hosting/redis). + + +You also need shared object storage — local-disk storage is per-pod, so a file uploaded through one replica is invisible to the others. See [Object Storage](/platform/self-hosting/object-storage). + +## Scaling the app + +```yaml +app: + replicaCount: 3 + resources: + limits: + memory: 8Gi + cpu: 2000m + requests: + memory: 4Gi + cpu: 1000m +``` + +**Memory is the constraint, not CPU.** Workflow executions run inside the app process in isolated-vm sandboxes, and file parsing happens in memory. Production telemetry shows 4–8 GB steady with peaks to 12 GB under heavy execution load. Under-provision memory and you get OOMKills that terminate in-flight workflow runs. + +A PodDisruptionBudget is created automatically once `replicaCount > 1` (`maxUnavailable: 25%`). Tighten it with `podDisruptionBudget.minAvailable` if you need to. + +### Autoscaling + +```yaml +autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 70 + targetMemoryUtilizationPercentage: 80 +``` + +Requires `metrics-server`. When enabled, the chart omits `spec.replicas` so the HPA owns replica count. + + + Scale-down terminates pods that may be running workflows. Set a conservative `minReplicas`, and consider a `behavior` block with a long `stabilizationWindowSeconds` on scale-down so long executions are not repeatedly interrupted. + + +Realtime gets the same HPA unless you disable it — and again, only scale it past one replica with Redis configured: + +```yaml +autoscaling: + realtime: + enabled: false +``` + +## Database + +Postgres is where scaling eventually stops being about replicas. + +### Connections + +Each app replica opens a pool. Total connections grow with replica count, and Postgres has a hard `max_connections`. A deployment that works at 2 replicas can exhaust connections at 6. + +Budget it: `replicas × pool size + realtime + cronjobs + migrations + headroom` must stay under `max_connections`. + +For anything beyond a handful of replicas, put **PgBouncer** in transaction pooling mode in front of the database and point `DATABASE_URL` at it. This is the single highest-leverage change for a large deployment — it decouples app replica count from database connection count. + +### Read replicas + +Heavy read paths — log listing, audit logs, dashboard aggregations — can be offloaded: + +```bash +DATABASE_REPLICA_URL=postgresql://user:pass@replica-host:5432/simstudio +``` + +Reads fall back to the primary when unset. Per-role overrides exist if different components should use different replicas: + +| Variable | Applies to | +|---|---| +| `DATABASE_REPLICA_URL` | Default for all roles | +| `DATABASE_REPLICA_URL_WEB` | The web app | +| `DATABASE_REPLICA_URL_REALTIME` | The realtime service | +| `DATABASE_REPLICA_URL_TRIGGER` | Trigger.dev workers | + + + Replicas lag. Sim routes only latency-tolerant reads to them, but if your replica lags badly, recently written logs may briefly not appear. Monitor replication lag. + + +### Sizing + +| Deployment | Instance | Storage | +|---|---|---| +| Small (1–5 users) | 2 vCPU / 8 GB | 50 GB | +| Standard (5–50 users) | 4 vCPU / 16 GB | 100 GB+ | +| Large (50+ users) | 8+ vCPU / 32 GB+ | 250 GB+, auto-grow | + +Knowledge base embeddings are the main growth driver — vector storage scales with document volume, not user count. Enable storage auto-increase. + +## Execution concurrency + +`SCHEDULE_EXECUTION_CONCURRENCY_LIMIT` (default `30`) bounds scheduled executions per app instance. The other three `*_EXECUTION_CONCURRENCY_LIMIT` variables apply only to Trigger.dev and are inert on a default self-host — see [Background Jobs](/platform/self-hosting/background-jobs#concurrency). + +When executions queue but memory is fine, raise the limit; when memory is the ceiling, add replicas instead. + +## Rate limits and quotas + +Self-hosted deployments run **without plan limits by default** — no rate limits, execution timeouts, or table and storage caps. Each can be opted back in individually; the variable list and suggested values are in [Environment Variables](/platform/self-hosting/environment-variables#limits). + +An execution timeout is worth setting even on an otherwise unlimited deployment — it is what stops a runaway workflow from holding a sandbox indefinitely. + +## Reference topology + +A production deployment serving ~100 active users: + +```yaml +app: + replicaCount: 3 + resources: + limits: { memory: 8Gi, cpu: 2000m } + requests: { memory: 4Gi, cpu: 1000m } + env: + REDIS_URL: "rediss://:@redis.internal:6380" + +realtime: + replicaCount: 2 + env: + REDIS_URL: "rediss://:@redis.internal:6380" + +postgresql: + enabled: false + +externalDatabase: + enabled: true + host: "pgbouncer.internal" + port: 6432 + database: simstudio + sslMode: require + +autoscaling: + enabled: true + minReplicas: 3 + maxReplicas: 10 + +podDisruptionBudget: + minAvailable: 2 +``` + +Plus: managed Postgres with PITR, managed Redis in an HA tier, object storage with versioning, and images pinned to an explicit tag. + + diff --git a/apps/docs/content/docs/en/platform/self-hosting/security.mdx b/apps/docs/content/docs/en/platform/self-hosting/security.mdx new file mode 100644 index 00000000000..af055d4b34f --- /dev/null +++ b/apps/docs/content/docs/en/platform/self-hosting/security.mdx @@ -0,0 +1,195 @@ +--- +title: Security & Hardening +description: Secrets, network boundaries, code sandboxing, and the settings to review before going live +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { FAQ } from '@/components/ui/faq' + +## Secrets + +Five secrets drive the security of a deployment. Generate each with `openssl rand -hex 32`. + +| Secret | Protects | Rotatable | +|---|---|---| +| `BETTER_AUTH_SECRET` | Session tokens | Yes — invalidates all sessions | +| `ENCRYPTION_KEY` | Workspace env vars, stored provider keys, MCP OAuth credentials, deployment/chat secrets | **No** — see below | +| `API_ENCRYPTION_KEY` | Reversible stored copy of user-generated API keys | **No** — existing keys keep authenticating, but their stored copy can no longer be displayed | +| `INTERNAL_API_SECRET` | Service-to-service calls | Yes — roll app and realtime together | +| `CRON_SECRET` | Background job endpoints | Yes — roll app and cron together | + + + `ENCRYPTION_KEY` cannot be rotated without re-encrypting the data it protects, and cannot be recovered if lost. Changing it renders all of that data permanently unreadable. Back it up independently of the database. + + +`BETTER_AUTH_SECRET` must be **identical** on the app and realtime services — they share sessions through the database, and a mismatch means realtime rejects every authenticated socket. + +### Storing them + +In increasing order of production-readiness: + +1. **`--set` on the command line** — dev only. Values appear in `helm get values` output and shell history. +2. **A pre-created Kubernetes Secret** — set `app.secrets.existingSecret.enabled: true` and the secret name. Works with Sealed Secrets and SOPS. The secret is consumed wholesale and must use the standard key names. +3. **External Secrets Operator** — sync from Vault, AWS Secrets Manager, Azure Key Vault, or GCP Secret Manager. Recommended. + +In the default and External Secrets modes, the chart writes every key under `app.env` and `realtime.env` into a chart-managed Secret mounted via `envFrom`, so no value is inlined into a pod spec. (In `existingSecret` mode the pre-created Secret is the source of truth and any `app.env` values you still pass are rendered inline — supply everything through the Secret in that mode.) Either way, a secret committed to `values.yaml` is a secret in your git history. + +## Network boundaries + +### Ingress + +Expose only the app (3000) and realtime (3002). Everything else — Postgres, Redis, the PII service, the cron endpoints — should be reachable only from inside the deployment. + +The background job endpoints under `/api/cron/*`, `/api/webhooks/poll/*`, and `/api/schedules/execute` are authenticated by `CRON_SECRET`, but there is no reason to expose them publicly. Point cron at the in-cluster Service. + +### NetworkPolicy + +The chart ships an optional policy that isolates east-west traffic and blocks cloud metadata endpoints (`169.254.169.254/32`, `169.254.170.2/32`) on egress — worth enabling, because those endpoints are the standard SSRF escalation target. + +```yaml +networkPolicy: + enabled: true +``` + + + `networkPolicy.ingressFrom` defaults to `[{}]` — an empty peer selector that allows ingress from **any pod in the cluster**. On a shared or multi-tenant cluster, scope it to your ingress controller: + + ```yaml + networkPolicy: + ingressFrom: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + ``` + + +The policy already allows HTTPS (443) egress to everything except the metadata CIDRs, which covers model provider APIs, integration APIs, and cloud object-storage endpoints. It also allows the bundled Postgres and Redis by pod selector. + +What it does **not** cover is any datastore you run outside the chart — a managed Postgres or Redis on a non-443 port. Add a rule for each: + +```yaml +networkPolicy: + enabled: true + egress: + - to: + - ipBlock: + cidr: 10.0.0.0/16 # your VPC / managed-service subnet + ports: + - protocol: TCP + port: 6379 # managed Redis + - protocol: TCP + port: 5432 # managed Postgres +``` + + + This applies even when `REDIS_URL` reaches the pod through a Secret rather than `values.yaml` — the chart cannot see the host, so it cannot generate the rule. A deployment that accepts the URL but has no matching egress rule will fail to reach Redis with `networkPolicy.enabled: true`. + + +If maintaining CIDR lists is not worth it, drop the port restriction instead: + +```yaml +networkPolicy: + enabled: true + allowExternalEgress: true +``` + +Cloud metadata endpoints stay blocked either way. This defaults to `false` because Sim's chart is deliberately stricter than the common chart default, which permits unrestricted egress. + +### Pod Security Standards + +All workloads set `runAsNonRoot`, drop all Linux capabilities, disable privilege escalation, and use `seccompProfile: RuntimeDefault` — the four controls the `restricted` profile requires. Label the namespace to enforce it: + +```bash +kubectl label namespace simstudio pod-security.kubernetes.io/enforce=restricted +``` + +`readOnlyRootFilesystem` is not set by default: Postgres and Ollama need a writable root, and the app container writes to Next.js's `.next/cache`. It is viable on the genuinely stateless services (`realtime`, `pii`, `copilot`) — set `.securityContext.readOnlyRootFilesystem: true` and mount an `emptyDir` at `/tmp` via `extraVolumes` / `extraVolumeMounts`. + +## Where user code runs + +Workflows can execute user-authored JavaScript and Python. Know which sandbox you are running before you expose Sim to untrusted authors. + +| Mode | Configuration | Isolation | +|---|---|---| +| **isolated-vm** (default) | none | In-process V8 isolate inside the app container. No network namespace or filesystem separation from the app process — isolation is at the JS-engine level. JavaScript only. | +| **E2B** | `E2B_ENABLED=true`, `E2B_API_KEY` | Remote sandbox per execution. Strongest isolation; requires outbound access to E2B. | +| **Daytona** | `SANDBOX_PROVIDER=daytona`, `DAYTONA_API_KEY` | Remote sandbox per execution. | + +Python execution and the tooling-dependent blocks require a remote sandbox provider — the in-process isolate runs JavaScript only. + + + With the default in-process sandbox, treat everyone who can author a workflow as someone running code in your app container's security context. If your Sim instance is open to a wide or partly-trusted audience, use a remote sandbox provider and enable the NetworkPolicy egress restrictions. + + +Resource ceilings for the in-process path: + +| Variable | Controls | +|---|---| +| `IVM_MAX_EXECUTIONS_PER_WORKER` | Executions before a worker is recycled | +| `IVM_MAX_BROKERS_PER_EXECUTION` | Host-call brokers per execution | +| `IVM_MAX_BROKER_ARGS_JSON_CHARS` | Max argument payload size | +| `IVM_MAX_BROKER_RESULT_JSON_CHARS` | Max result payload size | + +## The SSRF boundary + +Sim blocks outbound requests from database and connector tools to private, reserved, and loopback addresses. This stops a workflow from being used to scan your internal network. + +Self-hosted deployments often legitimately need to reach an internal database by service name. That is opt-in: + +```bash +ALLOW_PRIVATE_DATABASE_HOSTS=true +``` + + + This loosens the SSRF boundary for every workflow author on the instance. Enable it only on a trusted private network, and prefer pairing it with a NetworkPolicy that constrains what the app can actually reach. + + +## Client IP and forwarded headers + +Behind a load balancer, `X-Forwarded-For` is client-controllable. Set `AUTH_TRUSTED_PROXIES` to your proxies' actual addresses so Better Auth resolves the real client IP, and `TRUSTED_ORIGINS` if users reach Sim from more than one origin. Both are covered in [Authentication](/platform/self-hosting/authentication#behind-a-load-balancer). + +## Restricting who can use the instance + +Signup allowlists and blocklists, social-login toggles, SSO, and the `DISABLE_AUTH` escape hatch are all covered in [Authentication](/platform/self-hosting/authentication). The security-relevant summary: restrict signup before exposing the instance, and never set `DISABLE_AUTH=true` behind an internet-facing ingress. + +## PII redaction + +The optional Presidio-based service supports the Guardrails PII block and, when enabled, automatic redaction of PII from workflow logs: + +```yaml +pii: + enabled: true + +app: + env: + PII_REDACTION: "true" + INTERNAL_API_BASE_URL: "http://sim-app.simstudio.svc.cluster.local:3000" +``` + +`INTERNAL_API_BASE_URL` must be the **in-cluster** Service URL. The redaction path calls the app's own API, and a public ingress URL is usually not hairpin-reachable from inside the cluster. Without a reachable value the path fails closed — affected fields are scrubbed to `[REDACTION_FAILED]` rather than leaking, but redaction does not actually run. + +The service bundles ~2.2 GB of spaCy models, so first start takes around three minutes and it needs at least 4 GB of memory. + +## Pre-launch checklist + +- [ ] All five secrets generated fresh, stored in a secret manager, and **`ENCRYPTION_KEY` backed up separately** +- [ ] `BETTER_AUTH_SECRET` identical on app and realtime +- [ ] Images pinned to an explicit tag or digest on app, realtime, and migrations +- [ ] TLS terminating at the ingress; HTTP redirected or disabled +- [ ] `NEXT_PUBLIC_APP_URL` and `BETTER_AUTH_URL` set to the real public origin +- [ ] `AUTH_TRUSTED_PROXIES` set if behind a load balancer +- [ ] Signup restricted (`DISABLE_REGISTRATION` or `ALLOWED_LOGIN_DOMAINS`) +- [ ] `DISABLE_AUTH` **not** set +- [ ] NetworkPolicy enabled and `ingressFrom` scoped to the ingress controller +- [ ] Namespace labelled `pod-security.kubernetes.io/enforce=restricted` +- [ ] Object storage buckets private, with CORS limited to your Sim origin +- [ ] Database reachable only from the deployment; TLS enforced (`sslMode: require`) +- [ ] Backups configured **and a restore rehearsed** +- [ ] Sandbox strategy decided for user code + + diff --git a/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx b/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx index 2f833c6d7d7..ea28f8b3a1f 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx @@ -43,12 +43,20 @@ docker compose logs simstudio ## Migration Errors +Migrations run in their own `migrations` service and image — the app image does not contain the migration tooling. + ```bash # View migration logs -docker compose logs migrations +docker compose -f docker-compose.prod.yml logs migrations + +# Re-run them +docker compose -f docker-compose.prod.yml up --force-recreate migrations +``` -# Run manually -docker compose exec simstudio bun run db:migrate +On Kubernetes, migrations are an init container on the app pod: + +```bash +kubectl logs -n simstudio deploy/sim-app -c migrations --tail=200 ``` ## pgvector Not Found @@ -62,14 +70,37 @@ image: pgvector/pgvector:pg17 # NOT postgres:17 If you see SSL certificate errors when calling external APIs: -```bash -# Update CA certificates in container -docker compose exec simstudio apt-get update && apt-get install -y ca-certificates +The image already ships current CA certificates and runs as a non-root user, so installing packages inside it is not the fix. This almost always means the endpoint presents a certificate signed by a private CA — a corporate TLS-inspecting proxy, or an internal service. + +Mount your CA bundle and point Node at it: -# Or set in environment (not recommended for production) -NODE_TLS_REJECT_UNAUTHORIZED=0 +```yaml +# docker-compose.prod.yml +services: + simstudio: + volumes: + - /etc/ssl/certs/corporate-ca.crt:/certs/corporate-ca.crt:ro + environment: + - NODE_EXTRA_CA_CERTS=/certs/corporate-ca.crt ``` +```yaml +# Helm — mount a ConfigMap holding the CA +app: + env: + NODE_EXTRA_CA_CERTS: /certs/corporate-ca.crt + extraVolumes: + - name: corporate-ca + configMap: + name: corporate-ca + extraVolumeMounts: + - name: corporate-ca + mountPath: /certs + readOnly: true +``` + +`NODE_TLS_REJECT_UNAUTHORIZED=0` disables certificate verification entirely and should never be used outside a throwaway test. + ## Blank Page After Login 1. Check browser console for errors @@ -79,13 +110,12 @@ NODE_TLS_REJECT_UNAUTHORIZED=0 ## Windows-Specific Issues -**Turbopack errors on Windows:** +These apply to running Sim **from source** for development, not to the Docker or Kubernetes deployments, which are unaffected by the host OS. + +**Turbopack errors on Windows:** use WSL2. + ```bash -# Use WSL2 for better compatibility wsl --install - -# Or disable Turbopack in package.json -# Change "next dev --turbopack" to "next dev" ``` **Line ending issues:** @@ -104,6 +134,106 @@ docker compose logs -f docker compose logs -f simstudio ``` +## Scheduled Workflows Never Run + +The most common self-hosting surprise. + +**Docker Compose** — check the `cron` service is running and read its logs: + +```bash +docker compose -f docker-compose.prod.yml logs --tail=50 cron +``` + +A `401` there means the app and the scheduler disagree on `CRON_SECRET`. + +**Kubernetes** — check the CronJobs are present and firing: + +```bash +kubectl get cronjobs -n simstudio +kubectl get jobs -n simstudio --sort-by=.metadata.creationTimestamp | tail +``` + +A stale `LAST SCHEDULE` or failing jobs usually means `CRON_SECRET` is missing or does not match between the cron pods and the app. Call the endpoint by hand to see the status code: + +```bash +kubectl exec -n simstudio deploy/sim-app -- sh -c \ + 'curl -s -o /dev/null -w "%{http_code}\n" \ + -H "Authorization: Bearer $CRON_SECRET" \ + http://localhost:3000/api/schedules/execute' +``` + +Wrap it in `sh -c` with single quotes so `$CRON_SECRET` expands **inside the pod** — otherwise your local shell substitutes an empty value and you get a misleading `401`. + +`401` means the secret does not match. `202` means the endpoint accepted the run; it does not tell you whether a schedule was actually due, so confirm in the Logs view. + +## Gmail / Drive / Outlook Triggers Never Fire + +These are **polling** triggers, driven by the per-minute `/api/webhooks/poll/*` jobs. Check the scheduler is running them — `docker compose logs cron`, or `kubectl get cronjobs -n simstudio` for a recent `LAST SCHEDULE`. + +Microsoft Teams chat triggers are the different case: they use a Microsoft Graph subscription capped at about three days, renewed by the twice-daily `renew-subscriptions` job. If Teams triggers work for a couple of days and then stop, that job is not running. See [Background Jobs](/platform/self-hosting/background-jobs). + +## Collaboration Breaks With Multiple Replicas + +Two users editing the same workflow stop seeing each other, or live status never updates — with no error anywhere. + +This is Redis. Pub/sub and the Socket.IO adapter have no cross-pod fallback: + +```bash +kubectl exec -n simstudio deploy/sim-app -- printenv REDIS_URL +kubectl exec -n simstudio deploy/sim-realtime -- printenv REDIS_URL +``` + +Both pods must have `REDIS_URL`. On Helm they share one Secret, so setting it under `app.env` covers both. See [Redis](/platform/self-hosting/redis). + +## App Crashes at Startup With a REDIS_TLS_SERVERNAME Error + +`REDIS_URL` uses `rediss://` pointed at a bare IP address. TLS certificates cannot be verified against an IP, so set `REDIS_TLS_SERVERNAME` to the DNS name the certificate was issued for — or use a DNS hostname in the URL instead. + +## File Uploads Fail With a CORS Error + +The bucket's CORS policy does not allow your Sim origin. Uploads go directly from the browser to object storage via presigned `PUT`, so server-side configuration being correct is not enough. + +If small uploads succeed but files over 50 MB fail at the last step, `ETag` is missing from the CORS exposed headers — multipart uploads read it from the browser. See [Object Storage](/platform/self-hosting/object-storage). + +## Agent Output Arrives All at Once + +Your reverse proxy is buffering the response stream. Set `proxy_buffering off` (Nginx) or `flush_interval -1` (Caddy). See [Networking](/platform/self-hosting/networking). + +## Websockets Disconnect Every 30 Seconds + +The load balancer's backend timeout is closing them. On GKE, attach a `BackendConfig` with `timeoutSec: 3600` to the realtime Service; on AWS, raise the ALB `idle_timeout`. Clients reconnect, so this degrades rather than breaks. See [Networking](/platform/self-hosting/networking). + +## Knowledge Base Upload Fails + +Embeddings need a hosted provider — set `OPENAI_API_KEY`, configure Azure OpenAI, or set `KB_EMBEDDING_MODEL=gemini-embedding-001` with a Gemini key. There is no local embedding backend, so Ollama or vLLM does not substitute. If a key is set, verify pgvector is installed on the database. + +## Credentials Unreadable After a Restore + +Integrations show as connected but fail, or provider keys error on decrypt. `ENCRYPTION_KEY` does not match the value in use when the backup was taken. There is no recovery — the original key must be restored. + +## Kubernetes: App Pods Never Become Ready + +Check the migrations init container first — a failed migration deliberately blocks the rollout: + +```bash +kubectl logs -n simstudio deploy/sim-app -c migrations --tail=200 +kubectl describe pod -n simstudio +``` + +Common causes: `DATABASE_URL` unreachable, the database user lacking rights to create the `vector` extension, or an OOMKill from insufficient memory. See [Upgrades](/platform/self-hosting/upgrades) for migration-failure recovery. + +## Kubernetes: ImagePullBackOff + +Either the tag does not exist in the registry (`helm get values sim` and check), or you are pulling from a private registry without `global.imagePullSecrets`. When mirroring into a private registry, set `global.useRegistryForAllImages: true` — otherwise third-party images still point at Docker Hub. + +## Kubernetes: Postgres Pod Pending + +```bash +kubectl describe pvc -n simstudio +``` + +Almost always no default StorageClass, no PV provisioner installed, or a StorageClass that does not support `ReadWriteOnce`. Set `global.storageClass` to pick a specific one. + ## Getting Help - [GitHub Issues](https://github.com/simstudioai/sim/issues) diff --git a/apps/docs/content/docs/en/platform/self-hosting/upgrades.mdx b/apps/docs/content/docs/en/platform/self-hosting/upgrades.mdx new file mode 100644 index 00000000000..69b83291f99 --- /dev/null +++ b/apps/docs/content/docs/en/platform/self-hosting/upgrades.mdx @@ -0,0 +1,216 @@ +--- +title: Upgrades +description: Pinning versions, running migrations safely, and rolling back +--- + +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { FAQ } from '@/components/ui/faq' + +## Pin your version + + + Never run `:latest` in production. An unpinned tag means an unplanned restart can pull a new version with new migrations at an arbitrary time. + + +Sim publishes images to GHCR, tagged by release alongside `latest`: + +``` +ghcr.io/simstudioai/simstudio +ghcr.io/simstudioai/realtime +ghcr.io/simstudioai/migrations +``` + + + **Pin `app`, `realtime`, and `migrations` to the same tag.** They share a database schema. An app newer than its migrations runs against a schema missing columns it expects; an app older than its migrations runs against a schema it does not understand. Mismatched tags is the most common self-inflicted upgrade failure. + + + + + +```yaml +app: + image: + tag: "v1.2.3" # a tag from the releases page +realtime: + image: + tag: "v1.2.3" # a tag from the releases page +migrations: + image: + tag: "v1.2.3" # a tag from the releases page +``` + +When `image.tag` is unset it defaults to the chart's `appVersion`, which moves when you upgrade the chart. Setting it explicitly decouples the two. For maximum determinism, pin `image.digest` instead: + +```yaml +app: + image: + digest: "sha256:..." +``` + + + + +Images track `latest` by default. To pin, set `SIM_VERSION` in `.env` to a tag from the [releases page](https://github.com/simstudioai/sim/releases): + +```bash +# .env +SIM_VERSION=v1.2.3 +``` + +One variable drives all three schema-coupled images, so they cannot drift apart. The `cron` service is deliberately excluded — it only makes HTTP calls and shares no schema, so it tracks `latest` unless you pin `SIM_CRON_VERSION`. + + + + +## How migrations run + +Migrations are Drizzle SQL files applied by a dedicated image. + +- **Kubernetes** — an **init container on the app Deployment**. Every app pod waits for migrations to complete before it starts, so a failed migration blocks the rollout instead of starting an app against a mismatched schema. The container is idempotent, so it is a no-op on pods that start after the first. +- **Docker Compose** — a one-shot `migrations` service with `restart: no` that runs before the app. + +Migrations are **forward-only**. There are no down-migrations, which is why the pre-upgrade backup below is not optional. + +## Upgrade procedure + + + + + +### Read the release notes + +Check the [releases page](https://github.com/simstudioai/sim/releases) for new required environment variables and breaking changes. When the chart's minor version moves, also read its `README.md` upgrade notes — chart upgrades occasionally rename or remove values keys. + + + + + +### Take a backup + +Snapshot the database immediately before upgrading. Because migrations are forward-only, this snapshot is your only rollback path for schema changes. + +```bash +# Managed Postgres — take a manual snapshot +aws rds create-db-snapshot --db-instance-identifier sim-db \ + --db-snapshot-identifier "sim-pre-upgrade-$(date +%Y%m%d)" + +# Bundled Postgres — the Helm chart's database is named `sim` by default +# (Docker Compose uses `simstudio`; the cloud example values files override to `simstudio`) +kubectl exec -n simstudio statefulset/sim-postgresql -- \ + pg_dump -U postgres -Fc sim > "pre-upgrade-$(date +%F).dump" +``` + + + + + +### Rehearse against real data + +Migration surprises are usually data-shaped rather than schema-shaped, so a staging run against a copy of production data catches far more than a run against an empty database. + + + + + +### Apply + + + + +```bash +helm upgrade sim ./helm/sim \ + --namespace simstudio \ + --values my-values.yaml +``` + +Preview first if the chart version changed: + +```bash +helm diff upgrade sim ./helm/sim -n simstudio --values my-values.yaml +``` + +Then watch the rollout: + +```bash +kubectl rollout status -n simstudio deploy/sim-app --timeout=10m +kubectl logs -n simstudio deploy/sim-app -c migrations --tail=100 +``` + + + + +```bash +docker compose -f docker-compose.prod.yml pull +docker compose -f docker-compose.prod.yml up -d +docker compose -f docker-compose.prod.yml logs migrations +``` + +There is a short window where the app is unavailable while containers restart. Compose has no rolling-update mechanism — plan a maintenance window, or run Kubernetes if you need zero-downtime upgrades. + + + + + + + + +### Verify + +Run the [verification checklist](/platform/self-hosting/verify). At minimum: sign in, open a workflow, execute it, upload a file, and confirm the [background jobs](/platform/self-hosting/background-jobs) are still firing. + + + + + +## When a migration fails + +The app pods will not become ready — this is by design. + +```bash +kubectl logs -n simstudio deploy/sim-app -c migrations --tail=200 +``` + +```bash +docker compose -f docker-compose.prod.yml logs migrations +``` + +Common causes: + +| Symptom | Cause | +|---|---| +| `permission denied to create extension "vector"` | The database user lacks superuser rights. Create the pgvector extension manually as an admin, then re-run. | +| Connection refused / timeout | `DATABASE_URL` wrong, or the database is not reachable from the pod. Check network policy and credentials. | +| Lock timeout on a large table | A long-running query is blocking DDL. Drain traffic and retry during a quiet window. | +| Constraint violation | Pre-existing data conflicts with a new constraint. Capture the error, restore the pre-upgrade backup, and open an issue with the exact message. | + +Do not manually edit the migrations table to skip a failed migration — the schema and Sim's expectations will diverge in ways that surface much later. + +## Rolling back + +**Application-only rollback** (no migrations ran, or the new migrations are additive): + +```bash +helm rollback sim -n simstudio +``` + +```bash +# Docker Compose — set the previous tag and restart +docker compose -f docker-compose.prod.yml up -d +``` + +**Rollback after a schema change** requires restoring the database to the pre-upgrade backup, because migrations are forward-only: + +1. Scale the app to zero. +2. Restore the pre-upgrade database snapshot. +3. Redeploy the previous image tag on all three images. +4. Verify. + +This loses everything written since the snapshot. It is why the pre-upgrade backup and a staging rehearsal matter more here than in most systems. + + 1, the rolling update keeps the app available, though migrations run before the new pods start. On Docker Compose there is a restart window — Compose has no rolling-update mechanism." }, +]} /> diff --git a/apps/docs/content/docs/en/platform/self-hosting/verify.mdx b/apps/docs/content/docs/en/platform/self-hosting/verify.mdx new file mode 100644 index 00000000000..77bfb439380 --- /dev/null +++ b/apps/docs/content/docs/en/platform/self-hosting/verify.mdx @@ -0,0 +1,94 @@ +--- +title: Verify Your Install +description: A smoke test that exercises every subsystem in about ten minutes +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { FAQ } from '@/components/ui/faq' + +Run this after a first install, after an upgrade, and after a restore. Each step exercises a different subsystem, so a failure tells you exactly where to look. + +## Checklist + +| # | Do this | Proves | If it fails | +|---|---|---|---| +| 1 | Open your Sim URL and create an account | App, database, TLS, migrations | `kubectl logs deploy/sim-app` — and check the `migrations` init container | +| 2 | Sign out and sign back in | Session handling, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL` | URLs must match your real origin exactly | +| 3 | Open a workflow and drag two blocks onto the canvas | Realtime websocket connection | Browser console for socket errors; see [Networking](/platform/self-hosting/networking) | +| 4 | Open the same workflow in a second browser window and edit | Cross-replica collaboration | With >1 replica this needs [Redis](/platform/self-hosting/redis) | +| 5 | Paste a model API key in settings and run a two-block workflow | Execution engine, credential encryption, outbound network | App logs; check `ENCRYPTION_KEY` is set and outbound egress is allowed | +| 6 | Upload a small file in Files | File storage end to end | With object storage configured: presigned URL + bucket CORS. On local disk: the upload proxies through the app | +| 7 | Upload a file larger than 50 MB | Multipart upload path (object storage only) | Confirm `ETag` is in the bucket's CORS exposed headers | +| 8 | Create a knowledge base and upload a PDF | Document parsing, embeddings, pgvector | Needs a hosted embedding provider — see below | +| 9 | Invite a teammate from workspace settings | Email delivery | App logs for the mailer; see [Email](/platform/self-hosting/email) | +| 10 | Connect an integration account | OAuth configuration | Redirect URI mismatch → see [Integrations & OAuth](/platform/self-hosting/integrations-oauth) | +| 11 | Create a workflow with a Schedule trigger set to every minute, deploy it, wait 2 minutes | **Background jobs** | Check the scheduler's logs — see [Background Jobs](/platform/self-hosting/background-jobs) | +| 12 | Trigger a workflow via the API with an API key | Public API and API-key auth | Check the key was created successfully in settings | + + + Step 11 is the one most people skip and most often discover broken weeks later. Scheduled workflows and every polling trigger depend on the scheduler, and a wrong or missing `CRON_SECRET` makes it fail silently from the app's side. + + +## Infrastructure checks + +Before the UI walkthrough, confirm the deployment itself is healthy. + +```bash +# Everything running? +kubectl get pods -n simstudio + +# Migrations completed +kubectl logs -n simstudio deploy/sim-app -c migrations --tail=50 + +# Health endpoints +curl -fsS https://sim.yourdomain.com/api/health + +# Background jobs scheduled +kubectl get cronjobs -n simstudio + +# Redis configured (multi-replica deployments) — confirms the variable is set, +# not that Redis answers. Steps 3 and 4 below are the real reachability test. +kubectl exec -n simstudio deploy/sim-app -- printenv REDIS_URL +``` + +```bash +# Docker Compose +docker compose -f docker-compose.prod.yml ps +docker compose -f docker-compose.prod.yml logs migrations +curl -fsS http://localhost:3000/api/health +``` + +All six should be present on Compose: `simstudio`, `realtime`, `db`, `redis`, `cron`, and a completed `migrations`. + +## Reading the failures + +**Step 1 fails — app will not load.** Almost always migrations or database connectivity. Check the migrations init container first; a failed migration deliberately blocks the rollout. + +**Step 2 fails — login loops or rejects.** `NEXT_PUBLIC_APP_URL` or `BETTER_AUTH_URL` does not match the origin you are browsing. Both must be the exact public URL, with scheme and no trailing slash. + +**Step 3 fails — no live updates.** The reverse proxy is not passing websocket upgrades, or `/socket.io` is not routed to the realtime service. If realtime is on a separate hostname, `NEXT_PUBLIC_SOCKET_URL` must point at it and realtime's `ALLOWED_ORIGINS` must include the app origin. + +**Step 4 fails — edits do not sync between windows.** With more than one replica, this is Redis. Confirm `REDIS_URL` is present on both pods. + +**Step 5 fails — execution errors.** Check outbound connectivity to the model provider, then the app logs. If the error is about decrypting a credential, `ENCRYPTION_KEY` differs from the one that encrypted it. + +**Step 6 or 7 fails.** With object storage configured, a CORS error in the browser console means the bucket policy does not allow your Sim origin; step 7 failing while step 6 passes specifically means `ETag` is missing from the exposed headers. On local-disk storage there is no CORS involved — uploads proxy through the app, so look at the app logs and the proxy body-size limit instead. + +**Step 8 fails — knowledge base upload errors.** Knowledge bases need a hosted embedding provider — OpenAI, Azure OpenAI, or Gemini. There is no local embedding backend. If a key is set, check pgvector is installed on the database. + +**Step 9 fails — no email arrives.** With no provider configured, emails are written to the app logs instead of sent — check there first to confirm the message was generated, then debug the provider. + +**Step 11 fails — schedule never fires.** Read the scheduler's logs (`docker compose logs cron`, or `kubectl get cronjobs -n simstudio`). A `401` there means the app and the scheduler disagree on `CRON_SECRET`. + +## After an upgrade + +Re-run steps 1, 3, 5, 6, and 11 at minimum. Those cover the app, realtime, execution, storage, and background jobs — the five things a bad upgrade breaks. + +## After a restore + +Run the whole list, and pay special attention to **step 5 with an OAuth-backed integration**. That is what proves `ENCRYPTION_KEY` matches the backup. An app that loads and logs in but cannot decrypt credentials looks healthy right up until someone runs a real workflow. + + diff --git a/apps/sim/.env.example b/apps/sim/.env.example index db177410995..acb633776bf 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -29,6 +29,7 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000 ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables INTERNAL_API_SECRET=your_internal_api_secret # Use `openssl rand -hex 32` to generate, used to encrypt internal api routes API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt api keys +CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authenticates the scheduler against the background job endpoints (scheduled workflows, polling triggers, connector syncs) # Email Provider (Optional) # Configure ONE provider — the mailer auto-detects in priority order: diff --git a/apps/sim/app/(auth)/auth-redirect.test.ts b/apps/sim/app/(auth)/auth-redirect.test.ts new file mode 100644 index 00000000000..e4b9e25b3df --- /dev/null +++ b/apps/sim/app/(auth)/auth-redirect.test.ts @@ -0,0 +1,59 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { buildAuthCrossLink, resolvePostSignupDestination } from '@/app/(auth)/auth-redirect' + +describe('resolvePostSignupDestination', () => { + it('routes to the verify hop when verification is enforceable', () => { + expect( + resolvePostSignupDestination({ emailVerificationEnabled: true, redirectUrl: '' }) + ).toEqual({ kind: 'verify' }) + }) + + it('keeps the verify hop owning the callback URL when verification is enforceable', () => { + expect( + resolvePostSignupDestination({ + emailVerificationEnabled: true, + redirectUrl: '/invite/abc', + }) + ).toEqual({ kind: 'verify' }) + }) + + /** + * Regression guard: signup used to push `/verify` unconditionally, stranding + * self-hosted deployments with no mail provider on a screen no email can + * satisfy. + */ + it('never routes to verify when no mail provider is configured', () => { + expect( + resolvePostSignupDestination({ emailVerificationEnabled: false, redirectUrl: '' }) + ).toEqual({ kind: 'workspace' }) + }) + + it('preserves the callback URL when verification is not enforceable', () => { + expect( + resolvePostSignupDestination({ + emailVerificationEnabled: false, + redirectUrl: '/cli/auth?callback=http%3A%2F%2F127.0.0.1%3A9000&state=xyz', + }) + ).toEqual({ + kind: 'redirect', + url: '/cli/auth?callback=http%3A%2F%2F127.0.0.1%3A9000&state=xyz', + }) + }) +}) + +describe('buildAuthCrossLink', () => { + it('carries the invite flow and callback URL across the login/signup hop', () => { + expect(buildAuthCrossLink('/login', { callbackUrl: '/invite/abc', isInviteFlow: true })).toBe( + '/login?invite_flow=true&callbackUrl=%2Finvite%2Fabc' + ) + }) + + it('drops the query entirely when nothing needs carrying', () => { + expect(buildAuthCrossLink('/signup', { callbackUrl: null, isInviteFlow: false })).toBe( + '/signup' + ) + }) +}) diff --git a/apps/sim/app/(auth)/auth-redirect.ts b/apps/sim/app/(auth)/auth-redirect.ts index 75657ebb57e..0cfd1310b22 100644 --- a/apps/sim/app/(auth)/auth-redirect.ts +++ b/apps/sim/app/(auth)/auth-redirect.ts @@ -5,6 +5,44 @@ */ export const POST_AUTH_REDIRECT_STORAGE_KEY = 'postAuthRedirectUrl' +/** Route the verify hop lives at, entered only from signup. */ +export const VERIFY_FROM_SIGNUP_ROUTE = '/verify?fromSignup=true' + +/** Default post-auth destination when no callback URL was carried in. */ +export const DEFAULT_POST_AUTH_ROUTE = '/workspace' + +/** + * Where a successful email signup goes next. + * - `verify`: the verification hop, which owns the post-auth redirect from there + * - `redirect`: the validated callback URL the visitor arrived with + * - `workspace`: the default destination + */ +export type PostSignupDestination = + | { kind: 'verify' } + | { kind: 'redirect'; url: string } + | { kind: 'workspace' } + +interface PostSignupDestinationParams { + /** The server-derived effective flag — verification enabled AND deliverable. */ + emailVerificationEnabled: boolean + /** Callback URL that already passed `validateCallbackUrl`, or `''`. */ + redirectUrl: string +} + +/** + * `/verify` is a destination only when the deployment can actually deliver the + * code. A deployment with no mail provider would otherwise strand every new + * account on a screen no email can ever satisfy, so signup continues straight + * to the normal post-auth destination instead. + */ +export function resolvePostSignupDestination({ + emailVerificationEnabled, + redirectUrl, +}: PostSignupDestinationParams): PostSignupDestination { + if (emailVerificationEnabled) return { kind: 'verify' } + return redirectUrl ? { kind: 'redirect', url: redirectUrl } : { kind: 'workspace' } +} + interface AuthCrossLinkParams { /** Validated post-auth destination to carry over, or null to drop it. */ callbackUrl: string | null diff --git a/apps/sim/app/(auth)/signup/page.tsx b/apps/sim/app/(auth)/signup/page.tsx index 1fbd4cbfd22..3d5a8933cd6 100644 --- a/apps/sim/app/(auth)/signup/page.tsx +++ b/apps/sim/app/(auth)/signup/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next' import { isEmailSignupDisabled, isRegistrationDisabled } from '@/lib/core/config/env-flags' +import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' import SignupForm from '@/app/(auth)/signup/signup-form' @@ -24,6 +25,7 @@ export default async function SignupPage() { microsoftAvailable={microsoftAvailable} isProduction={isProduction} emailSignupEnabled={!isEmailSignupDisabled} + emailVerificationEnabled={isEmailVerificationEffectivelyEnabled()} /> ) } diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index de337cc5c3e..4915da788b3 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -11,7 +11,13 @@ import { isSsoEnabled } from '@/lib/core/config/env-flags' import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { captureClientEvent, captureEvent } from '@/lib/posthog/client' -import { buildAuthCrossLink, POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect' +import { + buildAuthCrossLink, + DEFAULT_POST_AUTH_ROUTE, + POST_AUTH_REDIRECT_STORAGE_KEY, + resolvePostSignupDestination, + VERIFY_FROM_SIGNUP_ROUTE, +} from '@/app/(auth)/auth-redirect' import { AuthDivider, AuthField, @@ -86,6 +92,8 @@ interface SignupFormProps { microsoftAvailable: boolean isProduction: boolean emailSignupEnabled: boolean + /** Server-derived: verification is enabled AND a mail provider is configured. */ + emailVerificationEnabled: boolean } function SignupFormContent({ @@ -94,6 +102,7 @@ function SignupFormContent({ microsoftAvailable, isProduction, emailSignupEnabled, + emailVerificationEnabled, }: SignupFormProps) { const router = useRouter() const searchParams = useSearchParams() @@ -343,18 +352,29 @@ function SignupFormContent({ logger.error('Failed to refresh session after signup:', sessionError) } + const destination = resolvePostSignupDestination({ emailVerificationEnabled, redirectUrl }) + if (typeof window !== 'undefined') { - sessionStorage.setItem('verificationEmail', emailValue) - if (redirectUrl) { - sessionStorage.setItem(POST_AUTH_REDIRECT_STORAGE_KEY, redirectUrl) - } else { - // Clear any leftover from an earlier signup in this tab — otherwise a - // signup with no callbackUrl inherits the previous CLI/invite destination. - sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY) + // Clear any leftover from an earlier signup in this tab — otherwise a + // signup with no callbackUrl inherits the previous CLI/invite destination. + sessionStorage.removeItem('verificationEmail') + sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY) + + if (destination.kind === 'verify') { + sessionStorage.setItem('verificationEmail', emailValue) + if (redirectUrl) sessionStorage.setItem(POST_AUTH_REDIRECT_STORAGE_KEY, redirectUrl) } } - router.push('/verify?fromSignup=true') + if (destination.kind === 'verify') { + router.push(VERIFY_FROM_SIGNUP_ROUTE) + } else if (destination.kind === 'redirect') { + // Full navigation, matching the verify hop: the destination (invite, CLI + // handoff) is server-rendered and must see the fresh session cookie. + window.location.href = destination.url + } else { + router.push(DEFAULT_POST_AUTH_ROUTE) + } } catch (error) { logger.error('Signup error:', error) setIsLoading(false) @@ -488,6 +508,7 @@ export default function SignupPage({ microsoftAvailable, isProduction, emailSignupEnabled, + emailVerificationEnabled, }: SignupFormProps) { return ( ) diff --git a/apps/sim/app/(auth)/verify/page.test.tsx b/apps/sim/app/(auth)/verify/page.test.tsx new file mode 100644 index 00000000000..1de1f85305c --- /dev/null +++ b/apps/sim/app/(auth)/verify/page.test.tsx @@ -0,0 +1,53 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockHasEmailService, mockIsEmailVerificationEffectivelyEnabled } = vi.hoisted(() => ({ + mockHasEmailService: vi.fn<() => boolean>(), + mockIsEmailVerificationEffectivelyEnabled: vi.fn<() => boolean>(), +})) + +vi.mock('@/lib/messaging/email/mailer', () => ({ + hasEmailService: mockHasEmailService, +})) + +vi.mock('@/lib/messaging/email/verification', () => ({ + isEmailVerificationEffectivelyEnabled: mockIsEmailVerificationEffectivelyEnabled, +})) + +vi.mock('@/app/(auth)/verify/verify-content', () => ({ + VerifyContent: () => null, +})) + +import VerifyPage from '@/app/(auth)/verify/page' + +describe('verify page', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders the verification experience when a mail provider is configured', () => { + mockHasEmailService.mockReturnValue(true) + mockIsEmailVerificationEffectivelyEnabled.mockReturnValue(true) + + const element = VerifyPage() + + expect(element.props.hasEmailService).toBe(true) + expect(element.props.isEmailVerificationEnabled).toBe(true) + }) + + /** + * The page hands the effective value down, so the verification form never + * renders on a deployment that cannot deliver a code — it redirects instead. + */ + it('reports verification off when no mail provider is configured', () => { + mockHasEmailService.mockReturnValue(false) + mockIsEmailVerificationEffectivelyEnabled.mockReturnValue(false) + + const element = VerifyPage() + + expect(element.props.hasEmailService).toBe(false) + expect(element.props.isEmailVerificationEnabled).toBe(false) + }) +}) diff --git a/apps/sim/app/(auth)/verify/page.tsx b/apps/sim/app/(auth)/verify/page.tsx index c8825186d02..0f828b78b4f 100644 --- a/apps/sim/app/(auth)/verify/page.tsx +++ b/apps/sim/app/(auth)/verify/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next' -import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/env-flags' +import { isProd } from '@/lib/core/config/env-flags' import { hasEmailService } from '@/lib/messaging/email/mailer' +import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification' import { VerifyContent } from '@/app/(auth)/verify/verify-content' export const metadata: Metadata = { @@ -16,7 +17,7 @@ export default function VerifyPage() { ) } diff --git a/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx index 8ab6df2075b..f066279140d 100644 --- a/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx +++ b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx @@ -1,9 +1,8 @@ 'use client' -import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type RefObject, useCallback, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { noop } from '@/lib/core/utils/request' import { AGENT_STREAM_PROTOCOL_HEADER, AGENT_STREAM_PROTOCOL_V1, @@ -18,22 +17,16 @@ import { ChatMessageContainer, EmailAuth, PasswordAuth, - VoiceInterface, } from '@/app/(interfaces)/chat/components' import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/(interfaces)/chat/constants' -import { useAudioStreaming, useChatStreaming } from '@/app/(interfaces)/chat/hooks' +import { useChatStreaming } from '@/app/(interfaces)/chat/hooks' import SSOAuth from '@/ee/sso/components/sso-auth' import { useDeployedChatConfig } from '@/hooks/queries/chats' import { useGitHubStars } from '@/hooks/queries/github-stars' -import { useVoiceSettings } from '@/hooks/queries/voice-settings' const logger = createLogger('ChatClient') -interface AudioStreamingOptions { - voiceId: string - chatId: string - onError: (error: Error) => void -} +const NEAR_BOTTOM_THRESHOLD_PX = 100 interface ChatRequestFile { name: string @@ -48,13 +41,6 @@ interface ChatRequestPayload { files?: ChatRequestFile[] } -const DEFAULT_VOICE_SETTINGS = { - voiceId: 'cgSgspJ2msm6clMCkdW9', // Default ElevenLabs voice (Jessica) — Flash v2.5-optimized -} - -/** - * Converts a File object to a base64 data URL - */ function fileToBase64(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() @@ -64,36 +50,8 @@ function fileToBase64(file: File): Promise { }) } -/** - * Creates an audio stream handler for text-to-speech conversion - * @param streamTextToAudio - Function to stream text to audio - * @param voiceId - The voice ID to use for TTS - * @param chatId - Optional chat ID for deployed chat authentication - * @returns Audio stream handler function or undefined - */ -function createAudioStreamHandler( - streamTextToAudio: (text: string, options: AudioStreamingOptions) => Promise, - voiceId: string, - chatId: string -) { - return async (text: string) => { - try { - await streamTextToAudio(text, { - voiceId, - chatId, - onError: (error: Error) => { - logger.error('Audio streaming error:', error) - }, - }) - } catch (error) { - logger.error('TTS error:', error) - } - } -} - export default function ChatClient({ identifier }: { identifier: string }) { const [messages, setMessages] = useState([]) - const [inputValue, setInputValue] = useState('') const [isLoading, setIsLoading] = useState(false) const messagesEndRef = useRef(null) const messagesContainerRef = useRef(null) @@ -104,13 +62,9 @@ export default function ChatClient({ identifier }: { identifier: string }) { const stickToBottomRef = useRef(true) const ignoreScrollRef = useRef(false) - const [isVoiceFirstMode, setIsVoiceFirstMode] = useState(false) - const { data: chatConfigResult, error: chatConfigError } = useDeployedChatConfig(identifier) - const { data: voiceSettings } = useVoiceSettings() const { data: starCount } = useGitHubStars() - const sttAvailable = voiceSettings?.sttAvailable === true const authRequired = chatConfigResult?.kind === 'auth' ? chatConfigResult.authType : null const chatConfig = chatConfigResult?.kind === 'config' ? chatConfigResult.config : null @@ -134,16 +88,12 @@ export default function ChatClient({ identifier }: { identifier: string }) { const { isStreamingResponse, abortControllerRef, stopStreaming, handleStreamedResponse } = useChatStreaming() - const audioContextRef = useRef(null) - const { isPlayingAudio, streamTextToAudio, stopAudio } = useAudioStreaming(audioContextRef) - - const NEAR_BOTTOM_THRESHOLD_PX = 100 /** * ChatGPT-style scroll. Without `force`, no-ops when the user has scrolled away. * With `force` (jump button), re-pins to bottom. */ - const scrollToBottom = useCallback((options?: { behavior?: ScrollBehavior; force?: boolean }) => { + const scrollToBottom = (options?: { behavior?: ScrollBehavior; force?: boolean }) => { const behavior = options?.behavior ?? 'smooth' const force = options?.force === true if (!force && !stickToBottomRef.current) return @@ -162,56 +112,49 @@ export default function ChatClient({ identifier }: { identifier: string }) { }, behavior === 'smooth' ? 400 : 50 ) - }, []) + } - const scrollToMessage = useCallback( - (messageId: string, scrollToShowOnlyMessage = false) => { - const messageElement = document.querySelector(`[data-message-id="${messageId}"]`) - if (messageElement && messagesContainerRef.current) { - const container = messagesContainerRef.current - const containerRect = container.getBoundingClientRect() - const messageRect = messageElement.getBoundingClientRect() - - if (scrollToShowOnlyMessage) { - const scrollTop = container.scrollTop + messageRect.top - containerRect.top - - container.scrollTo({ - top: scrollTop, - behavior: 'smooth', - }) - } else { - const scrollTop = container.scrollTop + messageRect.top - containerRect.top - 80 - - container.scrollTo({ - top: scrollTop, - behavior: 'smooth', - }) - } - } - }, - [messagesContainerRef] - ) + const scrollToMessage = (messageId: string) => { + const messageElement = document.querySelector(`[data-message-id="${messageId}"]`) + if (!messageElement || !messagesContainerRef.current) return - useEffect(() => { const container = messagesContainerRef.current - if (!container) return + const containerRect = container.getBoundingClientRect() + const messageRect = messageElement.getBoundingClientRect() + + container.scrollTo({ + top: container.scrollTop + messageRect.top - containerRect.top, + behavior: 'smooth', + }) + } + + /** + * Attaches on mount via a ref callback rather than an effect: the container + * renders only after the auth/loading early returns, so an effect would need + * unrelated render values as a stand-in for "the node exists yet". + */ + const attachMessagesContainer = useCallback((node: HTMLDivElement | null) => { + messagesContainerRef.current = node + if (!node) return const handleScroll = () => { if (ignoreScrollRef.current) return - const { scrollTop, scrollHeight, clientHeight } = container + const { scrollTop, scrollHeight, clientHeight } = node const distanceFromBottom = scrollHeight - scrollTop - clientHeight const nearBottom = distanceFromBottom <= NEAR_BOTTOM_THRESHOLD_PX stickToBottomRef.current = nearBottom setShowScrollButton(!nearBottom) } - container.addEventListener('scroll', handleScroll, { passive: true }) - return () => container.removeEventListener('scroll', handleScroll) - }, [chatConfig, isVoiceFirstMode, authRequired]) + node.addEventListener('scroll', handleScroll, { passive: true }) + return () => { + node.removeEventListener('scroll', handleScroll) + messagesContainerRef.current = null + } + }, []) const handleSendMessage = async ( - messageParam?: string, - isVoiceInput = false, + messageToSend: string, files?: Array<{ id: string name: string @@ -221,12 +164,10 @@ export default function ChatClient({ identifier }: { identifier: string }) { dataUrl?: string }> ) => { - const messageToSend = messageParam ?? inputValue if ((!messageToSend.trim() && (!files || files.length === 0)) || isLoading) return logger.info('Sending message:', { messageToSend, - isVoiceInput, conversationId, filesCount: files?.length, }) @@ -249,11 +190,10 @@ export default function ChatClient({ identifier }: { identifier: string }) { } setMessages((prev) => [...prev, userMessage]) - setInputValue('') setIsLoading(true) setTimeout(() => { - scrollToMessage(userMessage.id, true) + scrollToMessage(userMessage.id) }, 100) // One AbortController for fetch + SSE body reads so Stop cancels server work too. @@ -315,30 +255,12 @@ export default function ChatClient({ identifier }: { identifier: string }) { throw new Error('Response body is missing') } - const shouldPlayAudio = isVoiceInput || isVoiceFirstMode - const audioHandler = - shouldPlayAudio && chatConfig?.id - ? createAudioStreamHandler( - streamTextToAudio, - DEFAULT_VOICE_SETTINGS.voiceId, - chatConfig.id - ) - : undefined - - logger.info('Starting to handle streamed response:', { shouldPlayAudio }) - await handleStreamedResponse( response, setMessages, setIsLoading, () => scrollToBottom({ behavior: 'auto' }), { - voiceSettings: { - isVoiceEnabled: shouldPlayAudio, - voiceId: DEFAULT_VOICE_SETTINGS.voiceId, - autoPlayResponses: shouldPlayAudio, - }, - audioStreamHandler: audioHandler, outputConfigs: chatConfig?.outputConfigs, abortController, } @@ -364,41 +286,6 @@ export default function ChatClient({ identifier }: { identifier: string }) { } } - useEffect(() => { - return () => { - stopAudio() - if (audioContextRef.current && audioContextRef.current.state !== 'closed') { - audioContextRef.current.close() - } - } - }, [stopAudio]) - - const handleVoiceInterruption = useCallback(() => { - stopAudio() - - if (isStreamingResponse) { - stopStreaming(setMessages) - } - }, [isStreamingResponse, stopStreaming, setMessages, stopAudio]) - - const handleVoiceStart = useCallback(() => { - if (!sttAvailable) return - setIsVoiceFirstMode(true) - }, [sttAvailable]) - - const handleExitVoiceMode = useCallback(() => { - setIsVoiceFirstMode(false) - stopAudio() - }, [stopAudio]) - - const handleVoiceTranscript = useCallback( - (transcript: string) => { - logger.info('Received voice transcript:', transcript) - handleSendMessage(transcript, true) - }, - [handleSendMessage] - ) - if (chatConfigError) { logger.error('Error fetching chat config:', chatConfigError) return @@ -420,55 +307,29 @@ export default function ChatClient({ identifier }: { identifier: string }) { return } - if (isVoiceFirstMode) { - return ( - ({ - content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content), - type: msg.type, - }))} - /> - ) - } - return ( -
+
- {/* Header component */} - {/* Message Container component */} } + messagesContainerRef={attachMessagesContainer} messagesEndRef={messagesEndRef as RefObject} scrollToBottom={() => scrollToBottom({ behavior: 'smooth', force: true })} - scrollToMessage={scrollToMessage} chatConfig={chatConfig} /> - {/* Input area (free-standing at the bottom) */}
{ - void handleSendMessage(value, isVoiceInput, files) + onSubmit={(value, files) => { + void handleSendMessage(value, files) }} isStreaming={isStreamingResponse} onStopStreaming={() => stopStreaming(setMessages)} - onVoiceStart={handleVoiceStart} - sttAvailable={sttAvailable} />
diff --git a/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx index a964d796cb0..405a06bc0e7 100644 --- a/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx +++ b/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx @@ -3,7 +3,7 @@ import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' export default function ChatLoading() { return ( -
+
diff --git a/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx index f5cb4767fc8..392106c515a 100644 --- a/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx +++ b/apps/sim/app/(interfaces)/chat/components/auth/email/email-auth.tsx @@ -35,7 +35,7 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { const [email, setEmail] = useState('') const [authError, setAuthError] = useState(null) const [emailErrors, setEmailErrors] = useState([]) - const [showEmailValidationError, setShowEmailValidationError] = useState(false) + const hasEmailError = emailErrors.length > 0 const [showOtpVerification, setShowOtpVerification] = useState(false) const [otpValue, setOtpValue] = useState('') @@ -53,15 +53,12 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { const handleEmailChange = (e: React.ChangeEvent) => { const newEmail = e.target.value setEmail(newEmail) - const errors = validateEmailField(newEmail) - setEmailErrors(errors) - setShowEmailValidationError(false) + setEmailErrors([]) } const handleSendOtp = async () => { const emailValidationErrors = validateEmailField(email) setEmailErrors(emailValidationErrors) - setShowEmailValidationError(emailValidationErrors.length > 0) if (emailValidationErrors.length > 0) { return @@ -75,7 +72,6 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { } catch (error) { logger.error('Error sending OTP:', error) setEmailErrors([toError(error).message || 'Failed to send verification code']) - setShowEmailValidationError(true) } } @@ -149,12 +145,10 @@ export default function EmailAuth({ identifier }: EmailAuthProps) { value={email} onChange={handleEmailChange} className={cn( - showEmailValidationError && - emailErrors.length > 0 && - 'border-[var(--text-error)] focus:border-[var(--text-error)]' + hasEmailError && 'border-[var(--text-error)] focus:border-[var(--text-error)]' )} /> - {showEmailValidationError && emailErrors.length > 0 && ( + {hasEmailError && (
{emailErrors.map((error) => (

{error}

diff --git a/apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx b/apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx index 0d6a1841e9c..bbf1471f8b9 100644 --- a/apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx +++ b/apps/sim/app/(interfaces)/chat/components/auth/password/password-auth.tsx @@ -17,21 +17,19 @@ interface PasswordAuthProps { export default function PasswordAuth({ identifier }: PasswordAuthProps) { const [password, setPassword] = useState('') const [showPassword, setShowPassword] = useState(false) - const [showValidationError, setShowValidationError] = useState(false) const [passwordErrors, setPasswordErrors] = useState([]) + const hasPasswordError = passwordErrors.length > 0 const authenticate = useChatPasswordAuth(identifier) const handlePasswordChange = (e: React.ChangeEvent) => { const newPassword = e.target.value setPassword(newPassword) - setShowValidationError(false) setPasswordErrors([]) } const handleAuthenticate = async () => { if (!password.trim()) { setPasswordErrors(['Password is required']) - setShowValidationError(true) return } @@ -41,7 +39,6 @@ export default function PasswordAuth({ identifier }: PasswordAuthProps) { } catch (error) { logger.error('Authentication error:', error) setPasswordErrors([toError(error).message || 'Invalid password. Please try again.']) - setShowValidationError(true) } } @@ -84,15 +81,14 @@ export default function PasswordAuth({ identifier }: PasswordAuthProps) { onChange={handlePasswordChange} className={cn( 'pr-10', - showValidationError && - passwordErrors.length > 0 && + hasPasswordError && 'border-[var(--text-error)] focus:border-[var(--text-error)]' )} /> -
- - -

{file.name}

-
- - ))} -
- )} - - {/* Textarea */} -