Skip to content

Configure

Networking

Reverse proxies, TLS, WebSockets, timeouts, and request size limits

Configure ingress for long-lived WebSockets, server-sent event (SSE) streams, and large request bodies. Both the app and realtime service must be reachable from browsers.

Topology

Two services need to be reachable. You can put them on one hostname or two.

Route /socket.io to realtime and everything else to the app.

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

Reverse proxy configuration

Caddy handles certificates, WebSockets, and streaming correctly by default.

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

Cloud load balancers

GKE (GCE ingress)

Code
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. The chart renders no annotations on any Service, and realtime.service accepts only type, port, and targetPort — an annotations key there is silently dropped. Annotate the Service directly:

Code
kubectl annotate service -n '<namespace>' \
  -l 'app.kubernetes.io/instance=<release>,app.kubernetes.io/component=realtime' \
  cloud.google.com/backend-config='{"default": "sim-realtime-backendconfig"}'

Re-apply it after any helm upgrade that recreates the Service, or manage the annotation with a kustomize patch so it survives.

TLS on GKE typically uses a ManagedCertificate, which the chart references by annotation but does not create — create it yourself before the first deploy:

Code
apiVersion: networking.gke.io/v1
kind: ManagedCertificate
metadata:
  name: simstudio-ssl-cert
  namespace: simstudio
spec:
  domains:
    - sim.yourdomain.com
    - sim-ws.yourdomain.com
Code
ingress:
  className: ""
  annotations:
    kubernetes.io/ingress.class: "gce"
    kubernetes.io/ingress.global-static-ip-name: "sim-ip"
    networking.gke.io/managed-certificates: "simstudio-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

GKE requires the kubernetes.io/ingress.class: gce annotation; it does not use ingressClassName for this controller. Certificate and load-balancer provisioning can take an hour or more. Check kubectl describe managedcertificate -n simstudio simstudio-ssl-cert for CertificateStatus: Active. See Google's secure Ingress guide.

AWS (ALB ingress)

Code
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

ALB defaults to a 60-second idle timeout. Raise it for connections that can remain idle that long; it is not a fixed lifetime for WebSockets exchanging heartbeats. See ALB attributes.

Azure (Application Gateway)

Application Gateway's backend request timeout defaults to 20 seconds; raise it for long-running responses. This differs from connection idle timeout. See Azure backend settings. For another AKS ingress controller, configure its corresponding stream, body-size, and timeout settings.

Request size limits

Requests must fit every layer: your reverse proxy, Next.js Proxy, and the route's body cap. Raising one layer does not raise the others.

VariableDefaultApplies to
API_MAX_JSON_BODY_BYTES50 MiBConfigured cap; contract-validated API routes clamp it to 10 MiB, or a lower route cap
CHAT_MAX_REQUEST_BYTES220 MiBConfigured cap for the public deployed-chat endpoint
WEBHOOK_MAX_REQUEST_BYTES10 MiBPublic webhook receiver endpoints

A reverse-proxy limit of at least 250 MB clears these configured defaults; use the syntax and units your proxy accepts. You can lower it to match smaller app limits.

Most /api requests also pass through Next.js Proxy. The current app configuration leaves its proxyClientMaxBodySize at 10 MiB. Larger bodies are truncated to that buffer rather than automatically rejected by Next.js; the route may then fail to parse them. Receiving larger custom-route bodies requires changing experimental.proxyClientMaxBodySize in the app build, which increases per-request memory use. The shared JSON parser separately clamps contract-validated bodies to 10 MiB, so raising the Next.js buffer alone does not increase their limit. See Next.js proxy body buffering.

With object storage configured, regular file uploads do not flow through the proxy — the browser PUTs 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, object storage, and your telemetry backend. Whether HTTP_PROXY / HTTPS_PROXY apply depends on which of those paths a call takes — there is no single answer, and no global setting that covers all of them.

The server runs on Bun, and Bun's native fetch honors $HTTP_PROXY, $HTTPS_PROXY, and $NO_PROXY. Sim installs no global dispatcher. Default fetch uses the configured proxy unless NO_PROXY excludes the destination. The rest either build their own HTTP agent or speak a non-HTTP protocol.

This depends on the runtime. The published images run Bun. If you build the standalone output and run it under Node instead, Node ignores these variables unless started with NODE_USE_ENV_PROXY=1 (Node 22.21+ / 24.5+), and the fetch-based "Yes" rows stop being proxied — model providers, Resend, Gmail sending, the desktop update feed, and the telemetry relay. The Azure Blob, GCS and Azure Communication Services rows still hold: those SDKs read the proxy variables through their own agents rather than through fetch. See Bun fetch and Node proxy configuration.

