Deployment
How a GioJS server behaves in production: static files, health checks, reverse proxies, process supervision, sizing, and running several instances.
A GioJS server is two kinds of process: the giojs-server Rust binary (HTTP, routing, caching, compression) and the Node.js worker(s) it spawns for React rendering. Starting the server starts everything. For step-by-step setups - Docker, Fly.io, Railway, Render, or a Linux server with systemd and nginx or Caddy - follow Deploying; before going live, work through the production checklist. This page is the reference those guides point to.
| Situation | Recipe |
|---|---|
| Container, single instance or scaling | Docker (a standalone build in a slim Node image) |
| Managed platform | Fly.io, Railway, Render |
| Linux VPS or bare metal | systemd + nginx or Caddy |
| Kubernetes, Windows Server | docs/deployment/kubernetes.md and docs/deployment/windows-nssm.md in the repository |
| No server features needed | Static export to any static host |
Ship either a standalone folder (gio build standalone: the server binary and a bundled worker that run with node run.mjs on any host with Node 20+) or the project itself (npm ci --omit=dev and npm start - no build step, and npm update stays your upgrade path). Either way, run with NODE_ENV=production (or unset) and typecheck before you ship (tsc --noEmit).
Static files
Ship public/ next to app/. Rust serves its files at the site root (/robots.txt, /favicon.ico, /.well-known/...) and under /public/*, ahead of the page cache and the Node worker. Dotfiles (other than .well-known/), symlinks, and a top-level public/_gio/ are served under /public/* only. The root-served set is indexed at startup, so add files as part of the deploy - files copied in while the server runs are picked up on the next restart (they are reachable under /public/* immediately). Point GIO_PUBLIC_DIR elsewhere if your assets live outside the project.
Guards, header rules, and [[rate_limits]] for /public/* paths cover the root URL of the same file as well. A static export (gio export) copies public/ into out/ at both places, so static hosts serve the same URLs.
Health check
/_gio/health returns JSON and always answers 200 - cached and static content keeps serving even while the Node worker is respawning. nodeReady is false while no worker is ready (with a worker pool, only when every worker is down at once), and workers counts the ready ones. Readiness probes should read nodeReady. Use it for readiness probes, load balancer health checks, and uptime monitors. With [health] details = false it answers only {"status":"ok","nodeReady":...}; with [health] enabled = false it is a 404, and probes must use a page of your own (the port only opens once a worker is ready, so gio start and the testing kit treat that 404 as ready):
{
"status": "ok",
"http2": true,
"tls": false,
"deploymentId": "0e92bc3a01f4ea44",
"nodeReady": true,
"workers": { "configured": 2, "ready": 2 },
"cacheEntries": 42,
"uptimeSecs": 3600
}deploymentId is 16 hex characters derived from the build, or the value of GIO_DEPLOYMENT_ID (up to 64 characters) when you pin one - compare it across instances to see that a rollout has finished.
Behind a reverse proxy or load balancer
GioJS closes an HTTP/1.1 keep-alive connection after header_read_timeout_secs (10 seconds by default) without a new request, and any connection after idle_timeout_secs (60 seconds) with nothing in flight. Proxies that pool upstream connections (nginx upstream keep-alive, ingress-nginx, AWS ALB) keep them idle for 60 seconds by default and ignore the Keep-Alive hint, so they can reuse a connection at the moment GioJS closes it and answer that request with a 502. Either keep the proxy's upstream idle timeout below 10 seconds, or raise both GioJS deadlines above the proxy's. The proxy reads every request head in full, so a longer head deadline behind it costs nothing:
[server]
header_read_timeout_secs = 65 # above a 60s ALB / ingress-nginx idle timeout
idle_timeout_secs = 65A plain proxy_pass with no upstream keep-alive opens a fresh connection per request and needs neither. See Configuration for every connection limit.
Keep the original Host header (proxy_set_header Host $host; in nginx): CSRF protection and the WebSocket origin check compare the browser's Origin with it, so a proxy that rewrites it makes same-origin form posts and WebSockets fail with 403. And when the proxy terminates TLS, GioJS does not send Strict-Transport-Security by itself - set [security] hsts = true. See Security.
Client IPs, HTTPS and request IDs
Behind a proxy, every connection comes from the proxy, so rate limits would put all visitors in one bucket and req.ip would be the proxy's address. List the proxy in trusted_proxies and GioJS reads the real client from the headers it adds (see Reverse proxies & client IPs for the exact rules). Whatever the proxy, get four things right:
- Trust only the proxy. Put its address (or the private range it connects from) in
trusted_proxies, and make sure clients cannot reach GioJS directly - bind to127.0.0.1or firewall the port. - Forward
X-Forwarded-For,X-Forwarded-ProtoandX-Forwarded-Host, with the proxy setting Proto and Host rather than passing a client's values through. Appending is fine: GioJS readsX-Forwarded-Forfrom the right and takes the last Proto and Host value, the one the nearest proxy wrote (HAProxy'sadd-headerworks as well asset-header). - Keep the
Hostheader (or setX-Forwarded-Host), so the host GioJS sees is the one the browser used. It is still client-supplied unless the proxy only routes your own domains to GioJS - never base a security decision onreq.host. - Decide who sets
X-Request-Id. GioJS adopts a valid id from a trusted proxy. If yours passes a client's header through instead of setting one (most do), make it set or strip the header, or setaccept_request_id = falseso GioJS generates every id.
nginx (on the same machine):
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Request-Id $request_id; # nginx's own id becomes GioJS's
}
# gio.toml
[server]
host = "127.0.0.1"
trusted_proxies = ["127.0.0.1", "::1"]Caddy's reverse_proxy sets all three forwarding headers and keeps Host by default, and Traefik does the same for its routers - both drop forwarding headers sent by untrusted clients. Neither sets an X-Request-Id, so a client's own would pass straight through: have Caddy set one, and have Traefik strip it (a headers middleware with customRequestHeaders: { X-Request-Id: "" }) or turn adoption off:
example.com {
reverse_proxy 127.0.0.1:3000 {
header_up X-Request-Id {http.request.uuid}
}
}
# gio.toml - Caddy on the same host; for Traefik in Docker, trust the
# network it connects from instead, e.g. ["172.16.0.0/12"]
[server]
trusted_proxies = ["127.0.0.1", "::1"]
# accept_request_id = false # Traefik without the strip middlewareCloud load balancers connect from addresses inside your network: trust that range. AWS ALB appends to X-Forwarded-For, sets X-Forwarded-Proto and keeps Host; Google Cloud's HTTPS load balancer appends both the client IP and its own forwarding-rule IP, so add that public IP and Google's proxy ranges (35.191.0.0/16, 130.211.0.0/22). Neither sets X-Request-Id or X-Forwarded-Host, nor can either remove them, so a client's own values arrive as if the load balancer had sent them. Turn request-id adoption off, and treat the host as client-supplied:
# gio.toml behind AWS ALB (your VPC CIDR) or Google Cloud LB
[server]
trusted_proxies = ["10.0.0.0/16"]
accept_request_id = false # the LB would pass a client's X-Request-Id throughIn Kubernetes, trust the pod CIDR the ingress controller runs in. ingress-nginx sends an X-Request-ID, but it deliberately reuses one the client sent; keep accept_request_id on only if a client-chosen id is acceptable in your logs. Behind Cloudflare, trust Cloudflare's published IP ranges; it does not set X-Request-Id either, so turn adoption off or remove the header with a Transform Rule.
Every response carries X-Request-Id, and the same id is on the server and worker log lines for the request - see Observability.
Process supervision
One GioJS server is two processes: the Rust server (the one your supervisor - systemd, Docker, Kubernetes, PM2 - starts) and the Node worker it spawns and restarts on its own (one worker per [server] workers, each supervised on its own). Send the server SIGTERM to stop: it stops accepting, closes idle keep-alive connections at once, lets in-flight requests finish (8 seconds at most), then gives every worker a few seconds to run its plugin shutdown hooks and exit before taking whatever is left down with it.
A server that dies without that chance - SIGKILL, the OOM killer, a crash, a container runtime that kills only the main process - never leaves a worker behind. Each worker's stdin is a pipe the server holds open and never writes; the operating system closes it however the server dies, and the worker reads end-of-file and exits within moments (Windows uses a job object for the same guarantee). Launchers apply the same scheme one level up: gio and a standalone run.mjs start the server with a piped stdin and GIO_EXIT_ON_STDIN_EOF=1, so killing the launcher outright stops the server and frees the port too.
run.mjs) as the main process so your runtime's stop signal reaches it - and keep GIO_EXIT_ON_STDIN_EOF unset when you start the server binary directly: with stdin attached to /dev/null or a terminal it is ignored anyway, but it exists for launchers that hold the pipe.Sizing
By default a server renders on one Node worker, which keeps memory low and is plenty for sites where most traffic is cache hits and static files - Rust serves those on all cores without touching Node. When renders are the bottleneck (many uncached or personalized pages, slow getServerSideProps, CPU-heavy route handlers), run a worker pool:
[server]
workers = "auto" # one per CPU core, at most 8 - or an exact count- Memory. Each worker is a full Node process holding its own copy of your app, React and any in-memory data - budget the RSS of one worker (often 100-200 MB, more for large apps) times the worker count, plus the Rust server. In a container, set the memory limit for the whole pool;
"auto"counts the CPUs the container may use, not its memory. - CPU. More workers than cores only adds memory. Leave a core for the Rust server when the box is busy with TLS, compression and images.
- State. Anything a module keeps in memory (a counter, an in-process cache, a rate limiter) exists once per worker. Requests from the same visitor can land on different workers, so keep shared state outside the process.
- Plugin hooks. A Node plugin's
onStartupruns in every worker - N times at once - and again in each respawned worker. Move one-time jobs (migrations, schedulers, queue consumers) out of the server, or run them only whereGIO_WORKER_INDEXis"0"and make them idempotent (and take a lock when several instances run). - Many small instances or one big one. A pool shares one page cache, one image cache and one set of WebSocket rooms; separate instances each keep their own. Prefer a pool per machine and scale out with instances beyond it.
/_gio/metrics shows each worker's requests in flight and restart count (gio_worker_in_flight, gio_worker_restarts_total), which tells you whether a pool is saturated or a worker keeps crashing. It does not show worker memory: gio_memory_bytes is the Rust server process alone. Measure a worker's RSS with your process tools (ps or top on the node processes under the server) once it has served real traffic for a while, and size the limit from the container's total memory under load.
Multi-instance deployments
The page cache is per-instance (in-memory LRU plus a local disk tier) - there is no shared cache backend yet. When running multiple instances (Kubernetes, multiple VMs), set GIO_DEPLOYMENT_ID to the same value on every instance so they agree on the deployment ID. By default the ID is derived from the app's content (the client build each instance produces at startup, the app's server-side sources and the gio.toml settings pages render with), so identical builds already agree - pinning it explicitly protects you when pods roll out at different times:
GIO_DEPLOYMENT_ID=release-2026-09-06A browser still running the previous deployment's code gets a 409 on its next client navigation and reloads into the new build. If your rollout keeps the old client chunks reachable (a CDN in front), [server] skew_protection = false ignores the old id instead, so those pages keep navigating without a reload (startup logs a warning).