# Security and hardening (/platform/self-hosting/security)





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

## Client IP and forwarded headers [#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).

## Secrets [#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` | Encrypted stored API-key material when configured | Rotation needs re-encryption; existing hash-based authentication continues, but old encrypted material cannot be decrypted with a new key |
| `INTERNAL_API_SECRET` | Service-to-service calls | Yes — roll app and realtime together |
| `CRON_SECRET` | Background job endpoints | Yes — roll app and cron together |

<Callout type="error">
  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.
</Callout>

`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 [#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:

| Mode | Behavior |
| - | - |
| Default | `app.env` and `realtime.env` keys go into a chart-managed Secret consumed through `envFrom` |
| External Secrets | The operator populates the app Secret from `externalSecrets.remoteRefs.app`; an unmapped non-empty `app.env` key fails rendering |
| `existingSecret` | The 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 [#network-boundaries]

### Ingress [#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 [#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
```

<Callout type="warn">
  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:

  ```yaml
  networkPolicy:
    ingressFrom:
      - namespaceSelector:
          matchLabels:
            kubernetes.io/metadata.name: ingress-system
  ```
</Callout>

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:

```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
```

<Callout type="warn">
  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`.
</Callout>

If maintaining CIDR lists is not worth it, drop the port restriction instead:

```yaml
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 [#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.

| Layer | What it blocks | Can an operator lift it? |
| - | - | - |
| NetworkPolicy | The CIDRs in `networkPolicy.egressExceptCidrs`, defaulting to `169.254.169.254/32` and `169.254.170.2/32`, are excluded from the broad egress rule | **Yes** — 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 |
| Application | AWS/Azure/GCP IMDS, ECS task metadata, Alibaba, Oracle, the Azure WireServer, and IMDS over IPv6 | **No** — 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 [#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 [#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:

```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 — 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 [#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 | V8 isolate in a Node child worker inside the app container. The worker shares the container's network namespace and OS/filesystem context. JavaScript only. |
| **E2B** | `E2B_ENABLED=true`, `E2B_API_KEY`, `E2B_FUNCTION_TEMPLATE_ID`, `E2B_FUNCTION_TEMPLATE_GENERATION` | Remote sandbox per execution; requires outbound access to E2B. Review the provider's isolation and network configuration. |
| **Daytona** | `SANDBOX_PROVIDER=daytona`, `DAYTONA_API_KEY`, `DAYTONA_FUNCTION_SNAPSHOT_ID` | Remote 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.

<Callout type="warn">
  **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.
</Callout>

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

| Variable | Effect when unset |
| - | - |
| `NEXT_PUBLIC_SANDBOXES_ENABLED` | The 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_ENABLED` | The 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](/platform/self-hosting/sandboxes) for the base-image build and promotion procedure. `bunx sim-setup doctor` reports a mismatch in either direction.

<Callout type="warn">
  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.
</Callout>

Resource ceilings for the local-worker 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 [#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:

| Provenance | Examples | Reaches allowlisted private destinations |
| - | - | - |
| Configured endpoint | GitHub Enterprise, Grafana, a data-drain destination, a connector's host | Yes |
| Self-hosted service | vLLM, Jupyter, 1Password Connect, ClickHouse, an MCP server — software usually run on-prem without TLS, so plain HTTP is expected | Yes |
| Request target | The HTTP block's URL, an A2A agent, an RSS feed, a Function block's `fetch` | Yes |
| Database host | A database, cache, or mail connector's host | Yes |
| Content fetch | An image URL, a file imported by URL, a link from a third-party API response | **No** |
| Proxy | The outbound HTTP proxy itself | **No** |

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:

```bash
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:

```bash
EGRESS_ALLOWED_HOSTS=host.docker.internal
```

<Callout type="warn">
  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.
</Callout>

### Upgrading from an earlier release [#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 [#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 [#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:

```yaml
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.

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

```yaml
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
```

3. 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](/platform/enterprise/data-retention) for policy inheritance and the separate input/block-output stages.
4. 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 [#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`.

<Callout type="error">
  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.
</Callout>

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:

```yaml
# 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'
```

## Related docs [#related-docs]

* [Authentication](/platform/self-hosting/authentication)
* [Sandboxes](/platform/self-hosting/sandboxes)
* [Verify your install](/platform/self-hosting/verify)
