GioJSdocs
On this page

Configuration

All GioJS configuration lives in gio.toml at the project root. Every field is optional - defaults are production-ready - and an unknown key stops the server with a hint instead of being ignored.

Every protection and feature is on by default, and each one can be turned off or loosened here. Doing so is never silent: the protections log a warning at startup that names the key (see Turning things off).

Sections

Each section has its own reference page, with every key, its default, what 0 or false means and what it costs to turn off:

SectionWhat it configures
[app]Informational name and router; never read.
[server]Listen address, connection limits and timeouts, body limit, proxies, request ids, workers, skew protection.
[server.tls]Terminate TLS in GioJS from a PEM certificate and key.
[security]Default security headers, HSTS, Content-Security-Policy with nonces.
[security.csrf]Cross-site request protection, trusted origins and exempt paths.
[security.websocket]The Origin check on WebSocket upgrades.
[cache]The page cache: memory and disk tiers, ETags, stale-while-revalidate.
[compression]Brotli and gzip.
[prefetch]Per-client prefetch budgets and the prefetch switch.
[images]The /_gio/image optimizer: widths, quality, formats, remote sources, limits.
[[fonts]]Self-hosted WOFF2 fonts and their preload links.
[css]Path-served stylesheets, minification, critical CSS.
[websocket]WebSocket routes: switch, connection cap, pings.
[[rate_limits]]Token-bucket budgets per client and path.
[[redirects]]Redirect rules evaluated before routing.
[[rewrites]]Serve another route under the requested URL.
[[headers]]Response headers for matching paths.
[[guards]]Session and cookie gates.
[i18n]Locales and how the request locale is detected.
[metrics]The Prometheus endpoint and who may scrape it.
[health]The /_gio/health endpoint and its details.
[revalidate]The token for POST /_gio/revalidate.
[logging]Text or JSON server logs.
[env]Whether .env files are loaded.
[dev]Dev-only: allowed hosts, devtools, the file watcher.

gio.config.ts holds what only JavaScript can express (Node plugins); see gio.config.ts below and its reference.

Full reference

Every key GioJS reads, with its default. Every section and key is optional, and a partial table keeps the defaults for what it leaves out ([server] with only http2 = false still listens on 0.0.0.0:3000).

toml
#:schema ./node_modules/@gio.js/server/gio.schema.json

[app]                   # informational
name = "my-app"
router = "app"          # the app/ router is the only one

[server]
host = "0.0.0.0"        # IP address to bind; GIO_HOST overrides
port = 3000             # GIO_PORT, then PORT, override
http2 = true            # HTTP/2 support
max_body_bytes = 2097152  # request body limit (2 MiB); 0 = none of its own (64 MiB IPC cap)
max_connections = 10000   # concurrent connections (see Connection limits)
tls_handshake_timeout_secs = 10
header_read_timeout_secs = 10       # slowloris guard; also the HTTP/1.1 idle timeout
request_body_timeout_secs = 30      # whole-body upload deadline, then 408
render_timeout_secs = 30            # worker answer deadline, then 504; 0 = none
idle_timeout_secs = 60              # close connections with nothing in flight
http2_max_concurrent_streams = 250
http2_keep_alive_interval_secs = 20 # PING HTTP/2 peers this often...
http2_keep_alive_timeout_secs = 20  # ...and drop them if the ack takes longer
trusted_proxies = []    # reverse proxies whose forwarding headers count (see Reverse proxies)
proxy_headers = "x-forwarded"       # "x-forwarded" (X-Forwarded-*) or "forwarded" (RFC 7239)
accept_request_id = true            # keep a trusted proxy's X-Request-Id (false = always generate)
skew_protection = true  # 409 + hard reload for a client from another deployment (false = ignore)
workers = 1             # Node render processes: a count or "auto" (see Render workers)
rate_limit_max_buckets = 100000     # live [[rate_limits]] buckets kept; 0 = unlimited

[server.tls]
enabled = false         # set true to terminate TLS in GioJS directly
cert_path = "/path/to/cert.pem"
key_path  = "/path/to/key.pem"

[cache]                 # the page cache (see Caching)
enabled = true          # false: store nothing, render every request (Cache-Control unchanged)
memory_max_entries = 1000           # pages kept in memory; the disk tier holds the rest
disk_enabled = true     # false: memory only, no files written
disk_path = ".gio/cache/pages"      # relative to the project root; GIO_CACHE_DIR overrides
disk_max_bytes = 536870912          # disk tier cap (512 MiB), oldest evicted first; 0 = unbounded
etag = true             # false: no page ETags, no 304s
swr_multiplier = 10     # serve stale until 10x revalidate old; 0 = never stale

[compression]
enabled = true          # gzip / Brotli, negotiated from Accept-Encoding
min_size_bytes = 1024   # smaller bodies are sent as-is (max 65535)
prefer_brotli = true    # false = gzip only

[prefetch]              # per-client budgets for <GioLink> prefetches (429 past them)
enabled = true          # false: every prefetch gets 429, nothing prefetches
max_concurrent = 5      # in flight at once; 0 = unlimited
max_per_second = 20     # 0 = unlimited

[[fonts]]               # self-hosted fonts, repeat per font file
family = "Inter"
url    = "/fonts/inter.woff2"   # public/fonts/inter.woff2, or an https:// URL (downloaded once)
weight = 400            # default 400
style  = "normal"       # default "normal"
preload = true          # false: no preload link (fonts used below the fold)

[images]
enabled = true          # false: /_gio/image is a 404 and <GioImage> renders plain src
allowed_widths = [16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840]
quality = 75            # 1-100
formats = ["avif", "webp"]    # modern formats to negotiate, in order; JPEG is the fallback
disk_max_bytes = 536870912    # on-disk image cache cap (512 MiB)
max_remote_bytes = 20971520   # max fetched remote source size (20 MiB); 0 = unlimited
remote_timeout_secs = 30      # whole remote download deadline; 0 = none
max_source_dimension = 10000  # widest/tallest source decoded, in px; 0 = unlimited
max_decode_bytes = 268435456  # decoder memory per source (256 MiB); 0 = unlimited

[[images.remote_patterns]]
protocol = "https"      # default "https"
hostname = "images.example.com"
pathname = "/photos/*"  # optional; exact match, or prefix with trailing *

