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.
NEXT_PUBLIC_SOCKET_URL can be left unset — the client defaults to the page origin.
Use separate hostnames when your ingress setup cannot split paths across backends. GKE's example values use this layout.
Then tell the client where realtime lives, and tell realtime which origins to accept:
ALLOWED_ORIGINS is the CORS allowlist realtime enforces on socket connections; it must contain the app's origin. The URL keys only need to be set once under app.env — the chart writes them into a Secret both Deployments consume.
Both hostnames need DNS records and TLS certificates.
Reverse proxy configuration
Caddy handles certificates, WebSockets, and streaming correctly by default.
flush_interval -1 disables response buffering, which keeps streamed agent output flowing token by token instead of arriving in one block at the end.
Nginx buffers responses and times out idle connections by default. Both need overriding.
For an existing ingress-nginx deployment, the equivalent annotations are below. The community controller retired in March 2026 and no longer receives security updates. For a new deployment, use a maintained controller supported by your cluster and apply that controller's equivalent settings. This retirement does not apply to the standalone Nginx example above.
Set the read/idle timeouts on the entrypoint, since they are static configuration rather than per-ingress:
Traefik streams responses by default and needs no buffering override.
Cloud load balancers
GKE (GCE ingress)
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:
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:
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)
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.
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.
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:
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.
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: