Skip to content

Configuration reference

A service is configured by one file. This page lists everything it accepts.

You write it as gpilot.toml in the directory you deploy from. gpilot deploy picks it up automatically, along with schema.graphql beside it.

TOML is canonical, but the CLI also accepts YAML, JSON and JSON5, chosen by extension (gpilot.yaml, gpilot.yml, gpilot.json, gpilot.json5, gpilot.tml) and converts them to TOML on the way up. gpilot config convert does the same conversion locally, and gpilot config get prints what the service is currently deployed from.

Every setting has a default, and an empty file is valid. Configure what you need to change. A deployment without a configuration file runs on the defaults alone; see what goes into a deployment.

What the edge accepts, and how much GraphQL it is willing to parse. Not every limit here runs on every request:

  • On every request, a cache hit included: graphql_paths, which decides whether a request is GraphQL at all and refuses a GraphQL operation sent anywhere else, and max_body_bytes. Authentication and the client profile checks also run before the cache.
  • Only on requests that reach GraphQL processing, which means a cache miss or a pass: max_query_depth, max_document_bytes, max_selections, introspection and introspection_cache_max_age. A hit is answered without parsing the document, so these limits cost it nothing. They protect your origin, which a hit does not reach.
KeyTypeDefaultDoes
request.graphql_pathslist of strings["/graphql"], or [] without a schemaThe paths treated as GraphQL. Several paths are allowed (for example the old and the new path during a migration); all of them serve the service’s one schema, so a second API with a schema of its own is a second service. Matched the way your origin’s router would: case does not matter, repeated slashes, a trailing slash, . and .. segments, percent-escapes (except %2F) and ;parameters are ignored, and a path below an entry counts as the entry (/graphql/x is /graphql, /graphql-admin is not), because that is how Express mounts Apollo Server and Yoga. An entry of / matches the root only. Anything else is forwarded as ordinary HTTP, except a request that carries a GraphQL operation there: that is refused with 404 and NOT_A_GRAPHQL_PATH while any GraphQL check is in force (the introspection block, persisted_query.only_registered, a client profile with persisted_query = "required", or a depth, document size or selection limit, on the service or a profile), which by default it is. See GraphQL on other paths. Serve GraphQL only on these paths all the same: an upload or a WebSocket subscription sent elsewhere is not recognized. An empty list is taken at its word: GraphQL nowhere, and nothing is refused for being GraphQL. A deployment without a schema always runs with []: setting the key there (any value, [] included) does not fail the deployment, the key is ignored, and the build records a request_graphql_paths error saying so.
request.max_body_bytesinteger (bytes)8388608 (8 MiB)Largest request body accepted, inclusive. A larger one is refused with 413 and PAYLOAD_TOO_LARGE before it is parsed and before it reaches your origin. Raise or lower it with any integer. The limit cannot be removed: TOML has no null, and gpilot refuses a null in a YAML or JSON file. 0 is not “no limit” here: it refuses every request that carries a body.
request.forward_unchecked_bodieslist of "batch", "multipart", "other"[]The kinds of body on a GraphQL path that are forwarded to your origin unchecked instead of refused with 415 and UNSUPPORTED_GRAPHQL_BODY. batch is a JSON array of operations, multipart a multipart/form-data upload, other anything else (text, no content type, JSON that names no operation, a body that does not parse). A listed kind reaches your origin as the bytes the client sent, past persisted query enforcement, the introspection block and the document limits. A request without a body is never refused here. A compressed body (any Content-Encoding other than identity) is refused with 400 and INVALID_GRAPHQL_REQUEST, not forwarded, whichever kinds are listed. See bodies on a GraphQL path.
request.max_query_depth.client_documentinteger10Deepest nesting allowed in an operation a client sent. 0 disables the limit.
request.max_query_depth.registered_documentinteger10The same for a document resolved from the persisted query registry. 0 disables it, which is only safe with persisted_query.preset = "trusted": under apq any client can register a document and then send its hash.
request.max_document_bytesinteger65536 (64 KiB)Longest GraphQL document parsed, in bytes of the query string, inclusive. A longer one is refused with 400 and DOCUMENT_SIZE_LIMIT_EXCEEDED before it is parsed. Governs a document the request carried; a registered one is measured against persisted_query.max_document_bytes, which inherits this value unless you set it. An APQ registration over the registry’s ceiling is not stored. 0 disables the limit.
request.max_selectionsinteger4000Most selections an operation may make, inclusive, counted with every alias and every fragment spread expanded. More is refused with 400 and DOCUMENT_SELECTION_LIMIT_EXCEEDED before the query plan is built. Governs a document the request carried; a registered one is measured against persisted_query.max_selections, which inherits this value unless you set it. 0 disables the limit. See how selections are counted.
request.introspection"blocked" | "allowed""blocked"Whether the edge answers __schema and __type introspection. Blocked by default so a public service does not hand out its schema shape. __typename is never blocked.
request.introspection_cache_max_ageinteger (seconds)unsetAccepted and has no effect. It used to give the response to an introspection request GraphPilot itself sends a cache lifetime; such a platform probe is now never cached, so it always reads what your origin answers. A configuration that sets it still deploys.
[request]
graphql_paths = ["/graphql", "/api/graphql"]
introspection = "allowed"
forward_unchecked_bodies = ["multipart"] # this API takes file uploads
[request.max_query_depth]
client_document = 8

See limits for what each limit protects against.

How the request is sent to your API. The origin URL is not here: it belongs to the service and is changed with gpilot service edit.