[css]
enabled = true          # serve app/*.css by path (imported CSS is always bundled)
minify = true           # production CSS, path-served and bundled (standalone: at build time)
critical_extraction = true

[websocket]
enabled = true
max_connections = 1000  # 0 = unlimited
ping_interval_secs = 30 # 0 = no server pings

[[rate_limits]]         # repeat per path rule; /_gio/image honors these too
path = "/api/*rest"     # rule syntax: exact, :param, trailing *rest (covers /api too)
per_ip = 100            # requests per window (default 100)
window_seconds = 60     # default 60
burst = 20              # default 20
key_header = "x-api-key"  # optional: key on a header value instead of IP
max_keys_per_client = 64  # key_header values one client may hold a budget for; 0 = unlimited

[[redirects]]           # evaluated in Rust before routing (see Middleware)
from = "/old-blog/:slug"
to = "/posts/:slug"
status = 301            # 301/302/307/308, default 302

[[rewrites]]            # serve another route without changing the URL
from = "/latest"
to = "/posts/newest"

[[headers]]             # stamp response headers on matching paths
path = "/api/*"
[headers.headers]
x-frame-options = "DENY"

[[guards]]              # session gate: redirect unless the session verifies
path = "/admin/*rest"
require_session = true  # signed, unexpired gio_session (GIO_SESSION_SECRET)
redirect_to = "/login"
# require_cookie = "session"  # alone: only checks the cookie is present

[security]              # see Security
default_headers = true  # false drops nosniff, X-Frame-Options and Referrer-Policy
csp = ""                # Content-Security-Policy (unset or "" = none); "{nonce}" = fresh nonce per response
csp_report_only = ""    # same syntax, sent as Content-Security-Policy-Report-Only
# hsts                  # unset: max-age=31536000 only with [server.tls]
#                       # true | false | "raw value" | { max_age = 31536000, include_subdomains = false, preload = false }

[security.headers]      # empty by default: override ("value"), remove (""), or add headers
x-frame-options = "SAMEORIGIN"
permissions-policy = "camera=()"

[security.csrf]         # cross-site request protection, on by default
enabled = true
trusted_origins = []    # other origins allowed to POST / open WebSockets
exempt = []             # paths never checked: webhooks, OAuth form_post / SAML callbacks

[security.websocket]
check_origin = true     # WebSocket Origin check, independent of [security.csrf] enabled

[i18n]
locales = ["en", "de"]  # empty = i18n disabled
default_locale = "en"
detect_from = ["path", "accept-language", "cookie"]

[metrics]               # /_gio/metrics (Prometheus) is off while this section is absent
enabled = true          # with the section present; false = 404
token = ""              # require "Authorization: Bearer <token>" when set
ip_allowlist = []       # client IPs or CIDRs, e.g. ["10.0.0.5", "10.1.0.0/16"]; with no token
                        # either, loopback clients only; ["0.0.0.0/0", "::/0"] = everyone

[health]
enabled = true          # serve /_gio/health (false = 404)
details = true          # false: only {"status":"ok","nodeReady":...}

[env]
files = true            # load the .env files at startup; GIO_ENV_FILES=0|1 wins

[logging]
format = "text"         # "json": one JSON object per line (GIO_LOG_FORMAT overrides)

[revalidate]            # see Caching
token = ""              # enables POST /_gio/revalidate (>= 32 bytes); GIO_REVALIDATE_TOKEN wins

[dev]                   # only read when NODE_ENV=development
allowed_hosts = []      # extra Host names the /_gio/devtools endpoints answer to; ["*"] = any
devtools = true         # false: /_gio/devtools* is a 404, the overlay shows no codeframes
watch = true            # restart the worker on source changes
watch_ignore = []       # globs the dev watcher never restarts for, e.g. ["data/**", "*.db"]

[x-mytool]              # tables named x-* are left alone, for other tools
anything = "goes"

Strict by design

gio.toml is checked against this reference when the server starts. An unknown section or key anywhere - a typo, or a setting from another framework - stops the server with the file, the line, the full key path and the closest valid key, instead of being silently ignored while the default you meant to change stays in effect:

text
giojs-server: configuration error: gio.toml:12: unknown key [image] - did you mean [images]?
giojs-server: configuration error: gio.toml:14: unknown key `images.allowed_width` - did you mean `images.allowed_widths`?
giojs-server: configuration error: gio.toml:21: invalid `server.port`: invalid type: string "http", expected u16

The file is named as the server found it: gio.toml in the directory it runs in, or the path next to GIO_APP_DIR when that variable is set.

  • Wrong values fail the same way: a malformed trusted_proxies entry, a host that is not an IP address, an unknown [logging] format, a [[guards]], [[redirects]], [[rewrites]] or [[headers]] rule that cannot be enforced as written, a [[rate_limits]] path that cannot be parsed, an [i18n] whose default_locale is not one of its locales, a page cache directory inside app/ or public/.
  • Startup reports every refusal at once - every unknown key and section, every invalid value, the TLS certificate, [security], the revalidation token and local [[fonts]] files included - before the worker starts. Rules and [i18n] locales holding a misspelled or invalid key are checked once it is fixed (left out, it would only report fallout). A required key that is missing, or whose value is invalid (path = 3, or a [[rate_limits]] path that is not a valid pattern), ends the report there, and its last line says which checks did not run.

Strictness itself has no switch: a setting silently ignored is worse than a server that refuses to start. [x-*] tables are the escape hatch.

[x-*] tables

Top-level tables named x-... ([x-deploy]) are never read, so other tools can keep their settings in the same file. Anywhere else an x- key is unknown like any other, and a top-level table with another unknown name is refused with a hint: unknown key [mytool] - tables for other tools must be named x-... ([x-mytool]).

gio.toml
[x-deploy]
region = "eu-west-1"
replicas = 3

Retired keys

Keys earlier versions documented but never acted on are refused with what to do instead:

KeyInstead
[cache] memory_mbThe memory cache is bounded by entry count: use memory_max_entries (default 1000).
[cache.redis]There is no Redis cache backend yet: each instance keeps its own memory and disk cache. Remove the table.
[css] engineLightning CSS is the only CSS engine. Remove the key.
[prefetch] strategyChosen per link: <GioLink prefetch="hover" | "viewport" | {false}> (default "hover"). Remove the key.

Checking a configuration