Outbound pathHonors HTTP_PROXY / HTTPS_PROXY
Model providers reached over the default fetch — Anthropic, OpenAI, Google/Gemini, Vertex, Groq, Cerebras, xAI, Mistral, DeepSeek, OpenRouter, Together, Fireworks, Ollama, LiteLLM, and the other OpenAI-compatible providersYes
Email via Resend, Azure Communication Services, and Gmail sendingYes
The desktop update feed's calls to GitHubYes
Object storage — Azure Blob and GCSYes — their SDK pipelines read the proxy variables
Everything through the SSRF guard — the HTTP block, tools, connectors, outbound webhooks, content fetches, MCP serversNo
Azure OpenAI, Azure Anthropic, vLLMOnly when the endpoint comes from AZURE_OPENAI_ENDPOINT, AZURE_ANTHROPIC_ENDPOINT, or VLLM_BASE_URL. An endpoint typed into the block is validated and pinned to its resolved IP, which bypasses the proxy
Amazon Bedrock, and object storage on S3No — the AWS SDK uses its own request handler
Email via SMTPNo — Nodemailer opens a raw TCP connection
Email via Amazon SESNo — the AWS SDK transport, over HTTPS
OTLP export from the server SDKNo
The /api/telemetry relay that forwards browser eventsYes — it uses the default fetch
Postgres and RedisNo — raw TCP

The practical consequence: a mandatory-egress-proxy environment can route most LLM traffic, Resend mail, and Azure/GCS storage through the proxy, but guarded integration calls, S3, Bedrock, SMTP, telemetry, and datastore traffic still need a transparent proxy or NAT-based egress.

Internal destinations and NO_PROXY

Set NO_PROXY for every destination that is not on the public internet, not just model endpoints. The app reaches the realtime server (SOCKET_SERVER_URL), the Presidio PII service (PII_URL), and itself (INTERNAL_API_BASE_URL) over the same default fetch, alongside self-hosted Ollama, LiteLLM, and vLLM — so a proxy that cannot reach your internal network breaks live updates and PII redaction, not only inference.

Set it in the application environment, not your shell — under app.env on Helm, or the service's environment: on Compose. On Helm the suffixes alone are not enough: the chart wires SOCKET_SERVER_URL, PII_URL, and OLLAMA_URL to bare Service names, which no domain suffix matches. Add those names too — helm template prints the rendered ones, and the prefix is the release name unless it already contains sim, in which case it is the release name alone:

Code
# Helm — for a release named `acme`
app:
  env:
    NO_PROXY: "localhost,127.0.0.1,.svc,.svc.cluster.local,acme-sim-app,acme-sim-realtime,acme-sim-pii,acme-sim-ollama"
Code
# Docker Compose
services:
  simstudio:
    environment:
      # Add any internal model service you run: ollama, litellm, vllm.
      - NO_PROXY=localhost,127.0.0.1,simstudio,realtime,ollama

Per-request proxy

The HTTP block's proxyUrl is honored per request by the SSRF guard, which builds a proxy agent for that call instead of pinning the target IP. It applies only to that path — connectors, content fetches, and MCP calls take a different guarded transport with no per-request proxy option.

Additional ingress and Chat service routing

The chart renders a second, independent Ingress from ingressInternal — its own class, hosts, TLS secret, and rules. Use it to expose the same Deployments on an internal-only hostname alongside the public one. Replace the example class with your controller's configured class.

Code
ingressInternal:
  enabled: true
  className: "your-internal-ingress-class"
  app:
    host: sim.internal.example.com
    paths:
      - path: /
        pathType: Prefix
  realtime:
    host: sim.internal.example.com
    paths:
      - path: /socket.io
        pathType: Prefix
  tls:
    enabled: true
    secretName: sim-internal-tls

When realtime.host equals app.host, the realtime paths are folded into the app host's rule ahead of the catch-all — which is why /socket.io must come first. Give realtime a different host and it gets a rule of its own.

Both ingress and ingressInternal accept an optional copilot block, commented out in values.yaml because the copilot service is off by default. It follows the same host-sharing rule:

Code
# The route renders only when the service itself is enabled.
copilot:
  enabled: true

ingress:
  copilot:
    host: sim.yourdomain.com
    paths:
      - path: /copilot
        pathType: Prefix
Was this helpful?
Suggest an edit