KeyTypeDefaultDoes
origin.field_prefixstring"__gp_"The prefix on fields selection set expansion injects, so they can be recognized and stripped again. A client document whose alias (or unaliased field name) starts with it is refused with 400 RESERVED_FIELD_PREFIX. Change it only if it collides with your own field names: a schema field starting with the prefix can no longer be selected without an alias. An empty or blank value means the default __gp_.
origin.method_rewrite"verbatim" | "query_to_post" | "post_to_query""verbatim"Rewrites the HTTP method on the way to your origin. See below.
origin.connect_timeout_msinteger or unset1000How long to wait for TCP and TLS. Capped at 5000 on every plan.
origin.first_byte_timeout_msinteger or unset15000, or your plan’s cap if lowerHow long to wait for the first response byte. Plan-dependent; see origin timeouts.
origin.between_bytes_timeout_msinteger or unset10000How long to wait between two bytes of a response already in flight. Capped at 60000.
origin.request_id_headerstring or unsetunsetA header name your origin receives the GraphPilot request id under, so your own logs can be joined to GraphPilot’s. You choose the name, typically one your origin already logs, such as x-request-id. A name in the gp- namespace refuses the deployment, because that namespace never reaches your origin; so does a name that is not a valid HTTP header name. Do not reuse the name of your telemetry.correlation_header: GraphPilot’s id replaces the client’s value under that name. See request ids.
origin.forward_client_ipbooleanfalseWhether your origin receives the client’s IP address, in the gp-client-ip request header. Off by default: an end user’s IP address is personal data, so passing it on is your decision. On, the value is the address the edge saw for the connection (on a shielded request, the one the first edge location saw, never a GraphPilot address), and no header is sent when the address is unknown. A client cannot set it: every gp- header a client sends is dropped on arrival. Independent of telemetry.client_ip, which decides what GraphPilot stores, not what your origin receives. See request headers your origin receives.
origin.forwarded_headers"strip" | "pass""strip"What your origin receives of the forwarding headers a client sent: every header whose name starts with X-Forwarded-, plus Forwarded, X-Real-IP, X-Client-IP, Client-IP, True-Client-IP, CF-Connecting-IP, Fastly-Client-IP, X-Original-URL, X-Rewrite-URL and X-Original-Host. "strip" removes them right before the request is sent to your origin; GraphPilot’s own policies and vary rules still read them, and so does the capture of the client’s request headers in telemetry. "pass" forwards them unchanged, and then trusting them and keying the cache on them is up to you. The client’s address your origin can trust is gp-client-ip (origin.forward_client_ip). An [origin.headers] only list that keeps one of these headers, or the pattern x-forwarded-*, while they are stripped refuses the deployment. See forwarding headers.
origin.shieldstring or unsetunsetThe shield location that cache misses go through before they reach your origin, named <region>-<city> (see the location table). Off by default, and available on paid plans. On Glider the deployment still succeeds, with a plan warning in its findings: the shield is configured but inactive, and it turns on by itself after an upgrade, without a redeploy. A value that is not a location the network knows refuses the deployment and suggests the closest match.

A timeout above your plan’s ceiling or below 100 ms, or a retry policy whose worst case does not fit 120 seconds, refuses the deployment. See a value out of bounds is refused and timeouts and retries.

The QUERY method (draft-ietf-httpbis-safe-method-w-body) is a safe, idempotent method that carries a body: a read with a body, unlike GET. GraphPilot accepts it from clients and caches it exactly like the same operation over POST. method_rewrite lets the two sides adopt it independently:

ValueEffect
verbatimForward the method unchanged.
query_to_postDowngrade QUERY to POST, so clients can adopt QUERY while your origin stays on POST.
post_to_queryUpgrade POST to QUERY, so your origin can adopt QUERY while clients keep sending POST. Only query operations are upgraded; mutations and subscriptions stay POST, because QUERY is a safe method and must not carry them.

Off unless you opt in. Full detail on origin retries.

KeyTypeDefaultDoes
origin.retry.enabledbooleanfalseWhether failed origin requests are retried at all.
origin.retry.max_retriesinteger1Additional attempts. 1 means the origin sees the request at most twice.
origin.retry.status_codeslist of integers[502, 503, 504]Origin statuses that trigger a retry.
origin.retry.on_network_errorbooleantrueWhether a transport failure is retried.
origin.retry.retry_mutationsbooleanfalseWhether GraphQL mutations are retried. Turn on only if your origin never returns a gateway status after a write already executed, or your mutations are idempotent. Does not apply to plain HTTP requests.
origin.retry.retry_unsafe_methodsbooleanfalseWhether plain HTTP requests with a method other than GET, HEAD or OPTIONS (POST, PUT, PATCH, DELETE and any other) are retried. Turn on only for an origin that makes its writes idempotent. Does not apply to GraphQL requests.
origin.retry.backoff"none" | "fixed" | "exponential""fixed"How the delay is computed.
origin.retry.delay_msinteger1000Base delay.
origin.retry.max_delay_msinteger2000Ceiling for any single delay, for both fixed and exponential.

A plain HTTP request is retried only when its method is GET, HEAD or OPTIONS; any other method is sent once unless retry_unsafe_methods is true. See which requests are repeated.

Names dropped from the request on its way to your origin, after the cache decision has already been made from the request as the client sent it. Full detail on header and cookie filters.

KeyTypeDefaultDoes
origin.headers.hidelist of stringsunsetHeader names removed before the request reaches your origin.
origin.headers.onlylist of stringsunsetHeader names that survive; everything else is removed. Mutually exclusive with hide.
origin.cookies.hidelist of stringsunsetCookie names removed from the Cookie header.
origin.cookies.onlylist of stringsunsetCookie names that survive in the Cookie header; everything else is removed. Mutually exclusive with hide.
[origin.headers]
hide = ["x-internal-debug"]
[origin.cookies]
hide = ["internal_ab_test"]

Setting both hide and only on the same section, or naming a protected name (host, content-type, content-encoding, content-length, transfer-encoding, or anything starting with gp-), refuses the deployment. See protected names. So does a conditional rules list or when, which only [response.headers] and [response.cookies] accept. A rules list is refused with the reason, and the message closes with [origin.headers] supports only the plain `hide` and `only` lists (or [origin.cookies]); a bare when is refused as an unknown key.

KeyTypeDefaultDoes
cache.ignore_origin_header_without_freshnessbooleantrueLets an origin Cache-Control that constrains neither storage nor freshness (a bare private, public or no-transform) yield to the schema’s @cacheControl policy instead of deciding the response itself. A private it stated is still carried over, so the entry stays partitioned. Set false to restore the older precedence, where the header’s mere presence decided. See precedence.

What the Cache-Control directives a client sends on its own request do. Each value is "honor" or "ignore"; any other value, or an unknown key in this section, is refused when the file is read. Full detail on the client’s own Cache-Control.

