Skip to content

Commit 3de63c9

Browse files
authored
feat(self-host): align Docker Compose with Helm and overhaul self-hosting docs (#6225)
* feat(self-host): align Docker Compose with Helm and overhaul self-hosting docs Docker Compose shipped no scheduler, so scheduled workflows, every polling trigger, connector syncs, the outbox, and data drains silently never ran. Adds a cron service running the same 18 jobs the Helm chart schedules as CronJobs, and closes the remaining behavioral gaps between the two paths: bundled Redis in the chart, no hosted plan caps in chart defaults, pinned image tags, and fail-fast secrets. A CI check keeps the schedulers in sync. Also rewrites the self-hosting docs: 14 new pages, 8 updated, reorganized into Install / Configure / Operate. * fix(self-host): drop bun install from chart CI, remove air-gapped and backup docs The scheduler-parity check pulled a full dependency install into the chart-validation job, which fails building isolated-vm on that runner. Rewritten to use only node builtins so the job installs nothing. Also removes the air-gapped and backup/restore pages, and stops pinning a concrete release in the docs so the examples do not go stale each release. * fix(helm): bundle Redis in secret-manager modes unless the URL is supplied Suppressing Redis whenever a secret mode was active left those deployments with no Redis at all — REDIS_URL is optional there and both shipped examples omit it. The chart now steps aside only on a detectable signal: an explicit app.env.REDIS_URL, an ESO remoteRefs.app.REDIS_URL mapping, or the new redis.provideUrl=false opt-out for a pre-created Secret it cannot read. * fix(compose): derive realtime BETTER_AUTH_URL from NEXT_PUBLIC_APP_URL realtime read BETTER_AUTH_URL directly and fell back to localhost while simstudio derived it from NEXT_PUBLIC_APP_URL, so setting only the public origin left realtime authenticating against http://localhost:3000. * fix(helm): deliver bundled REDIS_URL via ConfigMap so an operator value always wins Injecting REDIS_URL as an inline container env made it beat every envFrom source, so a REDIS_URL held in a pre-created Secret or synced by External Secrets was silently shadowed and traffic moved to a fresh in-cluster Redis. Kubernetes resolves duplicate envFrom keys by letting the last source win, so the bundled URL now ships as a ConfigMap listed before the app Secret. Any operator-supplied value overrides it without the chart needing to read it, which also removes the redis.provideUrl flag the previous attempt required. * docs(helm): spell out the egress rule external datastores need The default NetworkPolicy allows 443 plus the bundled Postgres and Redis by pod selector. Anything you run outside the chart on another port needs its own rule, which is easiest to miss when REDIS_URL arrives via a Secret the chart cannot inspect. Adds a copyable example to the production checklist and the security guide. * feat(helm): add networkPolicy.allowExternalEgress for managed datastores The default policy allows 443 plus the bundled Postgres and Redis by pod selector, so a managed datastore on another port needs a hand-written CIDR rule — awkward when REDIS_URL arrives via a Secret the chart cannot inspect. Adds an opt-in switch that drops the port restriction while still blocking the cloud metadata endpoints. Defaults to false, keeping this chart stricter than the common chart default of unrestricted egress.
1 parent 1242301 commit 3de63c9

53 files changed

Lines changed: 3553 additions & 334 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/CONTRIBUTING.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,15 @@ After running this command, open [http://localhost:3000/](http://localhost:3000/
165165
git clone https://github.com/<your-username>/sim.git
166166
cd sim
167167

168+
# Generate the required secrets. The stack refuses to start without them
169+
# rather than booting with empty values.
170+
cat > .env << EOF
171+
BETTER_AUTH_SECRET=$(openssl rand -hex 32)
172+
ENCRYPTION_KEY=$(openssl rand -hex 32)
173+
INTERNAL_API_SECRET=$(openssl rand -hex 32)
174+
CRON_SECRET=$(openssl rand -hex 32)
175+
EOF
176+
168177
# Start Sim
169178
docker compose -f docker-compose.prod.yml up -d
170179
```

.github/workflows/ci.yml

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,12 @@ jobs:
294294
ecr_repo_secret: ECR_PII
295295
gh_runner: ubuntu-latest
296296
bs_runner: blacksmith-4vcpu-ubuntu-2404
297+
# No ECR repo is provisioned for cron, so it publishes to GHCR only.
298+
# The tag step below omits the ECR tag when the repo name is empty.
299+
- dockerfile: ./docker/cron.Dockerfile
300+
ghcr_image: ghcr.io/simstudioai/cron
301+
gh_runner: ubuntu-latest
302+
bs_runner: blacksmith-2vcpu-ubuntu-2404
297303
steps:
298304
- name: Checkout code
299305
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
@@ -338,15 +344,33 @@ jobs:
338344
ECR_REPO="${{ steps.ecr-repo.outputs.name }}"
339345
GHCR_IMAGE="${{ matrix.ghcr_image }}"
340346
341-
TAGS="${ECR_REGISTRY}/${ECR_REPO}:${{ github.sha }}"
347+
TAGS=""
348+
if [ -n "$ECR_REPO" ]; then
349+
TAGS="${ECR_REGISTRY}/${ECR_REPO}:${{ github.sha }}"
350+
fi
342351
343352
if [ "${{ github.ref }}" = "refs/heads/main" ] && [ -n "$GHCR_IMAGE" ]; then
344-
TAGS="${TAGS},${GHCR_IMAGE}:${{ github.sha }}-amd64"
353+
if [ -n "$TAGS" ]; then
354+
TAGS="${TAGS},${GHCR_IMAGE}:${{ github.sha }}-amd64"
355+
else
356+
TAGS="${GHCR_IMAGE}:${{ github.sha }}-amd64"
357+
fi
358+
fi
359+
360+
# An entry can legitimately resolve to no tags — e.g. the cron image has
361+
# no ECR repo, so on staging/dev (where GHCR tags are not applied) there
362+
# is nothing to push. Skip that build instead of failing the job.
363+
if [ -z "$TAGS" ]; then
364+
echo "No ECR repo and no GHCR tag for this entry on ${{ github.ref }} — skipping push."
365+
echo "skip=true" >> $GITHUB_OUTPUT
366+
else
367+
echo "skip=false" >> $GITHUB_OUTPUT
345368
fi
346369
347370
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
348371
349372
- name: Build and push images
373+
if: steps.meta.outputs.skip != 'true'
350374
uses: ./.github/actions/docker-build
351375
with:
352376
provider: ${{ vars.CI_PROVIDER }}
@@ -470,6 +494,10 @@ jobs:
470494
image: ghcr.io/simstudioai/pii
471495
gh_runner: ubuntu-24.04-arm
472496
bs_runner: blacksmith-4vcpu-ubuntu-2404-arm
497+
- dockerfile: ./docker/cron.Dockerfile
498+
image: ghcr.io/simstudioai/cron
499+
gh_runner: ubuntu-24.04-arm
500+
bs_runner: blacksmith-4vcpu-ubuntu-2404-arm
473501

474502
steps:
475503
- name: Checkout code
@@ -515,6 +543,7 @@ jobs:
515543
- image: ghcr.io/simstudioai/migrations
516544
- image: ghcr.io/simstudioai/realtime
517545
- image: ghcr.io/simstudioai/pii
546+
- image: ghcr.io/simstudioai/cron
518547

519548
steps:
520549
- name: Login to GHCR

.github/workflows/helm.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ jobs:
3232
with:
3333
version: v3.16.4
3434

35+
- name: Setup Bun
36+
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
37+
with:
38+
bun-version: 1.3.13
39+
40+
# Docker Compose and Kubernetes must run the same background jobs on the
41+
# same schedules; this fails the build if the two drift apart. The script
42+
# imports only node builtins, so this job installs no dependencies.
43+
- name: Scheduler parity (docker/crontab vs helm cronjobs)
44+
run: bun run scripts/check-cron-parity.ts
45+
3546
- name: Helm lint
3647
run: helm lint helm/sim --values helm/sim/ci/default-values.yaml
3748

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
<p align="center">
99
<a href="https://deepwiki.com/simstudioai/sim" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Ask-DeepWiki-E6E6E6?labelColor=C3C3C3&color=E6E6E6" alt="Ask DeepWiki"></a>
10-
<a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-E6E6E6?logo=cursor&logoColor=1A1A1A&labelColor=C3C3C3&color=E6E6E6" alt="Set Up with Cursor"></a>
10+
<a href="https://cursor.com/link/prompt?text=Help%20me%20set%20up%20Sim%20locally.%20Follow%20these%20steps%3A%0A%0A1.%20First%2C%20verify%20Docker%20is%20installed%20and%20running%3A%0A%20%20%20docker%20--version%0A%20%20%20docker%20info%0A%0A2.%20Clone%20the%20repository%3A%0A%20%20%20git%20clone%20https%3A%2F%2Fgithub.com%2Fsimstudioai%2Fsim.git%0A%20%20%20cd%20sim%0A%0A3.%20Generate%20required%20secrets%20%28the%20stack%20will%20not%20start%20without%20them%29%3A%0A%20%20%20cat%20%3E%20.env%20%3C%3C%20EOF%0A%20%20%20BETTER_AUTH_SECRET%3D%24%28openssl%20rand%20-hex%2032%29%0A%20%20%20ENCRYPTION_KEY%3D%24%28openssl%20rand%20-hex%2032%29%0A%20%20%20INTERNAL_API_SECRET%3D%24%28openssl%20rand%20-hex%2032%29%0A%20%20%20CRON_SECRET%3D%24%28openssl%20rand%20-hex%2032%29%0A%20%20%20EOF%0A%0A4.%20Start%20the%20services%20with%20Docker%20Compose%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20up%20-d%0A%0A4.%20Wait%20for%20all%20containers%20to%20be%20healthy%20(this%20may%20take%201-2%20minutes)%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.prod.yml%20ps%0A%0A5.%20Verify%20the%20app%20is%20accessible%20at%20http%3A%2F%2Flocalhost%3A3000%0A%0AIf%20there%20are%20any%20errors%2C%20help%20me%20troubleshoot%20them.%20Common%20issues%3A%0A-%20Port%203000%2C%203002%2C%20or%205432%20already%20in%20use%0A-%20Docker%20not%20running%0A-%20Insufficient%20memory%20(needs%2012GB%2B%20RAM)%0A%0AFor%20local%20AI%20models%20with%20Ollama%2C%20use%20this%20instead%20of%20step%203%3A%0A%20%20%20docker%20compose%20-f%20docker-compose.ollama.yml%20--profile%20setup%20up%20-d"><img src="https://img.shields.io/badge/Set%20Up%20with-Cursor-E6E6E6?logo=cursor&logoColor=1A1A1A&labelColor=C3C3C3&color=E6E6E6" alt="Set Up with Cursor"></a>
1111
</p>
1212

1313
<p align="center">
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
---
2+
title: Architecture
3+
description: Every service Sim runs, what it depends on, and where state lives
4+
---
5+
6+
import { Callout } from 'fumadocs-ui/components/callout'
7+
import { FAQ } from '@/components/ui/faq'
8+
9+
Understanding what runs where makes every other operational decision — scaling, backup, network policy, upgrades — straightforward.
10+
11+
## Services
12+
13+
| Service | Image | Port | Stateless | Required |
14+
|---|---|---|---|---|
15+
| **app** | `ghcr.io/simstudioai/simstudio` | 3000 | Only with object storage configured | Yes |
16+
| **realtime** | `ghcr.io/simstudioai/realtime` | 3002 | Yes | Yes |
17+
| **migrations** | `ghcr.io/simstudioai/migrations` || Yes (runs once) | Yes |
18+
| **postgresql** | `pgvector/pgvector:pg17` | 5432 | **No** | Yes |
19+
| **redis** | `redis:7-alpine` | 6379 | Mostly | Bundled by both; swap for a managed instance in production |
20+
| **cron** | `ghcr.io/simstudioai/cron` (Compose) / `curlimages/curl` (CronJobs) || Yes | Yes |
21+
| **pii** | `ghcr.io/simstudioai/pii` | 5001 | Yes | Optional |
22+
| **ollama** | `ollama/ollama` | 11434 | **No** (model cache) | Optional |
23+
| **telemetry** | `otel/opentelemetry-collector-contrib` | 4317/4318 | Yes | Optional |
24+
25+
### app
26+
27+
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).
28+
29+
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.
30+
31+
### realtime
32+
33+
A Bun Socket.IO server handling collaborative editing, live execution updates, and collaborative documents. Clients connect at `/socket.io`.
34+
35+
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.
36+
37+
<Callout type="warn">
38+
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.
39+
</Callout>
40+
41+
### migrations
42+
43+
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.
44+
45+
Migrations are forward-only. See [Upgrades](/platform/self-hosting/upgrades).
46+
47+
### postgresql
48+
49+
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).
50+
51+
This holds essentially all durable state: workflows, runs, logs, users, organizations, credentials, knowledge base chunks and embeddings, and table data.
52+
53+
### redis
54+
55+
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).
56+
57+
### cron
58+
59+
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).
60+
61+
## Where state lives
62+
63+
Three places once the deployment is configured for production. Everything else is disposable.
64+
65+
| Store | Contents | Backup |
66+
|---|---|---|
67+
| **PostgreSQL** | All application data | `pg_dump` / managed snapshots + PITR |
68+
| **Object storage** | Uploaded files, KB documents, execution outputs, avatars, logos | Bucket versioning + lifecycle |
69+
| **Secrets** | `ENCRYPTION_KEY`, `API_ENCRYPTION_KEY`, `BETTER_AUTH_SECRET`, `INTERNAL_API_SECRET`, `CRON_SECRET` | Secret manager |
70+
71+
<Callout type="error">
72+
**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.
73+
</Callout>
74+
75+
<Callout type="error">
76+
`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.
77+
</Callout>
78+
79+
Redis is a cache and message bus. Losing it drops in-flight live updates; it does not lose committed data.
80+
81+
## Request paths
82+
83+
**Editor / API** — browser → ingress/reverse proxy → app:3000 → Postgres, Redis, object storage.
84+
85+
**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).
86+
87+
**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.
88+
89+
**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.
90+
91+
**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.
92+
93+
**Background work** — CronJob → `Authorization: Bearer $CRON_SECRET` → app endpoint → same execution path.
94+
95+
## Network boundaries
96+
97+
| From | To | Purpose |
98+
|---|---|---|
99+
| Internet | app:3000, realtime:3002 | Users |
100+
| app, realtime | postgresql:5432 | Data |
101+
| app, realtime | redis:6379 | Pub/sub, cache |
102+
| app | Object storage endpoint | Files (server side) |
103+
| **Browser** | **Object storage endpoint** | Presigned uploads — must be publicly reachable |
104+
| app | Model provider APIs, integration APIs, SMTP/email provider | Outbound |
105+
| cron | app:3000 (internal Service / compose network) | Scheduled triggers |
106+
107+
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).
108+
109+
<FAQ items={[
110+
{ question: "Can I run the app without the realtime service?", answer: "A realtime server must exist — it carries collaborative editing and live execution updates, and the editor degrades badly without one. You can set realtime.enabled=false in the chart, but only when pointing app.env.SOCKET_SERVER_URL at a realtime instance you run yourself."},
111+
{ question: "Does workflow execution run in a separate worker process?", answer: "By default, no — executions run inside the app process using an isolated-vm sandbox, which is why the app has an 8 Gi memory limit. Setting E2B_ENABLED or SANDBOX_PROVIDER=daytona moves user code to a remote sandbox, and TRIGGER_DEV_ENABLED routes async jobs to Trigger.dev; otherwise the database-backed job queue is used."},
112+
]} />

0 commit comments

Comments
 (0)