Security and hardening

Review access, secrets, networking, and code execution before exposing a self-hosted deployment. Use the checklist first, then follow the setup details below.

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, with the GPU device-plugin exception reviewed if GPU Ollama is enabled
  • Object storage buckets private, with CORS limited to your Sim origin
  • Database reachable only from the deployment — on Compose, no host ports: mapping on the db service, or one bound to 127.0.0.1 — with a generated POSTGRES_PASSWORD
  • TLS enforced (sslMode: require) on an externally managed database, or on the bundled one once you have configured it for TLS — the shipped Compose database does not enable it
  • Backups configured and a restore rehearsed
  • Sandbox strategy and required immutable images configured for user code
  • Stored PII policy and internal masking path verified if redaction is required

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. Restrict signup before exposing the instance, and never set DISABLE_AUTH=true behind an internet-facing ingress.

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.

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_KEYEncrypted stored API-key material when configuredRotation needs re-encryption; existing hash-based authentication continues, but old encrypted material cannot be decrypted with a new key
INTERNAL_API_SECRETService-to-service callsYes — roll app and realtime together
CRON_SECRETBackground job endpointsYes — roll app and cron together

Changing ENCRYPTION_KEY without re-encrypting existing data makes that data unreadable until the original key is restored. Losing the original key makes the data unrecoverable. 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

Choose how to supply secrets:

  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.

The delivery mode changes how values reach pods:

ModeBehavior
Defaultapp.env and realtime.env keys go into a chart-managed Secret consumed through envFrom
External SecretsThe operator populates the app Secret from externalSecrets.remoteRefs.app; an unmapped non-empty app.env key fails rendering
existingSecretThe pre-created Secret supplies values. Non-empty app.env overrides are inlined on the pod and shadow Secret values; supply secrets through the Secret in this mode

A secret committed to values.yaml remains in 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

With chart ingress enabled, networkPolicy.ingressFrom defaults to [{}], which permits any source on the app and realtime ingress ports. Scope it to your ingress controller, especially on a shared or multi-tenant cluster. Replace ingress-system below with its actual namespace:

networkPolicy:
  ingressFrom:
    - namespaceSelector:
        matchLabels:
          kubernetes.io/metadata.name: ingress-system

The policy 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

allowExternalEgress defaults to false. Enabling it removes the broad egress port restriction while retaining the configured metadata-address exclusions.

Cloud metadata endpoints are blocked at two layers

The pod policy and guarded application requests enforce different boundaries. Instance metadata endpoints can expose cloud credentials.

LayerWhat it blocksCan an operator lift it?
NetworkPolicyThe CIDRs in networkPolicy.egressExceptCidrs, defaulting to 169.254.169.254/32 and 169.254.170.2/32, are excluded from the broad egress ruleYes — it is an ordinary value. Setting it to [] keeps the defaults (Helm's default treats an empty list as unset), but supplying your own non-empty list without those two entries silently drops them
ApplicationAWS/Azure/GCP IMDS, ECS task metadata, Alibaba, Oracle, the Azure WireServer, and IMDS over IPv6No — refused in code regardless of EGRESS_ALLOWED_HOSTS or EGRESS_ALLOWED_IP_RANGES, so allowlisting a broad range such as 169.254.0.0/16 cannot re-expose them

Guarded application requests refuse these metadata destinations regardless of the allowlist. Remote sandbox networking has its own boundary; this is not a guarantee for arbitrary Python or Shell traffic. The pod-level block depends on the policy you configure. If you override egressExceptCidrs to add your cluster's API server CIDR, keep both defaults in the list.

Optional Chat service NetworkPolicy

The copilot and copilot-postgresql workloads deliberately have no policy of their own, and neither do the CronJob pods — nothing in the chart's policies selects them, so they are unisolated whenever networkPolicy.enabled: true. The copilot service requires REDIS_URL on a non-443 port, and the chart cannot know your Redis host at render time — a default egress rule would block Redis on most installs. If you run networkPolicy.enabled: true with copilot.enabled: true, those two pods are unisolated until you write dedicated NetworkPolicies for them. The chart-level networkPolicy.egress list does not reach them — it is only rendered into the policies the chart creates, and it creates none for these workloads.

Pod Security Standards

Chart workloads set runAsNonRoot, drop Linux capabilities, disable privilege escalation, and use seccompProfile: RuntimeDefault. Review the full Kubernetes restricted requirements and any extra volumes you add. The GPU device plugin is an exception: The NVIDIA device-plugin DaemonSet the chart renders when ollama.gpu.enabled: true sets only allowPrivilegeEscalation and capabilities.drop, and mounts three hostPath volumes that restricted forbids outright. Enforce it at the namespace level only when GPU Ollama is off, or exempt that DaemonSet. 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 — set realtime.securityContext, pii.securityContext, or copilot.server.securityContext (the copilot keys nest one level deeper) to readOnlyRootFilesystem: true, and mount an emptyDir at /tmp via the matching 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)noneV8 isolate in a Node child worker inside the app container. The worker shares the container's network namespace and OS/filesystem context. JavaScript only.
E2BE2B_ENABLED=true, E2B_API_KEY, E2B_FUNCTION_TEMPLATE_ID, E2B_FUNCTION_TEMPLATE_GENERATIONRemote sandbox per execution; requires outbound access to E2B. Review the provider's isolation and network configuration.
DaytonaSANDBOX_PROVIDER=daytona, DAYTONA_API_KEY, DAYTONA_FUNCTION_SNAPSHOT_IDRemote sandbox per execution.