KeyTypeDefaultDoes
cache.request_directives.no_store"honor" | "ignore""honor"Honored: a request carrying no-store is still answered from the cache when it can be, but its response is not written, reported as CACHE_SKIPPED_REQUEST_NO_STORE.
cache.request_directives.no_cache"honor" | "ignore""ignore"Honored: a request carrying no-cache or max-age=0 skips the lookup, goes to your origin and is not written back, reported as PASS with CACHE_PASS_REQUEST_NO_CACHE. Ignored by default because any caller could then send every request past the cache to your origin.
[cache.request_directives]
no_cache = "honor" # only for callers you trust

What the cache key of a plain HTTP request is made of: any request that is not a GraphQL operation. By default it is the method, the path and the query string, plus a SHA-256 of the body when there is one, so GET /products/42?color=blue is stored under GET:/products/42?color=blue whichever hostname it arrived on. The same key is the ck: key you purge one response by, and the cache.key in request telemetry. GraphQL keys are not affected. An unknown value, or an unknown key in this section, is refused when the file is read. See the cache key.

KeyTypeDefaultDoes
cache.http_key.host"exclude" | "include""exclude"Whether the scheme and hostname are part of the key. Excluded, one path is one entry across the service hostname and every custom domain, and you purge it as ck:GET:/products/42. Included, the key is the full URL, GET:https://catalog-api.k7m2qp.graphpilot.cloud/products/42, and each hostname keeps its own entry. Include it only when your origin answers the same path differently per hostname.
[cache.http_key]
host = "include" # only when your origin serves different content per hostname

Changing the setting changes every plain HTTP key, so the entries stored under the old ones are no longer found: each misses once, is filled again under the new key, and the old entry expires on its own lifetime.

How the cache is partitioned. Every value except downstream is a policy expression; the concepts are on identifying callers.

KeyTypeDefaultDoes
cache.policy.publicexpressionunsetThe value a shared entry is partitioned by, for example one of your customers.
cache.policy.privateexpressionunsetThe value a private entry is partitioned by. Without it, a private response is not cached. Write it so it identifies one caller, not their group.
cache.policy.bypassexpressionunsetWhen it matches, the request skips the cache entirely and its response is not stored. Evaluated as a truth, not as a value.
cache.policy.downstream"off" | "if_cacheable" | "always""off"Whether to emit a Cache-Control header on the response sent to the client. See downstream header.
cache.policy.store_error_statuseslist of integers[]The 4xx and 5xx statuses that may be stored, under the ordinary rules, when your origin marks them cacheable. Any other error status is not stored, whatever its Cache-Control says, and reports PASS with CACHE_SKIPPED_ERROR_STATUS. A value outside 400 to 599 refuses the deployment. See an error status is not stored unless you opt in.
cache.policy.uncacheable_methodslist of strings["OPTIONS"]Request methods that skip the cache entirely: no lookup, no key, no write back. Reported as CACHE_PASS_UNCACHEABLE_METHOD. Empty the list to let CORS preflights be answered at the edge; read the hazard first, since a preflight is only safe to cache because GraphPilot keys it on the caller’s Origin and on what it asked for. Names are capitals, and one that is not a valid HTTP method is refused when the file is read.

Repeatable. Each entry derives a value and writes it onto the request as a header. Every response is partitioned on it, plain HTTP included, whether or not your origin’s Vary names it. An entry marked scope_only partitions a response your schema decided only when its scope names it in @vary; a response whose lifetime your origin’s Cache-Control decided is still partitioned on it. A request without a credential that the expression does not resolve for is stored as the variant without the header. A request with a credential that it does not resolve for, or resolves empty for, is not stored (CACHE_SKIPPED_PRIVATE_WITHOUT_DISCRIMINATOR). Each entry costs hit rate, so configure only those whose value shapes a response. See vary rules.

KeyTypeDefaultDoes
namestringrequiredThe header the derived value is written to. Configuring an entry claims that name: a client sending it has its value removed, logged as INGRESS_CLAIMED_HEADER_DROPPED. Should not start with gp-: such a name is not refused and still partitions the cache, but the namespace is stripped before your origin, so pass_to_origin has no effect. A name that is not a legal header name is skipped and reported in the deployment’s diagnostics.
valueexpressionrequiredComputes the value.
pass_to_origin"never" | "anonymized" | "plain""never"What your origin receives. anonymized sends a keyed digest, so the origin can tell two contexts apart without learning what distinguishes them. Under anonymized, request telemetry records that same digest as the segment value. Under never and plain, telemetry.vary_values decides: a keyed pseudonym by default, the value in clear only if you choose it. See what Insights records.
scope_onlybooleanfalseLeaves the entry to your schema’s named scopes: a response your schema decided is partitioned on it only when its scope names it. Only accepted with pass_to_origin = "never"; any other combination refuses the deployment. See vary rules.
[cache.policy]
private = "auth:idp.sub"
downstream = "if_cacheable"
[[cache.policy.vary]]
name = "x-segment"
value = "auth:idp.plan"
pass_to_origin = "anonymized"

What the cache does with response headers and cookies when it writes an entry. Full detail on header and cookie filters.

KeyTypeDefaultDoes
cache.headers.excludelist of stringsunsetResponse headers not written to the entry. The client that caused the miss still receives them; a later hit does not. Refused at deploy time if it names a protected header (content-type, content-encoding, cache-control, vary, surrogate-key).
cache.headers.allowlist of stringsunsetHeaders that no longer refuse storing the response, out of the fixed list GraphPilot otherwise refuses on (WWW-Authenticate, Proxy-Authenticate, Authentication-Info, Proxy-Authentication-Info, Clear-Site-Data, Set-Login). An allowed header is also never written to the entry. Refused at deploy time if it names anything other than one of those six, and refused too for Set-Cookie or Set-Cookie2, which [cache.cookies].allow governs instead; use cache.headers.exclude to drop an ordinary header from the cache entry.
cache.cookies.allowlist of stringssee the default listCookie names the service says carry no session: they do not make a request credentialed, do not refuse a response for carrying Set-Cookie, and are never written to the entry. Setting this key replaces the default list rather than adding to it.
[cache.headers]
exclude = ["x-request-id"]
[cache.cookies]
allow = ["GAESA", "GCLB", "my_custom_lb_cookie"]