giojs-server --check-config loads the .env files and gio.toml exactly as startup does, runs startup's checks, prints one JSON report and exits - 0 when the server would start, 1 when it would refuse - without binding a port. It never prints a secret, so it works as a CI step. gio doctor runs it for you.

bash
npx giojs-server --check-config

The report is one line of JSON; formatted, with one loosened limit:

json
{
  "cacheDir": "/srv/my-app/.gio/cache/pages",
  "configFile": "gio.toml",
  "envFiles": [".env"],
  "envFilesDisabledBy": null,
  "errors": [],
  "listen": { "host": "0.0.0.0", "port": 3000, "portSource": "default", "tls": false },
  "mode": "production",
  "ok": true,
  "proxyHeaders": "x-forwarded",
  "rateLimitRules": 0,
  "sessionGuards": 0,
  "sessionSecret": "unset",
  "sessionSecretError": null,
  "trustedProxies": 0,
  "warnings": [
    "[server] max_connections = 0: concurrent connections are unlimited - a connection flood can exhaust file descriptors and memory"
  ]
}

Editor autocomplete

@gio.js/server ships a JSON Schema for gio.toml, generated from the server's own config types, so it always matches what the server accepts. New apps start with the line that points editors at it; add it to the top of an existing gio.toml:

toml
#:schema ./node_modules/@gio.js/server/gio.schema.json
  • VS Code: install Even Better TOML (tamasfe.even-better-toml). Keys and values complete, unknown keys are underlined and hovering a key shows its documentation.
  • Other editors: any editor using the Taplo language server (Zed, Neovim, Helix) reads the same #:schema directive. JetBrains IDEs can map gio.toml to the schema file under Languages & Frameworks › Schemas and DTDs › JSON Schema Mappings.

Listen address

The server listens on [server] host and port, which default to 0.0.0.0 and 3000. The environment wins over the file, so one gio.toml serves every environment:

SettingPrecedence (first set wins)
PortGIO_PORT, then PORT, then [server] port, then 3000
HostGIO_HOST, then [server] host, then 0.0.0.0

The host is an IP address: 0.0.0.0 (every interface), 127.0.0.1 (this machine only), or IPv6 in brackets ([::], [::1]).

PORT is the variable Heroku, Render, Railway, Fly.io and Cloud Run set, so GioJS binds where the platform expects with no configuration. A plain HOST variable is not read: shells and CI images often set it to the machine's hostname. Empty values are ignored, and a malformed one (a port that is not a number, a host that is not an IP address) stops startup. The startup log names the address and where the port came from (GioJS listening on 0.0.0.0:8080 port_from="PORT").

Page cache, compression & prefetch

In short; [cache], [compression] and [prefetch] have the details.

KeyDefaultDescription
[cache] enabledtruefalse stores and serves nothing from the page cache: every request renders and answers X-Gio-Cache: bypass. Pages still send the Cache-Control their revalidate asks for, so a CDN in front can keep caching them.
[cache] memory_max_entries1000Pages kept in the in-memory LRU. Pages pushed out of memory are still served from the disk tier. At least 1: enabled = false is the off switch.
[cache] disk_enabledtruefalse keeps the memory LRU only: no entry files are written, a page the LRU drops renders again, and nothing survives a restart.
[cache] disk_path.gio/cache/pagesThe disk tier's directory, relative to the project root: a directory below the root (not ., not outside the project). Eviction and development-mode clears only ever delete the cache's own entry files (<sha256>.json), so other files in the directory are safe, but a dedicated directory keeps things clear. It must not be, contain or sit inside app/ or public/ (where entries would be served as static files); startup stops if it does. GIO_CACHE_DIR overrides it, may be absolute, and is held to the same rule.
[cache] disk_max_bytes536870912 (512 MiB)Size cap of the disk tier; the oldest entries are evicted past it. 0 disables the cap.
[cache] etagtruefalse sends no ETag with pages and never answers 304, for CDNs that mishandle weak validators or apps that set their own.
[cache] swr_multiplier10A page stays servable stale (while one refresh runs) until it is this many times its revalidate old, and the stale-while-revalidate directive covers the same window. 0 never serves stale and drops the directive.
[compression] enabledtrueCompress responses with Brotli or gzip, whichever the client accepts. Images, server-sent events and responses that already carry a Content-Encoding are never compressed. Turn it off when a proxy or CDN in front compresses instead.
[compression] min_size_bytes1024Responses with a known length below this are sent as-is. Streamed responses have no known length and are always compressed. At most 65535.
[compression] prefer_brotlitruetrue: Brotli for clients that accept it, gzip otherwise. false: gzip only.
[prefetch] enabledtruefalse answers every prefetch request 429 before it renders, which turns prefetching off site-wide.
[prefetch] max_concurrent5Prefetch requests (Purpose: prefetch, sent by <GioLink>) one client may have in flight. Past it the server answers 429, which the client treats as "not prefetched". 0 = unlimited.
[prefetch] max_per_second20Prefetch requests one client may start per second. 0 = unlimited.

Dev watcher

In development the server restarts the Node worker when source changes anywhere in the project. Outside app/ only source-like files count (.ts, .tsx, .js, .json, .css, .toml, ...), so SQLite databases, logs and uploads your app writes never restart it - but a JSON data file (a lowdb db.json) would, after every write. List such files in [dev] watch_ignore, or set [dev] watch = false to run without the watcher at all (a huge monorepo, a network filesystem, a container out of inotify watches) and restart the server yourself after a change (see [dev]):