Python, Shell, JavaScript with external imports, and tooling-dependent blocks require a remote sandbox provider. On a billing-free self-host the provider and Function base are not sufficient on their own: SANDBOXES_ENABLED grants the server-side entitlement (so does the ENTERPRISE_ENABLED master switch when SANDBOXES_ENABLED is left unset), and without that entitlement a workspace offers no Shell language and the Sandboxes settings page shows an upgrade notice even when the provider is configured correctly. JavaScript without import or require continues to run in the local isolate when no remote provider is configured.

The credential alone does not enable a remote sandbox. Each provider also needs an immutable reference to the dedicated Function image, and Sim validates the format before it will use it — an old shell template or snapshot name is deliberately not accepted as a fallback:

  • E2B_FUNCTION_TEMPLATE_ID must be <template>:<build-id>, where the build id is a UUID. A human-readable tag is rejected because E2B lets tags be reassigned.
  • E2B_FUNCTION_TEMPLATE_GENERATION must be a positive integer release generation.
  • DAYTONA_FUNCTION_SNAPSHOT_ID must be the snapshot ID (a UUID), not the snapshot name — Daytona accepts a name anywhere it accepts an ID, and only the ID is immutable.

Without valid provider credentials and the required Function image, code that needs a remote sandbox fails with a configuration error. Verify readiness before enabling its browser controls.

Two browser-side variables assert readiness in the UI; neither is derived automatically:

VariableEffect when unset
NEXT_PUBLIC_SANDBOXES_ENABLEDThe Function block's Sandbox selector stays hidden, even with a working provider. Shell and Settings → Sandboxes are unaffected — Shell appears, and the settings page swaps its upgrade notice for the sandbox list, from the server's own readiness check. Python stays selectable; whether it runs depends on the server-side provider and Function image, not on this flag
NEXT_PUBLIC_E2B_ENABLEDThe E2B-backed Pi block modes stay hidden; sim-setup doctor reports it as a mismatch against E2B_ENABLED. Revealing them is not enough to make them run — Pi executes on its own image, pinned with E2B_PI_TEMPLATE_ID or DAYTONA_PI_SNAPSHOT_ID, and fails closed without it

Set the public values only after the server-side configuration above is complete — they are assertions about readiness, not switches, and the server-side check has its own conditions beyond them. See Sandboxes for the base-image build and promotion procedure. bunx sim-setup doctor reports a mismatch in either direction.

With the default local 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 local-worker 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's outbound guard limits server-side request forgery (SSRF) by checking the destination and the source of a URL. Private, reserved, and loopback addresses are blocked except for the scoped self-hosted cases below.

  • The classes marked Yes can reach private destinations named in the operator allowlist.
  • Off Sim Cloud, a configured endpoint, self-hosted service, or request target written as localhost or a loopback literal is reachable without an allowlist. This supports local Ollama or Jupyter. It does not lift blocked ports or extend to a database, cache, or mail connector host.

These exceptions do not apply on Sim Cloud. They describe guarded Sim requests, not every connection a remote sandbox or external SDK may make. The guard classifies each request by where its URL came from:

