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:
| Section | What 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).
#: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:
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 u16The 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_proxiesentry, ahostthat 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]] paththat cannot be parsed, an[i18n]whosedefault_localeis not one of itslocales, a page cache directory insideapp/orpublic/. - 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]]paththat 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]).
[x-deploy]
region = "eu-west-1"
replicas = 3Retired keys
Keys earlier versions documented but never acted on are refused with what to do instead:
| Key | Instead |
|---|---|
[cache] memory_mb | The 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] engine | Lightning CSS is the only CSS engine. Remove the key. |
[prefetch] strategy | Chosen 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.
npx giojs-server --check-configThe report is one line of JSON; formatted, with one loosened limit:
{
"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:
#: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
#:schemadirective. JetBrains IDEs can mapgio.tomlto 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:
| Setting | Precedence (first set wins) |
|---|---|
| Port | GIO_PORT, then PORT, then [server] port, then 3000 |
| Host | GIO_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.
| Key | Default | Description |
|---|---|---|
[cache] enabled | true | false 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_entries | 1000 | Pages 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_enabled | true | false 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/pages | The 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_bytes | 536870912 (512 MiB) | Size cap of the disk tier; the oldest entries are evicted past it. 0 disables the cap. |
[cache] etag | true | false sends no ETag with pages and never answers 304, for CDNs that mishandle weak validators or apps that set their own. |
[cache] swr_multiplier | 10 | A 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] enabled | true | Compress 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_bytes | 1024 | Responses 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_brotli | true | true: Brotli for clients that accept it, gzip otherwise. false: gzip only. |
[prefetch] enabled | true | false answers every prefetch request 429 before it renders, which turns prefetching off site-wide. |
[prefetch] max_concurrent | 5 | Prefetch 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_second | 20 | Prefetch 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]):
[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.jsonnames 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, anddata/**coversdata/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_pathputs them, so a visible cache directory needs no pattern. Other files in that directory still count. - Editing
gio.tomlrestarts the worker like any other source file, but the new settings do not apply: the Rust server readsgio.tomlonce, at startup. Stop and startgio devafter changing it..envfiles are read once too, and editing them restarts nothing: restartgio 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:
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.
| Field | Default | Description |
|---|---|---|
max_connections | 10000 | Concurrent 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_secs | 10 | Deadline for completing the TLS handshake when [server.tls] is enabled. |
header_read_timeout_secs | 10 | Deadline 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_secs | 30 | Deadline for receiving a whole request body. A client that sends the body too slowly gets 408 Request Timeout. |
render_timeout_secs | 30 | Deadline 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_secs | 60 | Connections 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_streams | 250 | Concurrent streams (requests) per HTTP/2 connection. |
http2_keep_alive_interval_secshttp2_keep_alive_timeout_secs | 20 / 20 | The 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.
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:
[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/buildand 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.tsare taken from the first ready worker.revalidatePath/revalidateTagpurge 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 (
pluginsingio.config.ts) run per worker too:onStartupruns in every worker, and again when a worker is respawned, andonShutdownin 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 getsGIO_WORKER_INDEX("0"for the first) andGIO_WORKER_COUNT, soprocess.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:
[server]
trusted_proxies = ["127.0.0.1", "::1", "10.0.0.0/8"] # IPs and CIDR blocks, IPv4 and IPv6The 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- allowlisting127.0.0.1no longer admits everything a local proxy forwards. req.ipin route handlers andctx.ipin getServerSideProps, plusschemeandhost.
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:
[server]
accept_request_id = false # ignore incoming X-Request-Id, even from trusted proxiesHealth & 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]:
| Endpoint | Default | Description |
|---|---|---|
/_gio/health | on | Liveness 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/metrics | off | Prometheus exposition (request counts and latency histograms labeled by route pattern, cache tiers, IPC timing - see Observability). Returns 404 until enabled via [metrics]. |
/_gio/revalidate | off | POST 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:
[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.
# Scrape with a token:
curl -H "Authorization: Bearer a-long-random-secret" \
http://localhost:3000/_gio/metricsA malformed ip_allowlist entry ("10.0.0.0/33") stops startup, like a malformed trusted_proxies one, instead of quietly matching nobody.
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
Hostislocalhost,*.localhost, a loopback IP (127.0.0.1,[::1]), the[server] hostwhen it names a specific address, or an entry in[dev] allowed_hosts. Anything else gets a403- 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: localhostitself, so its requests must name the[server] hostor anallowed_hostsentry. - The state, stream and codeframe reads refuse requests a browser marks
Sec-Fetch-Site: cross-site, and requests whoseOriginis neither the requested host nor a host inallowed_hosts(the latter covers tunnels and port forwarders that rewriteHostto localhost). - open-in-editor accepts
POSTonly, also refusesSec-Fetch-Site: same-site(only same-origin calls pass), and applies the sameOriginrule, 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:
[dev]
allowed_hosts = ["192.168.1.20", "myvm.local", "*.tunnel.example"] # "*." or "." = any subdomainEntries 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.
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 feature | Key | Default | Off value | What you lose | Warns |
|---|---|---|---|---|---|
| CSRF protection | [security.csrf] enabled | true | false | Other websites can send form posts and other unsafe requests with the cookies of your visitors. | yes |
| WebSocket origin check | [security.websocket] check_origin | true | false | Other websites can open WebSockets with the cookies of your visitors. | yes |
| Default security headers | [security] default_headers | true | false | MIME sniffing, framing by other sites and full-URL referrers come back. | yes |
| HSTS while TLS is on | [security] hsts | unset | false | Browsers may connect over plain HTTP. | no |
| Request body limit | [server] max_body_bytes | 2097152 | 0 | Each body is buffered up to the 64 MiB worker message cap. | yes |
| Connection cap | [server] max_connections | 10000 | 0 | A connection flood can exhaust file descriptors and memory. | yes |
| Connection timeouts | [server] *_timeout_secs | 10 to 60 | 0 | Slow or idle clients can hold connections open. | no |
| Render timeout | [server] render_timeout_secs | 30 | 0 | A render that never answers holds its connection and a worker. | yes |
| HTTP/2 | [server] http2 | true | false | Clients speak HTTP/1.1 only (with TLS, ALPN offers only http/1.1). | no |
| Skew protection | [server] skew_protection | true | false | Old 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_buckets | 100000 | 0 | Clients rotating addresses grow memory without bound. | yes |
| Per-client key cap | [[rate_limits]] max_keys_per_client | 64 | 0 | One client can mint a fresh budget per key_header value. | yes |
| Page cache | [cache] enabled | true | false | Every request renders. | no |
| Disk cache tier | [cache] disk_enabled | true | false | The cache is memory only and starts empty after a restart. | no |
| Page ETags | [cache] etag | true | false | No 304 responses for pages. | no |
| Stale-while-revalidate | [cache] swr_multiplier | 10 | 0 | A stale page renders before it is served. | no |
| Compression | [compression] enabled | true | false | Bigger responses. | no |
| Prefetching | [prefetch] enabled | true | false | Links load on click, not ahead of it. | no |
| Prefetch budgets | [prefetch] max_concurrent, max_per_second | 5, 20 | 0 | The prefetches of one client are unbounded. | no |
| Image optimizer | [images] enabled | true | false | Images are served at full size without a srcset. | no |
| Image limits | [images] max_remote_bytes, remote_timeout_secs, max_source_dimension, max_decode_bytes | 20 MiB, 30, 10000, 256 MiB | 0 | One image can cost unbounded memory, CPU or time. | yes |
| Path-served CSS | [css] enabled | true | false | app/*.css is not served by path, and no critical CSS. | no |
| CSS minification | [css] minify | true | false | Bigger production stylesheets. | no |
| Critical CSS | [css] critical_extraction | true | false | No inlined first-paint CSS. | no |
| Font preload | [[fonts]] preload | true | false | The font loads when text needs it. | no |
| WebSockets | [websocket] enabled | true | false | No WebSocket connections: upgrades get 501. | no |
| WebSocket cap | [websocket] max_connections | 1000 | 0 | Open sockets are unbounded. | yes |
| WebSocket pings | [websocket] ping_interval_secs | 30 | 0 | Vanished 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] enabled | true | false | /_gio/health answers 404. | no |
| Health details | [health] details | true | false | No deployment id or worker topology in the answer. | no |
| .env files | [env] files | true | false | Only the process environment counts. | no |
| Dev host check | [dev] allowed_hosts | [] | ["*"] | DNS rebinding can read the dev endpoints and error details. | yes |
| Devtools | [dev] devtools | true | false | No dashboard, codeframes, editor links or live reload. | no |
| Dev watcher | [dev] watch | true | false | No 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/_gionamespace ([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 theGIO_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//loginand/api/%6Coginall draw from the/api/loginbucket, just as they all reach the same handler. /api/*restalso covers/apiitself (/api/is the same path). An exact/apirule 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_bucketsbuckets (100,000) - past that the least recently seen are evicted.0lifts the cap. key_headerbudgets are capped per client. One client gets its own bucket for at mostmax_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 to0(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:
| Variable | Description | Default |
|---|---|---|
GIO_APP_DIR | Path to the app/ directory; gio.toml is loaded from its parent | app |
GIO_HOST / GIO_PORT | Override [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 |
PORT | The port hosting platforms assign (Heroku, Render, Railway, Fly.io, Cloud Run): overrides [server] port; GIO_PORT wins over it | unset |
GIO_CACHE_DIR | Page cache directory, overriding [cache] disk_path; may be absolute | .gio/cache/pages |
GIO_ENV_FILES | 0 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 startup | unset ([env] files decides) |
GIO_DEPLOYMENT_ID | Pin 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 deploy | content-derived |
GIO_SOCKET_PATH / GIO_WS_SOCKET_PATH | Rust-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_DIR | Directory of the optimized-image disk cache (see [images]) | .gio/cache/images |
GIO_FONTS_DIR | Directory the [[fonts]] files are fetched into and served from | .gio/fonts |
GIO_STATIC_DIR | Directory of the built client assets (route chunks and stylesheets); a standalone bundle points it at its own static/ | .gio/build/static |
GIO_PUBLIC_DIR | Directory served at the site root and under /public/* | public/ next to app/ |
GIO_REVALIDATE_TOKEN | Bearer token that enables POST /_gio/revalidate (on-demand revalidation); at least 32 bytes, or the server refuses to start. Overrides [revalidate] token | unset (endpoint disabled) |
GIO_SESSION_SECRET | Key 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 used | unset (development: an ephemeral secret per server start) |
GIO_SITE_URL | Absolute 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 generates | unset |
NODE_ENV | development 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 spawns | unset |
RUST_LOG | Rust log filter (info/debug/trace) | info |
GIO_LOG_FORMAT | json or text: the server's log format, overriding [logging] format (see Observability) | text |
GIO_LOG_LEVEL | The worker's minimum log level for the framework's own lines: debug, info, warn or error; your console.log output is never filtered | info |
GIO_EDITOR / VISUAL / EDITOR | The editor the dev error overlay's file links open (the first one set wins); development only | code |
GIO_EXIT_ON_STDIN_EOF | 1: 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_COUNT | Set 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 replaced | set per worker |
GIO_EXPORT | Set to 1 by gio export while it renders, for your code to read | set by gio export |
GIO_SERVER_BIN | Read by the gio CLI and createTestServer(), not the server: the giojs-server binary to run instead of the installed platform package | installed binary |
GIO_OUT_DIR | Where 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:
| Order | File | Commit it? |
|---|---|---|
| 1 | .env.{mode}.local | no - local overrides, secrets |
| 2 | .env.local | no - local overrides, secrets |
| 3 | .env.{mode} | yes - per-mode defaults |
| 4 | .env | yes - 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.
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_ENVinside a.envfile 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/directorypython -m venv .envcreates - is skipped with a warning. - The files load once at startup; restart the server after editing them.
gio exportandgio build standaloneload the same files with the same rules (productionmode for standalone builds).[env] files = falseingio.tomlloads none of them, and the environment is all there is.GIO_ENV_FILES=0does the same without editing the file, andGIO_ENV_FILES=1loads them even whengio.tomlsays no. The server,gio export,gio build standaloneand 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.
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;
// ...
}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):
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:
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.
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:
// 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.