Neither section accepts a conditional rules list or when. Writing one refuses the deployment, because what is stored under a cache key cannot depend on a condition the key does not include. The message says so and names the plain lists the section does read, closing with [cache.headers] supports only the plain `exclude` and `allow` lists, or [cache.cookies] supports only the plain `allow` list.

Whether a client sees the headers GraphPilot sets to report what the cache did. Changing one changes only what leaves: caching, purging and telemetry are unaffected. Every header is listed on headers; full detail on switching the headers on and off.

KeyTypeDefaultDoes
response.cache_headersbooleantrueSends the cache status family: gp-cache, gp-cache-reason, gp-cache-hits, gp-cache-age, gp-cache-max-age and gp-cache-remaining. Set false to withhold all six.
response.surrogate_key_headerbooleanfalseSends gp-surrogate-key, the surrogate keys the response is tagged with, plus gp-surrogate-key-truncated when keys were left out. The keys are stored and purging by key works either way.
response.surrogate_key_header_max_bytesinteger (bytes)8192The most the value of gp-surrogate-key may carry, whole keys only, cache and root field keys first. Applies to that one header, not to all headers together. Keys that do not fit are counted in gp-surrogate-key-truncated; if none fits, gp-surrogate-key is left off. From 1 to 128000 (Fastly’s per-header limit); anything else refuses the deployment. Only read when surrogate_key_header is true. See size limit.
response.request_id_headerbooleanfalseSends gp-request-id, the id GraphPilot recorded this request under, shown as the Request ID column in Insights and as Entry ID in the request’s Overview tab. The id is minted and the telemetry row carries it either way; this decides only whether the caller sees it. See request ids.
[response]
cache_headers = false
surrogate_key_header = true
surrogate_key_header_max_bytes = 32000
request_id_header = true

cache_headers and surrogate_key_header are the only way to withhold a gp- header: [response.headers] refuses the namespace at deploy time.

CORS headers on the responses GraphPilot produces itself: its rejections and its persisted query answers, which never reach your origin and so carry none of the CORS headers your origin would send. Without this section, nothing changes. Full detail on edge rejections.

KeyTypeDefaultDoes
response.cors.allow_originslist of strings[]The origins whose requests get CORS headers on a response GraphPilot produced, written the way a browser sends Origin: scheme://host, with a port where it is not the scheme’s default, over http or https, with no path and no trailing slash. Matched case-insensitively. "*" on its own allows every origin.
response.cors.allow_credentialsbooleanfalseAdds Access-Control-Allow-Credentials: true, for a browser app that sends cookies or uses credentials: "include".

"*" combined with another origin or with allow_credentials = true, a partial wildcard such as https://*.example.com, an origin with a path, and null refuse the deployment. Responses from your origin, cache hits included, are never changed.

Names dropped from the response on its way to your client, applied on delivery rather than when an entry is written, so it also governs a cache hit served from an entry stored before this configuration existed. Full detail on header and cookie filters.

KeyTypeDefaultDoes
response.headers.hidelist of stringsunsetHeader names removed from the response before it reaches your client.
response.headers.onlylist of stringsunsetHeader names that survive; everything else is removed. Mutually exclusive with hide.
response.cookies.hidelist of stringsunsetCookie names removed from Set-Cookie and Set-Cookie2.
response.cookies.onlylist of stringsunsetCookie names that survive in Set-Cookie and Set-Cookie2; everything else is removed. Mutually exclusive with hide.
response.headers.rulesarray of tablesunsetLists that apply only to the requests their when matches. See below.
response.cookies.rulesarray of tablesunsetThe same for cookies.
[response.headers]
hide = ["x-origin-latency-ms"]
[response.cookies]
hide = ["__internal_session"]

[[response.headers.rules]] and [[response.cookies.rules]]

Section titled “[[response.headers.rules]] and [[response.cookies.rules]]”

Repeatable. Applied after the plain list and after one another, so a name survives only if every list that applies keeps it. Full detail on conditional rules.

KeyTypeDefaultDoes
hidelist of stringsunsetNames this rule drops.
onlylist of stringsunsetNames this rule keeps; everything else is dropped. A rule must set exactly one of hide and only.
whenexpressionunsetWhether the rule applies to this request. Evaluated as a truth, with auth: claims available. Absent means every request.
[[response.headers.rules]]
hide = ["x-debug-trace"]
when = "!auth:idp.role || auth:idp.role != 'admin'"

rules and when are accepted only here. [origin.*] and [cache.*] refuse them at deploy time, because their filtering reaches the cache under a key that does not include the condition.

Setting both hide and only on the same section, or naming a protected name (content-type, content-encoding, cache-control, vary, surrogate-key, or anything starting with gp-), refuses the deployment. See protected names.

Repeatable, one block per provider. The block name is what a policy expression refers to (auth:<name>.<claim>). Full detail on authentication.

JWT providers (type = "jwt"):