ProvenanceExamplesReaches allowlisted private destinations
Configured endpointGitHub Enterprise, Grafana, a data-drain destination, a connector's hostYes
Self-hosted servicevLLM, Jupyter, 1Password Connect, ClickHouse, an MCP server — software usually run on-prem without TLS, so plain HTTP is expectedYes
Request targetThe HTTP block's URL, an A2A agent, an RSS feed, a Function block's fetchYes
Database hostA database, cache, or mail connector's hostYes
Content fetchAn image URL, a file imported by URL, a link from a third-party API responseNo
ProxyThe outbound HTTP proxy itselfNo

Content fetches and proxy URLs do not use the private-destination allowlist. Their destinations must be publicly routable.

Deployments frequently need to reach an internal service by name or address. Name the destinations:

EGRESS_ALLOWED_HOSTS=host.docker.internal,vllm.ai.svc.cluster.local
EGRESS_ALLOWED_IP_RANGES=10.4.2.17/32,10.4.9.0/24

Wildcards such as *.svc.cluster.local and broad ranges such as 10.0.0.0/8 are accepted. They grant every workflow author access to the matching destinations; prefer specific hosts and narrow ranges.

Both lists are validated at startup. A malformed entry stops Sim with an error naming the setting.

  • EGRESS_ALLOWED_HOSTS accepts hostnames, not URLs or CIDRs. A wildcard must start with *. and cover at least two labels. *.local is refused; *.svc.cluster.local matches vllm.ai.svc.cluster.local but not bare svc.cluster.local.
  • EGRESS_ALLOWED_IP_RANGES accepts CIDRs and bare addresses. Prefixes shorter than /8, such as 0.0.0.0/1, are refused as near-catch-alls. 10.0.0.0/8 is the broadest single accepted entry.

On Sim Cloud, plain HTTP is refused for every provenance, self-hosted services included — nothing is vouched there, so a credential would cross the wire in the clear. The only exception is the proxy, whose scheme is fixed by protocol.

Naming a destination permits plain HTTP to it and lifts the blocked-port list for it, since those are the same decision about the same host. The loopback carve-out lifts plain HTTP and the private-address block, but not the port list — it is granted without being asked for, so http://localhost:5432 stays refused until localhost is named. A database, cache, or mail connector's host carries no scheme or port of its own, so naming one of those only lifts the private-address block. Cloud metadata endpoints stay blocked no matter how broad the allowlist is, and both variables are ignored entirely on Sim Cloud.

To reach a service on the Docker host, pair the allowlist with the host alias that Compose already sets up:

EGRESS_ALLOWED_HOSTS=host.docker.internal

An allowlist widens what every workflow author on the instance can reach. Name specific hosts and narrow ranges rather than whole private networks, and pair it with a NetworkPolicy that constrains what the app can actually reach. When networkPolicy.enabled is true the chart permits broad egress on port 443 only, so an allowlisted in-cluster target on another port also needs a networkPolicy.egress rule — or networkPolicy.allowExternalEgress: true for unrestricted egress.

Upgrading from an earlier release

Review these compatibility cases when upgrading:

  • ALLOW_PRIVATE_DATABASE_HOSTS still works, but it is deprecated and logs a warning at startup. It vouches for the whole private address space, loopback included, for database, cache, and mail connector hosts. Replace it with EGRESS_ALLOWED_HOSTS or EGRESS_ALLOWED_IP_RANGES naming the hosts you actually use.
  • 1Password Connect on a private, non-loopback address, and an MCP server on a private address or reached through a DNS name that points at loopback, are no longer reachable implicitly. Name them.
  • ALLOWED_MCP_DOMAINS governs which domains may be used; it no longer disables the address check, so an MCP server on a private address needs the allowlist too.
  • Content fetches — an image URL, a file imported by URL, an MCP OAuth endpoint on a different origin than the MCP server itself — never use the allowlist. Those destinations have to be publicly routable. (An SSO OIDC discovery URL is a configured endpoint and does use the allowlist; the endpoints named inside the discovery document are validated as content when Sim registers the provider, so an internal IdP endpoint is refused there and cannot be allowlisted — provide the endpoints explicitly to have them validated as configured endpoints instead.)
  • Redirects are re-judged at every hop under the request's own provenance. Four rules follow from that:
    • A hop landing on a blocked port is refused, and one downgrading to plain HTTP is refused for every provenance except the self-hosted-service and proxy classes, which expect it by design.
    • Only 301, 302, 303, 307 and 308 are followed. 300, 305 and 306 are not.
    • A cross-origin hop drops every header when the caller supplied no redirect policy, so no credential — Authorization, Cookie, or a custom one like PRIVATE-TOKEN — reaches the new origin. A caller that supplies a policy keeps its non-credential headers and drops the ones it marked sensitive.
    • A cross-origin hop that would carry a request body is refused rather than replayed. A body-preserving hop (307, 308, or any hop in a legacy-replay workflow) fails with a message saying so; a 301, 302 or 303 drops the body to a GET and continues.

