Configuration
The server is configured entirely through environment variables. Copy .env.example to .env for local work; pnpm dev:server loads .env without overriding anything already set in your environment.
Server
| Variable | Default | Meaning |
|---|---|---|
DATABASE_URL |
postgres://foliot:foliot@localhost:5432/foliot |
Postgres connection |
NODE_ENV |
Set to production for a real deployment. It is what makes the settings under “Required in production” below mandatory: the server refuses to start without them |
|
PORT, HOST |
4100, 0.0.0.0 |
Listen address |
LOG_LEVEL |
info |
debug, info, warn, error or silent |
FOLIOT_OPERATOR_TOKENS |
token:name,token:name for Foliot Console and admin calls |
|
FOLIOT_CORS_ORIGIN |
Comma-separated origins (or *) allowed to call the API from a browser. Unset means none |
|
FOLIOT_HANDLER_EGRESS |
closed |
Where handlers may live. closed connects only to public addresses; open is for development (handlers on localhost) and is refused with NODE_ENV=production or a KMS key without FOLIOT_KMS_ENDPOINT. pnpm dev:server sets open for you |
FOLIOT_HANDLER_ALLOW_CIDRS |
Private ranges handlers may live in under closed, 10.1.0.0/16,192.168.5.0/24. Opens them for every app. Link-local and metadata addresses are never reachable |
|
FOLIOT_TRUST_PROXY |
0 |
How many proxies sit in front, for resolving the client IP from X-Forwarded-For |
FOLIOT_GIT_SHA |
The deployed commit, reported by /health. Falls back to RAILWAY_GIT_COMMIT_SHA, GIT_COMMIT_SHA, then SOURCE_VERSION |
Required in production
With NODE_ENV=production the server refuses to start unless all of these are set, and its error lists every missing one at once with what would have quietly stopped working. Outside production they are optional (a development server has no Clerk), and a server that has Clerk sign-in on but lacks the key or the webhook secret logs a warning at boot saying what that costs.
| Setting | Without it |
|---|---|
FOLIOT_CLERK_ISSUER |
Nobody can sign in (FOLIOT_CLERK_AUTHORIZED_PARTIES is required with it) |
FOLIOT_CLERK_SECRET_KEY |
Invitations are recorded but never sent or matched, so nobody invited can join and an ownerless workspace cannot get an owner |
FOLIOT_CLERK_WEBHOOK_SECRET |
Names and emails never sync, and a deleted Clerk account keeps its access |
FOLIOT_KMS_KEY_ID |
App signing secrets would be wrapped with the development key |
A forgotten NODE_ENV means none of this applies, so set it on every real deployment.
Scheduler and limits
| Variable | Default | Meaning |
|---|---|---|
FOLIOT_SCHEDULER_ENABLED |
true |
Run the claim loop in this process |
FOLIOT_SCHEDULER_BATCH / _CONCURRENCY / _POLL_MS |
50 / 32 / 250 |
Claim size, in-flight limit, idle poll |
FOLIOT_LEASE_GRACE_MS |
15000 |
How long a worker may hold a claimed run beyond the step timeout |
FOLIOT_RETENTION_DAYS |
90 |
Compact finished runs after this |
FOLIOT_SYNC_TASK_MAX_MS |
60000 |
Longest a synchronous handler call may block; longer steps should answer 202 |
FOLIOT_PAYLOAD_MAX_BYTES |
262144 |
Cap on a run’s input, a signal payload, and each step’s output (over it fails the step, class output_too_large, and runs its onFailure). The total state is not capped, and neither is the result of an editRunState; each value of an edit’s patch is held to this limit, measured as a step’s output is, and each key to the same number of bytes |
Signing secrets
| Variable | Default | Meaning |
|---|---|---|
FOLIOT_KMS_KEY_ID |
AWS KMS key that wraps app signing secrets. Required when NODE_ENV=production: the server refuses to start without it |
|
AWS_REGION |
us-east-1 |
Region of that KMS key |
FOLIOT_KMS_ENDPOINT |
LocalStack only: for example http://localhost:4566 |
|
FOLIOT_SIGNING_SECRET_ROLLOVER_MS |
86400000 |
How long a rotated signing secret keeps verifying, so handlers can catch up |
Without NODE_ENV=production, a blank FOLIOT_KMS_KEY_ID is not an error: the server starts
on the in-process development key wrapper. Values sealed by it do not open under KMS, so
moving an existing database to KMS means rotating each app’s signing secret. A real deployment
must set both NODE_ENV=production and FOLIOT_KMS_KEY_ID.
KMS calls use the standard AWS credential chain. Against LocalStack, any credentials work, but the AWS SDK refuses to start with none, so set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to anything.
Operator identity
Operator identity is a static token map: each token:name pair in FOLIOT_OPERATOR_TOKENS is one person, and every action they take is recorded against that name. A name may not begin with user: or app:: Foliot records those for a signed-in person and for an app’s own key, and refuses to start if an operator is named that way. Operator tokens and app API keys are unaffected by everything below.
An operator token must be an opaque random string. The server refuses to start if one has the shape of a JWT (three base64url parts, the first beginning eyJ), because that shape is how it tells a Clerk session token from every other credential.
Sign-in with Clerk
People sign in to Foliot Console through Clerk. The server verifies the Clerk session token the browser sends and learns who the person is, and nothing else: no organisation, role or metadata claim in the token is ever read to decide what they may do. That is decided from the server’s own tables.
Clerk is off by default. With no FOLIOT_CLERK_ISSUER, a Clerk token is just an unrecognised credential and is answered 401, exactly like any other.
| Variable | Default | Meaning |
|---|---|---|
FOLIOT_CLERK_ISSUER |
Your Clerk instance’s Frontend API URL, for example https://clerk.example.com: the exact iss a token must carry. No trailing slash or path. Setting it turns sign-in on |
|
FOLIOT_CLERK_JWKS_URL |
<issuer>/.well-known/jwks.json |
Where that instance publishes its signing keys |
FOLIOT_CLERK_AUTHORIZED_PARTIES |
Comma-separated origins a token may have been minted for (its azp), for example https://console.example.com,http://localhost:4101. Required when the issuer is set: a token for any other origin is refused even with a valid signature, and so is one with no azp at all |
The three are checked together at boot. Setting the origins or the key URL without an issuer, or an issuer without origins, stops the server rather than leaving sign-in silently off or too permissive. URLs must be https (plain http is accepted only for localhost and 127.0.0.1).
The Clerk webhook
A second, independent variable turns on the webhook that keeps people’s names and emails in step with their Clerk accounts and removes a deleted account (see Workspaces, members and Clerk). It does not depend on the sign-in settings above, and either can be on without the other.
| Variable | Default | Meaning |
|---|---|---|
FOLIOT_CLERK_WEBHOOK_SECRET |
The Svix signing secret of the Clerk endpoint, whsec_ followed by base64, copied from the Clerk dashboard. Unset, POST /webhooks/clerk answers 404. A value that is not a well-formed secret stops the boot; the error names the variable and never repeats the value |
A third variable lets Foliot call Clerk’s Backend API, so that invitations send email and a pending invitation can let a person in (see Managing members). Like the webhook, it is independent of sign-in.
| Variable | Default | Meaning |
|---|---|---|
FOLIOT_CLERK_SECRET_KEY |
The instance’s Backend API secret key, sk_ followed by letters and digits, from the API keys page of the Clerk dashboard. Unset, an invitation is recorded and nothing is sent or matched. A value that is not shaped like a secret key (the publishable pk_ key is the likely mistake) stops the boot; the error never repeats it |
|
FOLIOT_CLERK_API_URL |
https://api.clerk.com |
Where the Backend API is reached. Only for a stand-in during development: it must be https, or http to a loopback host, because the secret key is sent to it. Setting it without the key stops the boot |
The key is a secret. It lets the holder send email as your Clerk instance and read every user’s addresses, so it belongs in the server’s secret store and nowhere else, and it is never logged: printing the server’s configuration shows it as [redacted].
For a Clerk development instance such as https://your-instance.clerk.accounts.dev, the sign-in values are:
FOLIOT_CLERK_ISSUER=https://your-instance.clerk.accounts.dev
# Defaults to <issuer>/.well-known/jwks.json, which is
# https://your-instance.clerk.accounts.dev/.well-known/jwks.json
FOLIOT_CLERK_AUTHORIZED_PARTIES=http://localhost:4101
What the server checks on every token: an RS256 signature against the instance’s published keys (the algorithm is fixed, never read from the token), the issuer, the authorized party, expiry and not-before with five seconds of leeway, that the session is not pending, and that the subject is a Clerk user id.
- Keys are fetched on first use and refreshed after an hour. The refresh happens behind a request that still holds a cached key, so one failed attempt at the hour mark signs nobody out: the server keeps verifying with its cached keys while Clerk’s endpoint is down, for up to six hours since the last good fetch, and then refuses with
503(not401, which would send a client back through sign-in) until the endpoint answers again. A key Clerk has revoked stops verifying at the first refresh that succeeds; during an outage it stays usable for at most those six hours. A token signed with an unknown key id triggers a refetch, at most one every 30 seconds however many such tokens arrive, so a flood of junk tokens cannot be turned into traffic to Clerk. After a rotation, a token signed with the new key can therefore be refused for up to 30 seconds. Each fetch has a 5-second timeout and a 64 KiB body cap. - A failed token is always the same
401 invalid token, whichever check failed; the reason is in the server log, never the token. - A verified person with no row in the
userstable gets one, with no workspace memberships, so they see nothing until someone adds them. Their email and name are copied from the token only if the session token template includes them. - What a person may do is decided on every request from their membership, read from the database each time and never cached: the app’s workspace, then their role in it. A
viewercan read, anoperatorcan also act on runs, workflows and step controls, and nothing about an app’s credentials or details is reachable by a person yet (those routes answer403whatever the role). An app in a workspace they are not in, an app with no workspace, and an app that does not exist all answer the same404. An operator token and an app key are unaffected.