KeyTypeDefaultDoes
type"jwt"required
allowed_algorithmslist of stringsrequiredThe algorithms this provider accepts. A security control, not a formality: it is what stops an algorithm downgrade. Accepted spellings: HS256, HS384, HS512, RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES256K, EdDSA and BLAKE2B.
jwks_urlstringunsetWhere the issuer publishes its keys. The key set is read at deploy time and delivered to the edge. When a token names a key id the delivered set lacks, the edge location fetches jwks_url (at most once a minute, or 10 seconds after a failed fetch) and shares the result, so a rotation is usually picked up without a redeploy. Only if that fetch fails is the token rejected with AUTH_UNKNOWN_KEY_ID. See authentication.
public_keystring (PEM)unsetPublic key for an asymmetric algorithm without a JWKS.
default_key_idstringunsetKey to use when a token carries no kid.
secretstringunsetShared secret, for symmetric algorithms. Stored and deployed to the edge as written, as part of the configuration document: anyone with service:edit, a member or an API key, can read it back with gpilot config get. Keep a gpilot.toml that holds a secret out of version control, or use an asymmetric algorithm with jwks_url or public_key, which hold only public material.
headerstring"Authorization"Header carrying the token. A Bearer prefix is stripped when present.
allowed_issuerslist of stringsrequired with jwks_url, public_key or an asymmetric algorithm, otherwise unsetAccepted iss values. A provider that verifies with a public key (it sets jwks_url or public_key, or lists an RS*, PS*, ES*, ES256K or EdDSA algorithm) is refused without it, or with an empty list, and the deployment fails. On a shared-secret provider, unset accepts any issuer and the deployment warns (auth_provider). Set it.
allowed_audienceslist of stringsunsetAccepted aud values. Unset accepts any audience, and the deployment warns (auth_provider). Set it.
required_noncestringunsetRequired nonce.
required_key_idstringunsetRequired kid.
time_tolerancedurationunsetClock skew allowance. See the note below on how to spell a duration.
max_validitydurationunsetReject tokens issued longer ago than this, whatever their expiry.
max_token_lengthintegerunset
max_header_lengthintegerunset
accept_futurebooleanfalseWhether a token whose nbf is in the future is accepted.
allow_missing_expbooleanfalseWhether a token without an exp claim is accepted. By default it is rejected with AUTH_INVALID_TOKEN. Turn it on only for an issuer that mints tokens without exp, and set max_validity with it. A token that states an expiry is held to it either way.

API key providers (type = "api_key"):

KeyTypeDefaultDoes
type"api_key"required
prefixeslist of stringsrequired, non-emptyThe prefixes marking a value as this provider’s credential. An empty list, or one containing an empty string, is refused.
headerstring"Authorization"Header carrying the key.

What happens to a credential that cannot be used, per reason. Letting a request continue is not the same as trusting it: the provider contributes no claims, so a private policy naming it yields no discriminator and the response is not cached.

KeyTypeDefaultDoes
auth.rejection.invalid_token"reject" | "continue""reject"The signature did not verify, the token expired, or a required claim did not match.
auth.rejection.unknown_key_id"reject" | "continue""reject"The token names a key id GraphPilot does not have.
auth.rejection.key_material_unavailable"reject" | "continue""reject"No key material at all for this provider.
[auth.providers.idp]
type = "jwt"
allowed_algorithms = ["RS256"]
jwks_url = "https://idp.example.com/.well-known/jwks.json"
allowed_issuers = ["https://idp.example.com/"]
[auth.rejection]
invalid_token = "reject"
unknown_key_id = "continue"
key_material_unavailable = "continue"

Four independent switches, plus a preset that names the common combinations. See choosing a setup for how to choose.

KeyTypeDefaultDoes
persisted_query.preset"off" | "apq" | "trusted_migrating" | "trusted""off"Sets the four switches below to a named combination (see the table after this one). A switch you write beside it overrides the preset’s value for that switch. mode is accepted as an alias of preset.
persisted_query.enabledbooleanfalseWhether a hash is resolved against the registry at all. Off, a request carrying a hash is answered with PERSISTED_QUERY_NOT_SUPPORTED.
persisted_query.apqbooleanfalseWhether a client may register an operation by sending it with its hash (Automatic Persisted Queries). Anyone who can call the service can then write to its registry. Off, a push is the only way in.
persisted_query.only_registeredbooleanfalseMakes the registry an allowlist: an operation it does not hold is refused with 403 and FORBIDDEN, including a full query sent as text, and logged as PERSISTED_QUERY_REFUSED. Fails closed when the registry cannot be read, again as FORBIDDEN, logged as PERSISTED_QUERY_REGISTRY_UNAVAILABLE. Checked below the cache, so on misses and passes: a response cached before you switched it on is served until it expires or is purged.
persisted_query.audit_unregisteredbooleanfalseReports each operation the registry does not hold as PERSISTED_QUERY_UNTRUSTED_OPERATION, and lets it run. The list of what your manifest is still missing.
persisted_query.document_ttl_secsinteger604800 (7 days)How long a resolved document stays in the edge-location cache. Entries are content-addressed, so a hash points at one document forever or at nothing.
persisted_query.not_found_ttl_secsinteger60How long a “this hash resolves to nothing” answer is remembered, so random hashes cannot force a lookup per request.
persisted_query.max_document_bytesinteger (bytes)request.max_document_bytesThe document ceiling for a registered document: one resolved from the registry for a hash, and one a client registers over APQ. Silence inherits the [request] value, so setting this is how a registered document gets a different ceiling from an ad-hoc one. 0 means no limit. Refused with 400 and DOCUMENT_SIZE_LIMIT_EXCEEDED.
persisted_query.max_selectionsintegerrequest.max_selectionsThe selection ceiling for a registered document, on the same terms. 0 means no limit. Refused with 400 and DOCUMENT_SELECTION_LIMIT_EXCEEDED.

What each preset sets:

Presetenabledapqonly_registeredaudit_unregistered
offfalsefalsefalsefalse
apqtruetruefalsefalse
trusted_migratingtruetruefalsetrue
trustedtruefalsetruefalse

Two combinations are refused when the file is read, naming both keys:

RefusedWhy
only_registered = true with apq = trueAn allowlist any client can add to is not an allowlist. Set apq = false to enforce it.
apq, only_registered or audit_unregistered set to true while enabled is falseA service that does not resolve hashes has nothing to register, refuse or audit, so the switches would read as configured and do nothing.
[persisted_query]
preset = "trusted_migrating"
apq = false # collect the audit list without letting clients register

Which clients this service serves, and what it does with a request from one it does not recognize. The rules, including why a profile matched on a header is not a security boundary, are in telling your clients apart.

KeyTypeDefaultDoes
client.unknown"allow" | "reject""allow"What happens to a request matching no profile. allow serves it with no client recorded against it. reject refuses it with 403 and UNKNOWN_CLIENT, above the cache, before your origin is reached. A CORS preflight (an OPTIONS with Access-Control-Request-Method and no body) is never refused by reject, min_version, a profile’s limits or persisted_query = "required"; an OPTIONS with a body is checked as usual.

An ordered array. The first profile whose when is true wins. Every limit a profile sets can only tighten the service-wide value of the same name; cache = "off" and max_age relax.