toml
[dev]
watch_ignore = ["data/**", "*.db.json", "public/uploads"]
  • Patterns are relative to the project root. * matches within one path segment, ? one character, and ** any number of segments; every other character is literal (app/[slug]/cache.json names that route folder).
  • A pattern without a / matches a file or directory name at any depth (*.db.json, uploads); one with a / is anchored at the root. Matching a directory covers everything in it, and data/** covers data/ itself: a top-level directory it matches is not watched at all.
  • The default is empty. node_modules, hidden directories (.git, .gio) and build output (dist, build, out, ...) are always ignored. A malformed pattern (.., a backslash) stops startup.
  • The page cache's own entry files are never a change, wherever [cache] disk_path puts them, so a visible cache directory needs no pattern. Other files in that directory still count.
  • Editing gio.toml restarts the worker like any other source file, but the new settings do not apply: the Rust server reads gio.toml once, at startup. Stop and start gio dev after changing it. .env files are read once too, and editing them restarts nothing: restart gio dev.

gio.config.ts

What only JavaScript can express lives in an optional gio.config.ts next to gio.toml. Today that is Node plugins (GioNodePlugin: onRequest / onResponse hooks around every request the worker handles). defineConfig types it:

gio.config.ts
import { defineConfig } from '@gio.js/core';
import { auditPlugin } from './lib/audit-plugin';

export default defineConfig({
  plugins: [auditPlugin],
});

It is checked at boot like gio.toml: an unknown key (plugin:) or a plugin without a name stops the worker with an error naming the file. The gio.config.ts reference covers the rest: every hook, which requests reach the plugins, and how they interact with caching and streaming.

Connection limits

The Rust server bounds what a single client can hold open, so slow or idle connections cannot exhaust it. Every field lives in [server], and setting any of them to 0 disables that limit.

FieldDefaultDescription
max_connections10000Concurrent client connections. At the cap the server stops accepting, and new clients wait in the OS listen backlog until a connection closes. Upgraded WebSockets are not counted here; [websocket] max_connections caps them. Keep the process file-descriptor limit (ulimit -n) above this value.
tls_handshake_timeout_secs10Deadline for completing the TLS handshake when [server.tls] is enabled.
header_read_timeout_secs10Deadline for receiving a complete request head (the slowloris guard). A new connection must send its first request within it, including the HTTP/2 handshake. Because the timer restarts while an HTTP/1.1 connection waits for its next request, it is also the HTTP/1.1 keep-alive idle timeout.
request_body_timeout_secs30Deadline for receiving a whole request body. A client that sends the body too slowly gets 408 Request Timeout.
render_timeout_secs30Deadline for the Node worker's answer: a whole buffered response, the head of a streamed one, and every gap between its chunks. Past it the request answers 504, or a streamed body ends where it is. SSE streams are not bounded by it. 0 lets a render that never answers hold its connection and a worker slot indefinitely.
idle_timeout_secs60Connections with no request in flight are closed after this long, gracefully for HTTP/2 (GOAWAY). In practice it applies to HTTP/2, because HTTP/1.1 idles are reaped by header_read_timeout_secs first.
http2_max_concurrent_streams250Concurrent streams (requests) per HTTP/2 connection.
http2_keep_alive_interval_secs
http2_keep_alive_timeout_secs
20 / 20The server PINGs each HTTP/2 connection on this interval and closes it if the ack does not arrive within the timeout, so dead peers are reaped. Setting either to 0 disables pings.

These deadlines never cut an established response. Streaming SSR, SSE streams, streamed route handler responses and WebSockets stay open as long as they run: the head deadline covers only the reading of request heads, and a connection with a response still streaming is never idle.

HTTP/1.1 responses carry Keep-Alive: timeout=N, so clients stop reusing a connection before the server closes it. Proxies and load balancers that pool upstream connections to GioJS (nginx keepalive, ingress-nginx, AWS ALB) ignore that hint and keep idle connections for 60 seconds by default, longer than the 10-second HTTP/1.1 idle close. Set the proxy's upstream idle timeout below header_read_timeout_secs, or raise both header_read_timeout_secs and idle_timeout_secs above the proxy's timeout (the proxy already absorbs slow clients). Otherwise the proxy can reuse a connection at the moment GioJS closes it and answer that request with a 502. The deployment guide has settings for each proxy.

Render workers

Pages, getServerSideProps and route handlers run in a Node worker process the Rust server supervises. By default there is one, so all rendering shares one CPU core: a slow render holds up the renders queued behind it (cache hits, static files and images never wait - Rust serves those). To render on several cores, run a pool:

toml
[server]
workers = 4        # or "auto": one per CPU core, at most 8
  • Each request goes to the ready worker with the fewest requests in flight (an open SSE stream or a streaming response counts until it ends). A streaming response, an SSE stream or a Partial Prerendering hole render stays on the worker that started it, and each WebSocket stays on one worker for its lifetime. Room broadcasts (broadcast(room, ...)) reach sockets on every worker - room membership lives in the Rust server.
  • Workers are supervised one by one: a crashed worker fails only the requests it had in flight (503) and is respawned while the others keep serving.
  • Only the first worker bundles the client code into .gio/build and writes .gio/routes.d.ts; the others start once it is ready and load its build, so a pool costs no extra build time. In production a respawned worker reuses that build too. No worker rebuilds while others serve from .gio/build: one that cannot load the build fails its boot and is retried, and a first worker that cannot record its build stops startup.
  • Every worker loads the same app, and the middleware rules of middleware.ts are taken from the first ready worker. revalidatePath / revalidateTag purge the one cache the Rust server holds, whichever worker calls them.
  • Module-level state is per worker. A counter or an in-memory store kept in a module variable exists once per worker, so it is not shared - keep shared state in a database, a cache server or the session cookie.
  • Node plugin hooks (plugins in gio.config.ts) run per worker too: onStartup runs in every worker, and again when a worker is respawned, and onShutdown in every worker as it stops. One-time work (a migration, a scheduler, a queue consumer) belongs outside the server or behind a guard: each worker gets GIO_WORKER_INDEX ("0" for the first) and GIO_WORKER_COUNT, so process.env.GIO_WORKER_INDEX === '0' limits a job to one worker per server - keep it idempotent, as that worker can be respawned too.

Every worker is a full Node process with its own copy of your app and React, so memory grows with the pool - plan for roughly the RSS of one worker (often 100-200 MB) per worker, and see Sizing before raising it. Dev mode always runs one worker: every edit restarts it with a fresh build. /_gio/health reports workers: { configured, ready }, and the metrics carry each worker's load and restarts.

Reverse proxies & client IPs

Behind a reverse proxy or load balancer, every connection GioJS sees comes from the proxy. The real client is in a forwarding header the proxy adds - which any client can also send itself. trusted_proxies lists the peers whose forwarding headers GioJS believes; from everyone else they are ignored entirely:

toml
[server]
trusted_proxies = ["127.0.0.1", "::1", "10.0.0.0/8"]   # IPs and CIDR blocks, IPv4 and IPv6

The default is an empty list: nobody is trusted and the connecting address is the client, which is right when GioJS faces the internet directly. A malformed entry stops the server at startup. With trusted proxies configured, the client IP is found by walking X-Forwarded-For from right to left, skipping every trusted address; the first untrusted one is the client (if every hop is trusted, the leftmost). Entries left of it were written by the client and are never read - not even checked for being well-formed - so nothing a client sends there (a spoofed address, junk bytes, kilobytes of padding) changes the answer. Only when a hop a trusted proxy wrote is not an address (say, unknown) is the client unknown: the connecting address stands in for it in rate limits and req.ip, and the metrics allowlist refuses the request.

From a trusted peer, X-Forwarded-Proto sets the scheme (otherwise https when [server.tls] is on, else http) and X-Forwarded-Host sets the host (otherwise the Host header). GioJS reads the value the nearest proxy wrote - the last one - so a proxy that appends to these headers instead of replacing them (HAProxy's add-header) is safe; behind a chain of proxies, have the inner one pass the outer one's value on. Set proxy_headers = "forwarded" if your proxy sends the standard RFC 7239 Forwarded: for=...;proto=...;host=... header instead; exactly one header family is read, because a proxy that manages one passes a client's copy of the other straight through.

The resolved client is used everywhere a client matters:

  • [[rate_limits]] buckets (IPv6 clients are still grouped by /64) and prefetch budgets.
  • The [metrics] ip_allowlist - allowlisting 127.0.0.1 no longer admits everything a local proxy forwards.
  • req.ip in route handlers and ctx.ip in getServerSideProps, plus scheme and host.
Only list proxies you control, and make sure each one sets X-Forwarded-Proto and X-Forwarded-Host (or strips them) rather than passing a client's values through. Some cannot: AWS ALB and Google Cloud's load balancer forward a client's X-Forwarded-Host untouched, as they do Host. The host is client-supplied unless your proxy pins it, so never use req.host / ctx.host to make a security decision. Never trust a range your visitors can connect from - with 0.0.0.0/0 every client picks its own IP. The deployment guide has per-proxy settings.

Request IDs

Every response carries an X-Request-Id header - cache hits, static files, redirects and errors included. The same id is on the server's log lines for that request and on every log line the Node worker writes while handling it (see Observability), and route handlers and getServerSideProps can read it as req.requestId / ctx.requestId.

An incoming X-Request-Id is kept only when it comes from a trusted proxy and matches ^[A-Za-z0-9._:-]{1,128}$, so a proxy's id follows the request through. Otherwise GioJS generates a UUID, so a client talking to GioJS directly cannot pick its own id. Behind a proxy, it is the proxy that decides: nginx with proxy_set_header X-Request-Id $request_id always sets its own, but many proxies pass a client's header through unchanged (Traefik, Caddy unless told otherwise, AWS ALB, Google Cloud's load balancer, Cloudflare), and ingress-nginx reuses an incoming one on purpose. Behind those, have the proxy set or strip the header, or turn adoption off and GioJS generates every id itself:

toml
[server]
accept_request_id = false   # ignore incoming X-Request-Id, even from trusted proxies

Health & metrics

GioJS serves its built-in endpoints directly from the Rust layer - no Node round-trip, so they stay responsive even under load. They are configured in [health], [metrics] and [revalidate]:

EndpointDefaultDescription
/_gio/healthonLiveness probe - returns 200 with a JSON body: {status, http2, tls, deploymentId, nodeReady, workers, cacheEntries, uptimeSecs}. nodeReady is false while no Node SSR worker is ready - during a respawn of the only worker, or of every worker in a pool (cached and static content still serves) - so readiness probes should check that field. workers is { configured, ready } (see Render workers). [health] details = false leaves only {status, nodeReady}, so the deployment ID and worker topology are not public; [health] enabled = false unroutes it (404).
/_gio/metricsoffPrometheus exposition (request counts and latency histograms labeled by route pattern, cache tiers, IPC timing - see Observability). Returns 404 until enabled via [metrics].
/_gio/revalidateoffPOST purges cached pages by tag or path for CMS webhooks and scripts, authenticated with Authorization: Bearer. Returns 404 until a token is set (GIO_REVALIDATE_TOKEN or [revalidate] token) - see Caching.

Metrics are opt-in so you never expose them by accident. A [metrics] section with neither a token nor an allowlist answers only clients on this machine (loopback); anything else gets 403. Open it up with either or both:

toml
[metrics]
enabled = true          # serve /_gio/metrics

# Secure it for production - use either or both:
token        = "a-long-random-secret"     # require Authorization: Bearer <token>
ip_allowlist = ["10.0.0.5", "10.0.0.6"]   # only allow these client IPs (or CIDRs)

The allowlist checks the client IP after trusted_proxies resolution: behind a trusted proxy it is the forwarded client, never the proxy itself.

bash
# Scrape with a token:
curl -H "Authorization: Bearer a-long-random-secret" \
  http://localhost:3000/_gio/metrics

A malformed ip_allowlist entry ("10.0.0.0/33") stops startup, like a malformed trusted_proxies one, instead of quietly matching nobody.

Loopback means the client after trusted_proxies resolution: behind a proxy on the same machine, list it in trusted_proxies so its forwarded clients are not mistaken for local ones. Until you do, a request it forwards with X-Forwarded-For, Forwarded or X-Real-IP gets 403, but one forwarded without any of those headers comes from 127.0.0.1 and is answered - whoever sent it. The startup line about loopback-only metrics says so whenever trusted_proxies is empty. To serve metrics to every client with no token, say so explicitly with ip_allowlist = ["0.0.0.0/0", "::/0"] - the server then logs a warning at startup. With metrics off (no [metrics] section, or enabled = false) the endpoint answers 404.

Dev endpoints & allowed hosts

In development the server also serves /_gio/devtools and its sub-endpoints: the dashboard, its state and event stream (which also drives live reload), error-overlay codeframes that return project source, and open-in-editor. Because the starter binds 0.0.0.0, they are locked down against browser-based attacks (every key is on the [dev] page):

  • They only answer requests whose Host is localhost, *.localhost, a loopback IP (127.0.0.1, [::1]), the [server] host when it names a specific address, or an entry in [dev] allowed_hosts. Anything else gets a 403 - this defeats DNS rebinding, where a malicious site re-points its own domain at your machine.
  • The localhost names and loopback IPs count only on a connection from this machine. A client on another machine can send Host: localhost itself, so its requests must name the [server] host or an allowed_hosts entry.
  • The state, stream and codeframe reads refuse requests a browser marks Sec-Fetch-Site: cross-site, and requests whose Origin is neither the requested host nor a host in allowed_hosts (the latter covers tunnels and port forwarders that rewrite Host to localhost).
  • open-in-editor accepts POST only, also refuses Sec-Fetch-Site: same-site (only same-origin calls pass), and applies the same Origin rule, so a link, form or <img> on another site cannot launch your editor.
  • The dashboard page itself only checks Host: following a link to it is harmless, and another site cannot read it.
  • SSR error pages served to any other host leave out the error message and stack, which name files and code on your machine.

If you browse the dev server from another machine or through a name - a VM, a container host, a phone on your LAN, a tunnel - add the hostname or IP you type in the address bar:

toml
[dev]
allowed_hosts = ["192.168.1.20", "myvm.local", "*.tunnel.example"]  # "*." or "." = any subdomain

Entries are hostnames or IPs; a port or a pasted http(s):// prefix is ignored, and an entry that is not a host is skipped with a startup warning naming it (--check-config and gio doctor report it too). Pages themselves are unaffected; without the entry only the overlay codeframes, open-in-editor, live reload, the dashboard, and the details on SSR error pages stop working from that host. When bound to 0.0.0.0 with no allowed_hosts, the server logs a reminder at startup. Blocked requests are logged once per distinct host or origin.

An allowed_hosts entry opens the dev endpoints - project source included - to every client that can reach the port and sends that host, not only to you. On an untrusted network, list no hosts and bind the dev server to 127.0.0.1 (or publish the container port to 127.0.0.1 only). Behind a container port mapping the connection comes from another address, so localhost itself needs an entry there.

allowed_hosts = ["*"] answers any Host from any machine, error details included, and logs a loud warning at startup: DNS rebinding is no longer blocked. Open-in-editor ignores "*": it stays same-origin and answers only localhost hosts from this machine, a specific [server] host and the hosts listed by name, so a rebound site cannot launch your editor. To have no dev endpoints at all, set [dev] devtools = false: /_gio/devtools* answers 404, and the error overlay shows the message and stack without codeframes, editor links or live reload.

Security

Without any [security] section every response carries X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN and Referrer-Policy: strict-origin-when-cross-origin (plus Strict-Transport-Security when [server.tls] is enabled), and cross-site POST/PUT/PATCH/DELETE requests and WebSocket upgrades are refused with 403. A Content-Security-Policy with per-response nonces is one line away. Headers your app or a [[headers]] rule sets win over these defaults, and [security] default_headers = false drops the three built-in ones. See Security for every option.

Every protection you turn off or loosen in gio.toml logs one warning at startup naming the key, and giojs-server --check-config (and gio doctor) reports the same text under warnings. The table in Turning things off lists them all.

Turning things off

Every protection and feature below is on by default. The "Warns" column marks the ones whose off value logs a startup warning; each section page quotes the exact text. The Turning Protections On and Off guide walks through when each switch makes sense.

Protection or featureKeyDefaultOff valueWhat you loseWarns
CSRF protection[security.csrf] enabledtruefalseOther websites can send form posts and other unsafe requests with the cookies of your visitors.yes
WebSocket origin check[security.websocket] check_origintruefalseOther websites can open WebSockets with the cookies of your visitors.yes
Default security headers[security] default_headerstruefalseMIME sniffing, framing by other sites and full-URL referrers come back.yes
HSTS while TLS is on[security] hstsunsetfalseBrowsers may connect over plain HTTP.no
Request body limit[server] max_body_bytes20971520Each body is buffered up to the 64 MiB worker message cap.yes
Connection cap[server] max_connections100000A connection flood can exhaust file descriptors and memory.yes
Connection timeouts[server] *_timeout_secs10 to 600Slow or idle clients can hold connections open.no
Render timeout[server] render_timeout_secs300A render that never answers holds its connection and a worker.yes
HTTP/2[server] http2truefalseClients speak HTTP/1.1 only (with TLS, ALPN offers only http/1.1).no
Skew protection[server] skew_protectiontruefalseOld clients keep navigating softly against a new deployment.yes
Trusting no proxy[server] trusted_proxies[]["0.0.0.0/0"]Any client can pick its own IP, rate-limit bucket and request id.yes
Rate-limit memory cap[server] rate_limit_max_buckets1000000Clients rotating addresses grow memory without bound.yes
Per-client key cap[[rate_limits]] max_keys_per_client640One client can mint a fresh budget per key_header value.yes
Page cache[cache] enabledtruefalseEvery request renders.no
Disk cache tier[cache] disk_enabledtruefalseThe cache is memory only and starts empty after a restart.no
Page ETags[cache] etagtruefalseNo 304 responses for pages.no
Stale-while-revalidate[cache] swr_multiplier100A stale page renders before it is served.no
Compression[compression] enabledtruefalseBigger responses.no
Prefetching[prefetch] enabledtruefalseLinks load on click, not ahead of it.no
Prefetch budgets[prefetch] max_concurrent, max_per_second5, 200The prefetches of one client are unbounded.no
Image optimizer[images] enabledtruefalseImages are served at full size without a srcset.no
Image limits[images] max_remote_bytes, remote_timeout_secs, max_source_dimension, max_decode_bytes20 MiB, 30, 10000, 256 MiB0One image can cost unbounded memory, CPU or time.yes
Path-served CSS[css] enabledtruefalseapp/*.css is not served by path, and no critical CSS.no
CSS minification[css] minifytruefalseBigger production stylesheets.no
Critical CSS[css] critical_extractiontruefalseNo inlined first-paint CSS.no
Font preload[[fonts]] preloadtruefalseThe font loads when text needs it.no
WebSockets[websocket] enabledtruefalseNo WebSocket connections: upgrades get 501.no
WebSocket cap[websocket] max_connections10000Open sockets are unbounded.yes
WebSocket pings[websocket] ping_interval_secs300Vanished peers are noticed later.no
Metrics for this machine only[metrics] ip_allowlist[]["0.0.0.0/0", "::/0"]Anyone can scrape /_gio/metrics.yes
Health endpoint[health] enabledtruefalse/_gio/health answers 404.no
Health details[health] detailstruefalseNo deployment id or worker topology in the answer.no
.env files[env] filestruefalseOnly the process environment counts.no
Dev host check[dev] allowed_hosts[]["*"]DNS rebinding can read the dev endpoints and error details.yes
Devtools[dev] devtoolstruefalseNo dashboard, codeframes, editor links or live reload.no
Dev watcher[dev] watchtruefalseNo restart on source changes.no

[security] csp is the one protection that is off by default: a policy only you can write, and nonces make every page private, so CDN caching and ETags are lost while it is on. See Content Security Policy.

Some behavior has no switch at all, on purpose:

  • Path canonicalization and its 400s, and the closed /_gio namespace ([server]).
  • Hiding error details in production: the digest is kept, the message and stack go to the log ([security]).
  • The cache bypass for personalized renders: use PPR holes instead ([cache]).
  • The server-only import guard, which each module opts into (below).
  • Strict unknown-key errors ([x-*] tables are the escape hatch) and the GIO_PUBLIC_ prefix.
  • Parts of switchable features: the same-origin check on open-in-editor ([dev]), the image optimizer's path-traversal checks, redirect blocking and guard enforcement ([images]), request id validation, and the 32-byte minimum for the revalidation token ([revalidate]).

Rate limits

Each [[rate_limits]] rule is a token bucket per client: per_ip requests per window_seconds, plus burst on top. path uses the rule pattern syntax (/api/*rest, /users/:id), and a path that cannot be parsed stops startup. When several rules match, the one covering most of the path wins. Limits run in Rust before routing, so a rejected request (429 with Retry-After) never reaches Node. Every key is on the [[rate_limits]] page.

  • Paths are matched in canonical form - the same form middleware rules use. Repeated and trailing slashes and percent-escaped letters, digits, -, ., _, ~ are normalized first, so /api/login/, //api//login and /api/%6Cogin all draw from the /api/login bucket, just as they all reach the same handler.
  • /api/*rest also covers /api itself (/api/ is the same path). An exact /api rule still takes precedence there, whichever order the two rules are listed in.
  • A client is an IPv4 address or an IPv6 /64. IPv6 hosts typically control a whole /64, so per-address buckets would let one host rotate into unlimited fresh budgets.
  • Memory is bounded. Buckets that have refilled are dropped (a new one starts full, so nothing is lost), and the store holds at most [server] rate_limit_max_buckets buckets (100,000) - past that the least recently seen are evicted. 0 lifts the cap.
  • key_header budgets are capped per client. One client gets its own bucket for at most max_keys_per_client (64) distinct header values per rule; further values share the client's own bucket, so rotating the header cannot mint fresh budgets. Set it to 0 (no cap) for an API gateway whose many keys arrive from one address.

Environment variables

A few runtime knobs live in the environment rather than gio.toml. The table below gives each one in a line; Environment variables is the complete reference - which process reads each variable, how it combines with gio.toml and .env files, and the variables only the CLI, the exporter, the testing kit and create-giojs read:

VariableDescriptionDefault
GIO_APP_DIRPath to the app/ directory; gio.toml is loaded from its parentapp
GIO_HOST / GIO_PORTOverride [server] host / port without editing gio.toml (a second instance, a test server). The host must be an IP address; a malformed value stops startup (see Listen address)[server] values
PORTThe port hosting platforms assign (Heroku, Render, Railway, Fly.io, Cloud Run): overrides [server] port; GIO_PORT wins over itunset
GIO_CACHE_DIRPage cache directory, overriding [cache] disk_path; may be absolute.gio/cache/pages
GIO_ENV_FILES0 skips the .env files, 1 loads them, whatever [env] files says (for platforms that inject the environment and should ignore stray files). Any other value stops startupunset ([env] files decides)
GIO_DEPLOYMENT_IDPin the deployment ID across pods (otherwise derived from the client build the server produced at startup, the app's server-side sources, the gio.toml [images] settings and [css] (minify, enabled, critical_extraction), the served [[fonts]] files, [i18n] (locales and default_locale) and, in a standalone build, its .gio/manifest.json). Persisted pages are dropped when it changes, so change a pinned ID with every deploycontent-derived
GIO_SOCKET_PATH / GIO_WS_SOCKET_PATHRust-to-Node IPC paths, for HTTP and WebSocket traffic; the server passes the resolved values to the Node worker (in a worker pool, the other workers get them with a -w<N> suffix)per-instance .gio/ipc-<pid>-<rand>.sock and .gio/ws-... (Unix), unique named pipes (Windows)
GIO_IMAGE_CACHE_DIRDirectory of the optimized-image disk cache (see [images]).gio/cache/images
GIO_FONTS_DIRDirectory the [[fonts]] files are fetched into and served from.gio/fonts
GIO_STATIC_DIRDirectory of the built client assets (route chunks and stylesheets); a standalone bundle points it at its own static/.gio/build/static
GIO_PUBLIC_DIRDirectory served at the site root and under /public/*public/ next to app/
GIO_REVALIDATE_TOKENBearer token that enables POST /_gio/revalidate (on-demand revalidation); at least 32 bytes, or the server refuses to start. Overrides [revalidate] tokenunset (endpoint disabled)
GIO_SESSION_SECRETKey material for sessions and require_session guards: at least 32 bytes, comma-separated to rotate (the first signs, all verify). Required in production once sessions are usedunset (development: an ephemeral secret per server start)
GIO_SITE_URLAbsolute base URL of the site: resolves relative metadata URLs (Open Graph, canonical) when no metadataBase is set, relative URLs from app/sitemap.ts / app/robots.ts, and the sitemap.xml gio export generatesunset
NODE_ENVdevelopment enables dev mode (file watcher, dev endpoints, error details) and selects the .env.development* files; anything else - unset included - is production and selects .env.production*. The server passes the decided mode to the Node worker it spawnsunset
RUST_LOGRust log filter (info/debug/trace)info
GIO_LOG_FORMATjson or text: the server's log format, overriding [logging] format (see Observability)text
GIO_LOG_LEVELThe worker's minimum log level for the framework's own lines: debug, info, warn or error; your console.log output is never filteredinfo
GIO_EDITOR / VISUAL / EDITORThe editor the dev error overlay's file links open (the first one set wins); development onlycode
GIO_EXIT_ON_STDIN_EOF1: shut down gracefully when stdin reaches end-of-file. Set by launchers that start the server with a piped stdin they hold open (gio, a standalone run.mjs), so a launcher killed outright never leaves the server behind; ignored when stdin is not a pipe (see Deployment)unset
GIO_WORKER_INDEX / GIO_WORKER_COUNTSet by the server in each Node worker, for your code to read: the worker's index in the pool (0 for the first) and the pool size (see Render workers). Any value you set is replacedset per worker
GIO_EXPORTSet to 1 by gio export while it renders, for your code to readset by gio export
GIO_SERVER_BINRead by the gio CLI and createTestServer(), not the server: the giojs-server binary to run instead of the installed platform packageinstalled binary
GIO_OUT_DIRWhere gio export writes the static site./out
GIO_PUBLIC_*Inlined into client bundles at build time (see below); every other variable is server-only-

.env files

The server loads .env files from the project root (the parent of app/) at startup - before gio.toml is read and before the Node worker starts, so both see the values. Files are applied in this order, and the first file to define a variable wins:

OrderFileCommit it?
1.env.{mode}.localno - local overrides, secrets
2.env.localno - local overrides, secrets
3.env.{mode}yes - per-mode defaults
4.envyes - shared defaults

{mode} is development when NODE_ENV=development and production otherwise. Variables already set in the real environment always win over every file, so deploy-time configuration never gets shadowed by a file left on disk. Add .env*.local to .gitignore.

.env
DATABASE_URL="postgres://localhost/dev"
GIO_PUBLIC_API_URL=https://api.example.com

# multiline values, single quotes (no substitution), ${VAR} references
export PRIVATE_KEY="-----BEGIN KEY-----
...
-----END KEY-----"
GREETING='Hello $USER'
API_ENDPOINT=${GIO_PUBLIC_API_URL}/v2
  • The startup log lists the files it loaded - names only, never values.
  • A file that exists but cannot be parsed stops startup with the file name and line number, like an invalid gio.toml.
  • NODE_ENV inside a .env file is ignored (with a warning): the mode is decided before the files are read. Set it in the real environment.
  • A candidate that is not a regular file - such as the .env/ directory python -m venv .env creates - is skipped with a warning.
  • The files load once at startup; restart the server after editing them.
  • gio export and gio build standalone load the same files with the same rules (production mode for standalone builds).
  • [env] files = false in gio.toml loads none of them, and the environment is all there is. GIO_ENV_FILES=0 does the same without editing the file, and GIO_ENV_FILES=1 loads them even when gio.toml says no. The server, gio export, gio build standalone and the testing kit all follow both.

Environment variables in client code

Pages and components also run in the browser, where there is no process.env. Variables prefixed GIO_PUBLIC_ that are set when the client bundles are built are inlined as string literals; every other process.env.X read in client code is undefined. Secrets can only ship to the browser if you name them GIO_PUBLIC_*. In the browser process.env is an object holding exactly those public values (plus NODE_ENV), so destructuring and process.env[name] see the same values the server rendered with.

tsx
export default function Checkout() {
  // Inlined at build time: safe to read anywhere.
  const apiUrl = process.env.GIO_PUBLIC_API_URL;
  const { GIO_PUBLIC_STRIPE_KEY } = process.env; // works too
  // Server-only: undefined in the browser. Read it in getServerSideProps.
  const key = process.env.STRIPE_SECRET_KEY;
  // ...
}
Client bundles are built when the server starts, so a normal deploy picks up new GIO_PUBLIC_* values on restart. A standalone build freezes them at build time (in the client chunks and in worker.js, so server and client render the same value) - changing one needs a rebuild. Server-only variables are always read at runtime.

Keeping server code out of the browser

Each route's client bundle imports only the page's (and its layouts') default export. getServerSideProps and getStaticPaths are tree-shaken away in every export form - declarations, export { loader as getServerSideProps }, export ... from './data', export * from - together with everything only they import: your helper modules, Node builtins, and npm packages such as database clients. Code is never rewritten as text, so strings and comments that look like exports are left alone. Bare side-effect imports (import './polyfill') are kept: a module that client code imports bare - a page, layout or component, anything they import, or an npm package they use - keeps its top-level code in every bundle that reaches it, even one where only server code uses its exports. Only files the browser bundle can reach count: a bare import in a route.ts handler, gio.config.ts, middleware.ts, a test or a script never pulls a module into a page that uses it only in getServerSideProps. The decision is made per module, never per import, so the same code always builds the same bundles.

To turn an accidental client import into a loud error, mark server modules as server-only - either import the guard or name the file *.server.ts (.tsx, .js, .jsx):

lib/db.ts
import '@gio.js/core/server-only';

export const db = createClient(process.env.DATABASE_URL);

Using db from getServerSideProps or a route.ts handler is fine. If a component imports it - or reads a TypeScript enum it declares - that route's client bundle is rejected (it is never written to disk), and the error names the import chain:

text
client bundle for route "/dashboard" imports server-only code:
app/dashboard/page.tsx -> components/Stats.tsx -> lib/db.ts -> @gio.js/core/server-only.
The page still server-renders but will NOT hydrate (no client JS) until this import
is removed from client code.

The error is logged at startup and, in development (NODE_ENV=development), shown in the error overlay when you open the page; in production it stays in the server log. Other routes are unaffected and keep their shared chunks. The bare server-only specifier is recognized too, but prefer @gio.js/core/server-only: the npm server-only package throws when loaded outside React Server Components, which includes GioJS's server render.

Tree-shaking can only drop code it can prove unused. A server export built by calling a function at module scope (export const getServerSideProps = withAuth(async () => ...)) is kept, because the call might have side effects - and with it the code it wraps. Call the wrapper inside a declaration instead (export async function getServerSideProps(ctx) { return withAuth(ctx, load); }), and keep such helpers in server-only modules so any leak fails the build instead of shipping.

Static page caching

Export revalidate from any page module to control caching:

typescript
// Cache for a year (ISR: until a purge or a new deployment)
export const revalidate = false;

// Cache for 60 seconds, then revalidate
export const revalidate = 60;

// Never cache (default when not set)
// (omit the export)
revalidate = false maps to a one-year TTL (31536000 seconds) in the Rust cache layer: in practice the page stays cached until it is purged, evicted or a new deployment changes the cache key.

To refresh a cached page as soon as its data changes, tag it and purge it with revalidateTag() / revalidatePath() or POST /_gio/revalidate - see Caching.