Security & Hardening

Secrets

Five secrets drive the security of a deployment. Generate each with openssl rand -hex 32.

SecretProtectsRotatable
BETTER_AUTH_SECRETSession tokensYes — invalidates all sessions
ENCRYPTION_KEYWorkspace env vars, stored provider keys, MCP OAuth credentials, deployment/chat secretsNo — see below
API_ENCRYPTION_KEYReversible stored copy of user-generated API keysNo — existing keys keep authenticating, but their stored copy can no longer be displayed
INTERNAL_API_SECRETService-to-service callsYes — roll app and realtime together
CRON_SECRETBackground job endpointsYes — 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.

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:

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:

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:

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:

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

ModeConfigurationIsolation
isolated-vm (default)noneIn-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.
E2BE2B_ENABLED=true, E2B_API_KEYRemote sandbox per execution. Strongest isolation; requires outbound access to E2B.
DaytonaSANDBOX_PROVIDER=daytona, DAYTONA_API_KEYRemote 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:

VariableControls
IVM_MAX_EXECUTIONS_PER_WORKERExecutions before a worker is recycled
IVM_MAX_BROKERS_PER_EXECUTIONHost-call brokers per execution
IVM_MAX_BROKER_ARGS_JSON_CHARSMax argument payload size
IVM_MAX_BROKER_RESULT_JSON_CHARSMax 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:

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.

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

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

Common Questions

Not without re-encrypting everything it protects. Changing it makes workspace environment variables, stored provider API keys, MCP OAuth credentials, and deployment secrets permanently unreadable. Treat it as a permanent, backed-up value rather than a rotating secret.
By default in an in-process V8 isolate inside the app container, which isolates at the JS-engine level but shares the container's network and filesystem context. For untrusted authors, or to run Python at all, use E2B or Daytona so each execution runs in a remote sandbox.
networkPolicy.ingressFrom defaults to an empty peer selector as a simple default that works on any cluster. On a shared cluster you should scope it to your ingress controller's namespace.
It lets database and connector tools reach private, reserved, and loopback addresses — needed to connect to an internal database by Kubernetes service name. It also widens the SSRF boundary for every workflow author, so enable it only on a trusted network.

On this page