KeyTypeDefaultDoes
namestringrequiredThe canonical name, recorded on every request this profile matches. May not contain @ or be empty.
whenexpressionrequiredWhether this request is that client. Evaluated as a truth, so a bare header:x-app means “present and not empty”.
versionexpressionunsetWhere to read the client’s version. Evaluated as a value. Absent means the client is recognized with no version recorded.
min_versionstringunsetThe oldest release of this client the service serves. Anything below it, or a client reporting no readable version, is refused with 403 and CLIENT_VERSION_UNSUPPORTED. A value that is not a valid version refuses nothing and logs CLIENT_MIN_VERSION_UNREADABLE. Without version, refuses the deployment.
max_bodyinteger (bytes)unsetA second ceiling under request.max_body_bytes. A larger body is refused with 413 and PAYLOAD_TOO_LARGE, the same code the service-wide ceiling returns; your logs record CLIENT_BODY_TOO_LARGE and name the client.
max_query_depthintegerunsetHow deep an operation from this client may nest. Refused with 400 and QUERY_DEPTH_LIMIT_EXCEEDED. 0 refuses the deployment; leave the key out for no ceiling.
max_document_bytesinteger (bytes)unsetA second ceiling under request.max_document_bytes. Refused with 400 and DOCUMENT_SIZE_LIMIT_EXCEEDED. 0 refuses the deployment.
max_selectionsintegerunsetA second ceiling under request.max_selections. Refused with 400 and DOCUMENT_SELECTION_LIMIT_EXCEEDED. 0 refuses the deployment.
persisted_query"any" | "required""any"required refuses any operation from this client that does not arrive as a hash and nothing else, with 403 and PERSISTED_QUERY_REQUIRED. A payload carrying a document is refused whatever hash travels beside it, so the APQ registration handshake is refused too. Does not apply to operations sent over a subscription socket. Ignored, logged as CLIENT_PERSISTED_QUERY_UNENFORCEABLE, while persisted_query.enabled is false.
cache"on" | "off""on"off takes this client off the shared cache: nothing is looked up, nothing is stored, every request reaches your origin.
max_ageinteger (seconds)unsetHow old a cached entry may be before this client stops accepting it. Over the cap, its request goes to the origin and reports PASS with CACHE_SKIPPED_ENTRY_NOT_REPLACED; the entry is unchanged for other clients. Together with cache = "off", refuses the deployment.
[client]
unknown = "allow"
[[client.profile]]
name = "web-app"
when = "header:gp-client-name == 'web-app'"
version = "header:gp-client-version"
min_version = "2.1.0"
[[client.profile]]
name = "internal-bi"
when = "auth:idp.role == 'analyst'"
version = "auth:idp.client_version"
cache = "off" # fresh data every time, straight from the origin
max_query_depth = 6
[[client.profile]]
name = "gp-cli"
when = "starts_with(header:user-agent, 'gp-cli/')"
version = "split(header:user-agent, '/', 1)"

What is recorded about a request.

KeyTypeDefaultDoes
telemetry.max_query_bytesinteger32768 (32 KiB)How much of the client’s query document is retained, on a request the edge answered without calling the origin itself (a cache hit, a request served through the shield). The document sent to the origin is not cut by this setting, only as far as the log line size limit requires. 0 removes the bound. Query hashes are always computed over the full text, so truncation never affects a cache key or a purge.
telemetry.correlation_headerstring"GP-Correlation-ID"The request header a client sends its own correlation ID in. Matched case-insensitively. A name that is not a valid HTTP header name is refused at deploy. The header is forwarded to your origin unchanged, the default name included, unless [origin.headers] removes it. See correlating logs.
telemetry.recordexpression or booleanunset (record everything)Which requests are recorded in Insights: a policy expression read as a truth. "false" records nothing. See recording only some requests.
telemetry.client_ip"full", "truncated" or "none""full"How much of the end user’s IP address, and of the geo and network data derived from it, the request’s telemetry keeps. Unless "full", no [telemetry.headers] rule records gp-client-ip or a header that carries the client’s address either. Any other value refuses the deployment. See client IP address.
telemetry.vary_values"pseudonymized" or "clear""pseudonymized"The form request telemetry stores the value of a [[cache.policy.vary]] entry in, for entries whose pass_to_origin is never or plain: a keyed pseudonym, or the value in clear. Any other value refuses the deployment. See vary values.

Every request a service serves is recorded in Insights, on every plan, unless telemetry.record says otherwise. Set it to a predicate and only the requests it matches are recorded:

[telemetry]
# Nothing at all.
record = "false"
[telemetry]
# Everything but the cluster's health probes.
record = "!starts_with(header:user-agent, 'kube-probe/')"
  • Unset records everything. "false" records nothing, and a bare TOML false means the same (record = true is the same as leaving the key out). Any other value that is not a string, and an expression that does not parse, fails the deployment.
  • A request the expression cannot decide is recorded. The second example has no answer for a caller that sent no User-Agent, and that caller is kept. To leave them out as well, write header:user-agent && !starts_with(header:user-agent, 'kube-probe/'). See a request the expression cannot decide.
  • A request that is not recorded is still served and still counted. It is billed and counts towards your allowance exactly like any other. Only what Insights stores changes: the request, its log lines and any purge it triggers stay out of the request log, the charts, the purge history and gpilot requests.
  • It can only leave things out. Recording a request does not keep it while the account runs in lean mode.
  • An expression over auth: is decided on the verified claims. A request refused before authentication completes is decided without them.
  • The portal says so. With "false", every Insights tab says the service records no requests and why, in place of empty lists; with a predicate, a note names the expression. The deployment also reports record = "false" as an info finding.

There is no path or method source, so a health check is told apart by what it sends, typically its User-Agent. The setting needs a runtime that ships it: an older runtime does not read the key, records everything, and the deployment’s configuration report lists the key as unknown.

By default every recorded request keeps the client’s IP address as the edge saw it, plus the geo and network data looked up from it. telemetry.client_ip reduces that per service, for data-minimisation requirements, without turning telemetry off:

[telemetry]
client_ip = "truncated"
  • "full" (the default) keeps the address and everything derived from it. Leaving the key out changes nothing.
  • "truncated" keeps the network part of the address: an IPv4 address with its last octet zeroed (203.0.113.77 becomes 203.0.113.0), an IPv6 address cut to its first 48 bits (2001:db8:85a3:8d3::7 becomes 2001:db8:85a3::). An IPv4 address in IPv6 notation (::ffff:203.0.113.77) is truncated as the IPv4 address it is. An address that cannot be read is not stored at all.
  • "none" stores no address.
Fieldfulltruncatednone
Client IPas seennetwork part (/24 or /48)not stored
Country, continentkeptkeptkept
Region, citykeptkeptnot stored
Latitude, longitude, postal codekeptnot storednot stored
UTC offsetkeptkeptnot stored
ISP, ASN, connection speed and type, proxy type and descriptionkeptkeptnot stored
Edge location and node, TLS and HTTP versionkeptkeptkept

The reduction applies to the telemetry row. Log lines carry no client IP, country or city at all, whatever the setting. The geo lookup itself still runs on the full address at the edge; only what is stored is reduced, so the country of a "none" service is as accurate as before.

  • Counting is unaffected. Every request is still recorded, billed and counted towards your allowance exactly as before.
  • Your origin is unaffected. origin.forward_client_ip decides whether your origin receives the full address in gp-client-ip, independently of this key.
  • Filtering by client IP is exact. On a "truncated" service, filter by the truncated address (in Insights or with gpilot requests --ip=<address>). Only my requests and a bare gpilot requests --ip use your full address, so they match nothing on a "truncated" or "none" service.
  • In Insights, a truncated address is shown as stored (an IPv4 one in IPv6 notation, for example ::ffff:203.0.113.0), and a request without one shows Not stored. With "none" the UTC offset reads 0.
  • It applies from the next deployment on a runtime that ships the key. Requests recorded before keep what was stored then.

Every request whose cache entry a [[cache.policy.vary]] entry partitioned records the value that entry derived, shown in Insights as the request’s segment. telemetry.vary_values decides the form it is stored in, per service, for entries whose pass_to_origin is never (the default) or plain:

[telemetry]
vary_values = "pseudonymized"
  • "pseudonymized" (the default) stores a keyed pseudonym, hmac-sha256: followed by 16 hexadecimal digits. It is made with a secret key of the service that neither you nor your origin holds, so the stored value cannot be turned back into the one it stands for. Equal values give equal pseudonyms within the service, so requests of one segment still group together and Requests in this bucket still finds them. Two services give the same value two different pseudonyms. A pseudonym is never replaced by the value itself: if it cannot be derived, nothing is recorded.
  • "clear" stores the value as derived. This is an explicit choice, and responsibility for it lies with you: a rule whose value is a session cookie, an Authorization header, an email address or any other personal data then stores that data in your request telemetry, where Insights shows it. The [telemetry.headers] sanitize rules do not apply to it.

Not affected by this key:

  • Entries with pass_to_origin = "anonymized" store the v1= digest your origin receives, and cache.policy.private and public always store a v1= digest.
  • The cache and your origin. What the cache partitions on and what pass_to_origin sends your origin stay the same; only the stored copy changes.
  • Counting. Every request is still recorded, billed and counted as before.

A value in clear never equals its pseudonym, so after a change of this key requests of one segment form two groups, before and after the deployment. Requests recorded before keep what was stored then, until your plan’s retention deletes them.

Four independent rule lists. Each is an ordered list of rules; the first match wins, and a header no rule matches is not recorded.

ListCovers
telemetry.headers.requestThe request as the client sent it.
telemetry.headers.origin_requestThe request as it leaves for your origin, after vary scrubbing, your [origin.headers] filter, the forwarding header strip and signing: what your origin actually received.
telemetry.headers.responseThe response as your origin sent it. The name is older than the fourth list; read it as “origin response”.
telemetry.headers.client_responseThe response as the client receives it, after [response.headers] and cache.policy.downstream. The only list that shows GraphPilot’s own gp- headers, so it answers “what did my client actually see”.

Some headers are never recorded, whatever a rule says, a catch-all .* included. Names are matched case-insensitively.

  • Always: every header whose name starts with gp-vary- (the cache’s bucket discriminators), every header named in a [[cache.policy.vary]] entry, and gp-shield-hop.
  • Unless telemetry.client_ip is "full" (so under "truncated" and "none"), the headers that carry the client’s address: gp-client-ip, every header whose name starts with x-forwarded-, forwarded, x-real-ip, x-client-ip, client-ip, true-client-ip, cf-connecting-ip, fastly-client-ip, x-original-url, x-rewrite-url, x-original-host, x-cluster-client-ip, cf-connecting-ipv6, x-envoy-external-address and x-appengine-user-ip. Under "full" a rule that includes them records them like any other header; no default rule does. The last four are not among the forwarding headers that origin.forwarded_headers strips.

A rule names either an exact header or a pattern, plus an action:

ActionEffect
includeRecord the header and its value. The default when a rule names no action.
sanitizeRecord a keyed digest of the secret part instead of the value: hmac-sha256: followed by 16 hex digits, an HMAC-SHA-256 with a secret key held per service. The parts that carry no user data stay readable: a cookie’s name, an authorization scheme such as Bearer, and Set-Cookie attributes such as Secure or SameSite=Lax. The header is still visible as present, and two requests of one service carrying the same value get the same digest, so they are still comparable. The same value in two services gives two different digests, and a digest cannot be recomputed from candidate values without the key. If the service’s key cannot be read, each secret part is recorded as [redacted], never as a plain hash.
excludeDo not record it at all.
[[telemetry.headers.request]]
name = "authorization"
action = "sanitize"
[[telemetry.headers.request]]
pattern = "^x-internal-"
action = "exclude"
[[telemetry.headers.request]]
pattern = ".*"
action = "include"

The defaults are deliberately narrow. Unless you replace a list, these are the only headers recorded:

ListSanitizedRecorded
requestauthorization, cookiecontent-type, accept, referer
origin_requestauthorization, cookie, gp-signaturegp-timestamp, content-type, accept
responseset-cookiecontent-type, cache-control, server, content-encoding
client_responseset-cookieevery gp- header except gp-surrogate-key (excluded, the telemetry row carries the keys already), plus cache-control, age, content-type, content-encoding and vary