Response size cap

Every response on the one-shot guarded fetch path is bounded. The pinned fetch that provider SDKs use sets no cap. A caller that does not set its own limit gets the default of 100 MiB; exceeding it rejects the request with a payload-size error and destroys the socket rather than buffering the rest.

A large integration download can fail at this cap even when the provider is healthy. It applies to one-shot guarded fetches, not signed object-storage uploads or MCP's standalone SSE stream. That long-lived stream is deliberately unbounded.

PII redaction

The optional Presidio service supports the Guardrails PII block and configured workflow-data masking. Deploying it does not enable log redaction by itself.

  1. Enable the service and configure an internal app URL:
pii:
  enabled: true

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

  1. With networkPolicy.enabled: true, allow the app self-call in both directions. The chart has no app-to-app egress rule; after scoping ingressFrom, its inbound rules also omit app peers. Add matching rules, substituting your release name:
networkPolicy:
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app.kubernetes.io/name: sim
              app.kubernetes.io/instance: <release>
              app.kubernetes.io/component: app
      ports:
        - protocol: TCP
          port: 3000
  egress:
    - to:
        - podSelector:
            matchLabels:
              app.kubernetes.io/name: sim
              app.kubernetes.io/instance: <release>
              app.kubernetes.io/component: app
      ports:
        - protocol: TCP
          port: 3000
  1. Open Settings → Organization → Data retention and enable the Logs PII-redaction stage in the organization default or workspace override. Configure the entities and language to mask. See Data retention for policy inheritance and the separate input/block-output stages.
  2. Run a workflow with a sample sensitive value and inspect its stored log. Confirm the intended field is masked. [REDACTION_FAILED] indicates masking failed after policy was enabled; it is not a successful PII result.

The service bundles about 2.2 GB of spaCy models. The chart requests 4 GiB and limits it to 8 GiB; allow for the model-loading cold start and measure startup time on your hardware. Multiple PII workers each load their own models.

Postgres on Compose

The Compose files do not publish db on a host port. simstudio, realtime, and migrations reach it over the Compose network as db:5432. docker-compose.prod.yml also refuses to start without POSTGRES_PASSWORD, the same way it refuses to start without BETTER_AUTH_SECRET.

Installs created from an earlier Compose file published the database on every interface of the host (5432:5432), and a POSTGRES_PASSWORD left unset fell back to postgres. A Docker ports: mapping writes its own iptables rules, so a host firewall that looks like it blocks 5432 usually does not. Update the Compose file, then check what your database was created with:

  • You set POSTGRES_PASSWORD before the first start. Nothing else to do — updating the file closes the port.
  • You never set it. Postgres applies POSTGRES_PASSWORD only when it creates the data volume, so the database still uses postgres. Set POSTGRES_PASSWORD=postgres in .env so the stack starts, then rotate it: run docker compose -f docker-compose.prod.yml exec db psql -U postgres -c "ALTER ROLE CURRENT_USER PASSWORD '<new password>'" (with your POSTGRES_USER in place of postgres if you set one), set POSTGRES_PASSWORD to the same value, and run docker compose -f docker-compose.prod.yml up -d. Setting a new value in .env alone does not change the database's password and locks the app out.

bunx sim-setup start and bunx sim-setup update write the right value for you and print the rotation steps.

To reach the database from the host — psql, a desktop client, a backup job — use docker compose -f docker-compose.prod.yml exec db psql -U postgres simstudio, or pass a second Compose file that publishes it on loopback only and connect over an SSH tunnel:

# db-port.yml — docker compose -f docker-compose.prod.yml -f db-port.yml up -d
services:
  db:
    ports:
      - '127.0.0.1:5432:5432'