How GraphPilot reads your schema’s directives, and which fields count as keys without any directive at all.

[[surrogate_key.directives]] and [[cache_control.directives]]

Section titled “[[surrogate_key.directives]] and [[cache_control.directives]]”

Repeatable. Each entry names a directive GraphPilot reads, and the arguments it reads from it. See renaming directives.

The built-in entry uses the values in the second column. In an entry you write, a key marked required must be set; an optional surrogate_key argument you leave out is unset, and an optional cache_control key you leave out takes the built-in value.

KeyBuilt-in valueIn an entry you write
surrogate_key.directives[].directive_name"surrogateKey"Required. The directive, without the @.
surrogate_key.directives[].of_arg"of"Optional. Unset when left out: the directive reads no key fields, so it works only as a marker.
surrogate_key.directives[].static_arg"static"Optional. Unset when left out: the directive reads no fixed keys.
surrogate_key.directives[].ignore_arg"ignore"Optional. Unset when left out: ignore: true on that directive does nothing, and no diagnostic says so.
cache_control.directives[].directive_name"cacheControl"Required. The directive, without the @.
cache_control.directives[].max_age_arg"maxAge"Required. The lifetime argument.
cache_control.directives[].scope_arg"scope"Required. The scope argument.
cache_control.directives[].inherit_max_age_arg"inheritMaxAge"Required. The inheritance argument.
cache_control.directives[].swr_arg"swr"Optional; "swr" when left out. The stale-while-revalidate argument.
cache_control.directives[].vary_directive_name"vary"Optional; "vary" when left out. The directive that declares a named scope.
cache_control.directives[].vary_names_arg"names"Optional; "names" when left out. That directive’s list argument.
[[surrogate_key.directives]]
directive_name = "key" # read Apollo Federation's @key(fields:)
of_arg = "fields"

An entry replaces the built-in directive (see the caution below). directives = [] switches the family off, so no directive is recognized.

KeyTypeDefaultDoes
surrogate_key.scalarslist of strings["ID"]Scalar type names whose fields are treated as key fields.
surrogate_key.nameslist of strings["id", "_id", "key"]Field names treated as key fields whatever their type.
surrogate_key.ignored_typeslist of strings[]Object types and interfaces the two rules above do not key. An interface listed here does not take the types implementing it along.
surrogate_key.ignored_fieldslist of strings, each "Type.field"[]Single fields of an object type or interface the two rules above do not key.
surrogate_key.mutation_payload_depthinteger1How many levels below a mutation’s return type the deployment build looks for types that carry surrogate keys, so a mutation purges an entity the client did not select. 1 reaches the entity inside a Relay-style payload (updateX: UpdateXPayload { x: X }); 0 switches this off. 2 also reaches the entity’s owner and purges every cached response mentioning it, which is broad: prefer an owner key on the entity’s type, such as @surrogateKey(of: "service.id"). See automatic invalidation.
[surrogate_key]
ignored_types = ["RenderedSnippet"]
ignored_fields = ["Product.key"]

The exclusions switch off only those two automatic rules, never a key your schema declares; the build reports the conflict as a warning. The schema makes the same exclusions with @surrogateKey(ignore: true). An entry that matches no object type, interface or field is a warning, and an ignored_fields entry not of the form Type.field is an error; both exclude nothing and neither fails the deployment.

KeyTypeDefaultDoes
runtime.versionstringnoneThe proxy runtime version for the one deployment that gpilot deployment create makes from this file. See Runtime versions.

This key pins only that deployment. The CLI checks the version first and refuses an unknown or withdrawn one before uploading anything, removes [runtime] version from the uploaded config and sends it as the deployment’s runtime version. It is not the service’s pin: a redeploy from the portal, or a change of the pin, does not carry it. Set the service-level pin in the portal or with gpilot service runtime pin <version>. Changing it redeploys the service unless the service already runs the version the pin resolves to, or you save it for the next deployment in the portal; either way it needs service:edit and deployment:edit.

Stated so nobody configures them expecting an effect:

  • The [deployment] section and runtime.serving_mode are written by GraphPilot. A value you supply is ignored.

Whether a key GraphPilot does not know is refused depends on the section it is written in.

Refused. These sections deny unknown keys, so a misspelling in them fails when the file is read and the deployment does not start:

  • [client] and every [[client.profile]]
  • [persisted_query]
  • [response], [response.headers], [response.cookies], and every entry in their rules
  • [response.cors]
  • [origin.headers], [origin.cookies] and [origin.retry]
  • [cache.request_directives], [cache.http_key], [cache.headers] and [cache.cookies]
  • [auth.rejection]

Ignored. Everywhere else an unknown key deploys cleanly and does nothing: the top level of the file, [request], [origin], [cache], [cache.policy], every [[cache.policy.vary]] entry, [auth], every [auth.providers.<name>] block, [telemetry], [telemetry.headers], [surrogate_key] and [cache_control], and [runtime] apart from its version key. So a typo like [cache.polcy] or [reqest], or a misspelled key inside a provider block, is silent at deploy time.

  • An invalid enum value fails when the configuration is read. Values are matched exactly as spelled here; there is no case folding.
  • An unparseable policy expression fails, and the whole expression must parse: a typo does not become a shorter, different expression.
  • A [[cache.policy.vary]] entry whose name is not a legal header name is the exception to that: it does not fail, it is skipped, so the rule never fires. The deployment records it as an error in its diagnostics, which the portal shows under findings and gpilot deploy prints when the deployment finishes.
  • An api_key provider with an empty prefixes list, or one containing an empty string, is refused with a message saying why.
  • A duplicate provider name is rejected by TOML itself, before any of this runs.
  • A provider name longer than 977 bytes (UTF-8) refuses the deployment with AUTH_PROVIDER_NAME_TOO_LONG, before a deployment is created. Every [auth.providers.<name>] block counts, with or without a jwks_url.
  • An origin timeout above your plan’s ceiling or below 100 ms, or a retry policy whose worst case does not fit 120 seconds, refuses the deployment. See [origin].
  • A persisted query combination that contradicts itself is refused. See [persisted_query].