Skip to main content

Configuration

Configure database connections, authentication, workers, clustering, and node roles in forge.toml.

The Code

[project]
name = "my-app"

[database]
url = "${DATABASE_URL}"
pool_size = 50
replica_urls = ["${DATABASE_REPLICA_URL}"]

[gateway]
port = 9081

[worker]
max_concurrent_jobs = 10
poll_interval = "5s"

[auth]
jwt_algorithm = "RS256"
jwks_url = "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com"
jwt_issuer = "https://securetoken.google.com/my-project"

[node]
roles = ["gateway", "worker", "scheduler"]
worker_capabilities = ["general", "media"]

What Happens

Forge reads forge.toml at startup and substitutes environment variables. Each section configures a different subsystem. Sections you omit use sensible defaults.

Environment variables use ${VAR_NAME} syntax (uppercase letters, numbers, underscores). Default values are supported with ${VAR-default} or ${VAR:-default} syntax. Unset variables without defaults remain as literal strings.

Sections

[project]

OptionTypeDefaultDescription
namestring"forge-app"Project identifier
versionstring"0.1.0"Project version

[database]

OptionTypeDefaultDescription
urlstring-PostgreSQL connection URL
pool_sizeu3250Connection pool size
pool_timeoutstring"30s"Pool checkout timeout
statement_timeoutstring"30s"Query timeout
replica_urlsstring[][]Read replica URLs
read_from_replicaboolfalseRoute reads to replicas
min_pool_sizeu320Minimum connections kept open in the pool
test_before_acquirebooltruePing connection health before handing it to a caller
[database]
url = "${DATABASE_URL}"

During development, docker compose up --build runs PostgreSQL via Docker Compose. For production, provide a DATABASE_URL pointing to your PostgreSQL instance.

Read Replicas

[database]
url = "${DATABASE_URL}"
replica_urls = [
"${DATABASE_REPLICA_1}",
"${DATABASE_REPLICA_2}"
]
read_from_replica = true

Queries route to healthy replicas via round-robin. Mutations always use the primary. A background monitor pings each replica every 15 seconds and removes unhealthy ones from rotation. If all replicas fail, reads fall back to primary.

Queries that need read-after-write consistency can use #[forge::query(consistent)] to bypass replicas and read directly from the primary.

Single Pool

Forge uses a single primary connection pool for queries, mutations, jobs, cron, daemons, workflows, and observability. Workload isolation belongs at the worker level (concurrency limits, dedicated nodes), not the connection level. Size pool_size for the union of expected concurrency and use statement_timeout to bound runaway queries.

[gateway]

OptionTypeDefaultDescription
portu169081HTTP port
grpc_portu169000Inter-node communication port
max_connectionsusize4096Maximum concurrent connections
request_timeoutstring"30s"Request timeout
cors_enabledboolfalseEnable CORS handling
cors_originsstring[][]Allowed CORS origins (use ["*"] for any)
quiet_pathsstring[]["/_api/health", "/_api/ready", ...]Routes excluded from traces, metrics, and logs
max_body_sizestring"20mb"Total multipart body cap. Supports b, kb, mb, gb suffixes. Per-mutation max_size overrides this.
max_file_sizestring"10mb"Per-file cap applied when a mutation does not declare its own max_size. Must be ≤ max_body_size.

When cors_enabled = true, cors_origins must be non-empty. You can use ["*"] to allow any origin or list specific origins, but you cannot mix "*" with concrete origins — browsers ignore wildcards on credentialed requests and Forge will reject that combination at startup.

Request Body Limits

PayloadDefaultConfigurable
JSON body1 MiBNot configurable
Multipart total20 MiBgateway.max_body_size in forge.toml
Per file10 MiBgateway.max_file_size in forge.toml
Per mutation-#[forge::mutation(max_size = "200mb")] per handler

When a mutation declares max_size, that value becomes both the total and per-file limit for that endpoint — treat it as an explicit opt-in for large single files. Without an override, the total falls back to max_body_size and any single file is capped by max_file_size. Other fixed limits: 20 upload fields per request, 32 concurrent in-flight uploads.

[gateway.tls]

Optional TLS termination on the gateway listener. Off by default. Use this when you need encrypted traffic between a load balancer and the app (ALB backend HTTPS, compliance requirements) or for internal services without a fronting proxy.

OptionTypeDefaultDescription
cert_pathstring-Path to a PEM-encoded certificate chain file
key_pathstring-Path to a PEM-encoded private key file

Both paths set → TLS is on. Both omitted → plain HTTP. Setting only one is a configuration error — forge check and startup both reject it.

[gateway.tls]
cert_path = "${GATEWAY_TLS_CERT_PATH}"
key_path = "${GATEWAY_TLS_KEY_PATH}"

Use a certificate from a CA (cert-manager, ACM Private CA, mounted Kubernetes secret, etc.) for production. For internal or development use, generate a throwaway cert with openssl — see the TLS section of the deploy guide for the exact recipe.

caution

Forge's gateway TLS is for internal / backend-of-load-balancer use. It has no HSTS, OCSP stapling, ACME, or hot-reload. For public-facing edge TLS, terminate at a load balancer, CDN, or reverse proxy. Certificate rotation requires a process restart.

[function]

Controls query and mutation execution limits.

OptionTypeDefaultDescription
max_concurrentusize1000Maximum concurrent function executions
timeoutstring"30s"Function execution timeout
memory_limitusize536870912Memory limit per function (bytes, 512 MiB)
[function]
max_concurrent = 1000
timeout = "30s"
memory_limit = 536870912 # 512 MiB

The memory limit is advisory. Functions exceeding this limit may be terminated. Set appropriately for your workload.

[security]

Reserved security settings parsed from config for forward compatibility.

OptionTypeDefaultDescription
secret_keystring-Reserved; currently not used by the runtime
[security]
secret_key = "${FORGE_SECRET_KEY}"

Generate a secure key if you want to populate this value ahead of future runtime support:

openssl rand -base64 32

[auth]

OptionTypeDefaultDescription
jwt_algorithmstring"HS256"Signing algorithm
jwt_secretstring-Secret for HMAC algorithms (must be 32+ bytes)
jwks_urlstring-JWKS endpoint for RSA algorithms
jwks_cache_ttlstring"1h"Public key cache duration
jwt_issuerstring-Expected issuer claim (optional)
jwt_audiencestring-Expected audience claim; required when auth is enabled unless audience_required = false
audience_requiredbooltrueWhen true, jwt_audience must be set whenever auth is configured
required_claimsstring[]["exp", "sub"]JWT claims that must be present in every token
legacy_secretsarray of {secret, valid_until}[]Retired HMAC secrets still accepted for validation. Each entry needs a valid_until RFC 3339 timestamp; expired entries are dropped at startup with a tracing::warn.
jwt_leewaystring"60s"Clock-skew tolerance for exp/nbf validation
access_token_ttlstring"1h"Access token lifetime
refresh_token_ttlstring"30d"Refresh token lifetime
session_ttlstring"7d"Session TTL

jwt_secret must be at least 32 bytes when HMAC algorithms are used. Generate a suitable value with openssl rand -base64 32.

HMAC (Symmetric)

[auth]
jwt_algorithm = "HS256" # or HS384, HS512
jwt_secret = "${JWT_SECRET}"

RSA with JWKS (Asymmetric)

[auth]
jwt_algorithm = "RS256" # or RS384, RS512
jwks_url = "https://your-provider.com/.well-known/jwks.json"
jwt_issuer = "https://your-provider.com"
jwt_audience = "your-app-id"

Common JWKS URLs:

ProviderJWKS URL
Firebasehttps://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com
Auth0https://YOUR_DOMAIN.auth0.com/.well-known/jwks.json
Clerkhttps://YOUR_DOMAIN.clerk.accounts.dev/.well-known/jwks.json
Supabasehttps://YOUR_PROJECT.supabase.co/auth/v1/jwks

Claim Enforcement

[auth]
jwt_algorithm = "RS256"
jwks_url = "https://your-provider.com/.well-known/jwks.json"
jwt_audience = "your-app-id"
audience_required = true # default — remove or set false only during migration

# Require additional claims beyond exp and sub
required_claims = ["exp", "sub", "email"]

Secret Rotation

Move the outgoing secret to legacy_secrets while deploying the new one. Each retired entry must carry a valid_until timestamp — pick a date past your longest in-flight token's expiry (typically now + access_token_ttl). Tokens are now issued with a kid header derived from the signing secret, so validation routes a presented token to its matching key directly. Expired entries are dropped at startup so a forgotten rotation can't extend a key's effective lifetime.

[auth]
jwt_algorithm = "HS256"
jwt_secret = "${JWT_SECRET_NEW}"

[[auth.legacy_secrets]]
secret = "${JWT_SECRET_OLD}"
valid_until = "2026-06-01T00:00:00Z"

Dev Mode and FORGE_ENV

AuthConfig::dev_mode() and AuthMiddleware::permissive() are intended for local development and tests. They now return Result and refuse construction with ForgeError::Config when FORGE_ENV=production so a stray dev override can't accidentally ship without authentication. Set FORGE_ENV=production (or any case-insensitive variant) on every production deployment.

[realtime]

Controls the real-time subscription engine, SSE session limits, and invalidation debouncing. All defaults are production-safe; tune these only when profiling shows a bottleneck.

OptionTypeDefaultDescription
sse_max_sessionsusize10000Maximum concurrent SSE sessions across all clients
subscription_max_per_sessionusize50Maximum subscriptions per SSE session
debounce_quiet_windowstring"50ms"Coalesce DB changes arriving within this window before triggering re-execution
debounce_max_waitstring"200ms"Force a flush after this much time even if changes keep arriving
max_concurrent_reexecutionsusize64Bounded parallelism for query re-executions during an invalidation flush
resync_intervalstring"60s"Periodic re-evaluation of all active query groups to recover from dropped NOTIFY payloads. Set "0s" to disable.
postgres_change_buffer_sizeusize1024Broadcast channel buffer for raw change notifications arriving from PostgreSQL
[realtime]
sse_max_sessions = 10000
debounce_quiet_window = "50ms"
debounce_max_wait = "200ms"
max_concurrent_reexecutions = 64
resync_interval = "600s"

The debounce window matters for write-heavy workloads: debounce_quiet_window prevents re-execution storms when rows are updated in rapid succession, while debounce_max_wait puts a ceiling on how long a client can wait for an update.

The old field names (debounce_quiet, debounce_max, listener_channel_buffer) are still accepted as aliases for backward compatibility.

[mcp]

Controls Forge MCP server exposure on Streamable HTTP.

OptionTypeDefaultDescription
enabledboolfalseEnable MCP endpoint
pathstring"/mcp"MCP endpoint path under /_api
session_ttlstring"1h"MCP session lifetime
allowed_originsstring[][]Allowed Origin values
require_protocol_version_headerbooltrueRequire MCP-Protocol-Version header after initialize
oauthboolfalseEnable OAuth 2.1 Authorization Server for MCP clients

Requires auth.jwt_secret to be set when oauth is enabled.

[mcp]
enabled = true
path = "/mcp"
session_ttl = "1h"
allowed_origins = ["https://your-app.example"]
require_protocol_version_header = true

With default API routing, path = "/mcp" resolves to /_api/mcp.

[worker]

OptionTypeDefaultDescription
max_concurrent_jobsusize50Concurrent job limit per worker
job_timeoutstring"1h"Default job timeout
poll_intervalstring"5s"Fallback queue polling interval (wakeups are NOTIFY-driven)

Workers maintain a semaphore sized to max_concurrent_jobs. They only poll when permits are available. Backpressure propagates naturally.

[rate_limit]

OptionTypeDefaultDescription
modestring"hybrid"Rate-limit backend: "hybrid" or "strict"

hybrid (default) uses an in-memory DashMap for key = "user" and key = "ip" (per-node, fast, approximate) and a shared Postgres counter for key = "global". Right for DDoS protection and traffic shaping.

strict routes every check through the shared forge_rate_limits table. Counts are cluster-wide and exact, suitable for billing-grade quotas. Slightly higher per-request latency.

[rate_limit]
mode = "strict"

[cluster]

OptionTypeDefaultDescription
namestring"default"Cluster identifier
discoverystring"postgres"Discovery method; the current runtime only implements postgres
heartbeat_intervalstring"5s"Heartbeat frequency
dead_thresholdstring"15s"Missing heartbeats before dead
seed_nodesstring[][]Static seed node addresses (for static discovery)
dns_namestring-DNS name for service discovery (for dns discovery)

Discovery

Nodes register in the forge_nodes database table by default, so an external service is not required. The current runtime only implements Postgres-backed discovery; other configured discovery values are parsed but ignored with a warning.

[cluster]
discovery = "postgres"

[node]

OptionTypeDefaultDescription
rolesstring[]all rolesRoles this node assumes
worker_capabilitiesstring[]["general"]Job routing capabilities

Node Roles

RoleResponsibility
gatewayHTTP/gRPC endpoints, SSE subscriptions
functionQuery and mutation execution
workerBackground job processing
schedulerCron scheduling, leader election

Single-node deployment (default):

[node]
roles = ["gateway", "function", "worker", "scheduler"]

API-only node:

[node]
roles = ["gateway", "function"]

Worker-only node:

[node]
roles = ["worker"]
worker_capabilities = ["gpu", "ml"]

Scheduler node (singleton per cluster):

[node]
roles = ["scheduler"]

Multiple nodes can run Scheduler. Advisory locks ensure only one is active. Others wait as standbys.

Worker Capabilities

Route jobs to specific workers:

# GPU worker
[node]
roles = ["worker"]
worker_capabilities = ["gpu"]

# General purpose worker
[node]
roles = ["worker"]
worker_capabilities = ["general", "media"]

Jobs requiring worker_capability = "gpu" only run on workers with that capability. Jobs without a capability requirement run on any worker.

[observability]

OTLP-based telemetry for traces, metrics, and logs. Disabled by default. When enabled, Forge auto-instruments HTTP requests, function calls, job execution, and database queries without any application code changes.

OptionTypeDefaultDescription
enabledboolfalseEnable OTLP telemetry export
otlp_endpointstring"http://localhost:4318"OTLP collector endpoint (HTTP)
service_namestringproject nameService name in telemetry data
enable_tracesbooltrueExport distributed traces
enable_metricsbooltrueExport metrics
enable_logsbooltrueExport logs via OTLP
sampling_ratiof640.1Trace sampling ratio (0.0 to 1.0). Default samples 10% to keep ingest costs bounded under load; bump to 1.0 in development.
metrics_intervalstring"15s"Metrics export interval
log_levelstring"info"Log level for the tracing subscriber

Forge never reads observability env vars directly. If you want to vary settings per environment, use the generic TOML interpolation: otlp_endpoint = "${FORGE_OTEL_ENDPOINT-http://localhost:4318}". Nothing is magic about the FORGE_OTEL_ prefix; any variable name works.

[observability]
enabled = true
otlp_endpoint = "http://localhost:4318"
sampling_ratio = 0.5

Requires an OTLP-compatible collector (Jaeger, Grafana Alloy, OpenTelemetry Collector, etc).

What Gets Instrumented

With enabled = true, Forge automatically creates spans and records metrics for:

  • HTTP requests (http.request span): method, route, status code, duration, trace ID, request ID
  • Function calls (fn.execute span): function name, kind (query/mutation), duration
  • Job execution (job.execute span): job ID, job type, duration, outcome (completed/retrying/failed/timeout)
  • Database queries: operation, table, duration, connection pool utilization

Slow queries (over 500ms) emit a warning automatically. Database pool metrics (size, active, idle, waiting) are recorded every 15 seconds.

Routes listed in [gateway].quiet_paths are excluded from all telemetry. Health and readiness probes are excluded by default to avoid noise from Kubernetes liveness checks. Set quiet_paths = [] to monitor everything.

Console logs always work regardless of the enabled flag. The flag only controls OTLP export.

[signals]

Built-in product analytics and frontend diagnostics. Enabled by default. See Signals for the full guide.

OptionTypeDefaultDescription
enabledbooltrueMaster switch for the signals pipeline
auto_capturebooltrueAuto-record RPC calls as events
diagnosticsbooltrueAccept frontend error reports
session_timeout_minsu3230Inactivity timeout before closing a session
retention_daysu3290Drop monthly partitions older than this
anonymize_ipbooltrueStore hashed visitor ID instead of raw IP
batch_sizeusize100Events per batch INSERT to PostgreSQL
flush_interval_msu645000Max milliseconds between event buffer flushes
excluded_functionsstring[][]Function names to skip in auto-capture
bot_detectionbooltrueTag bot traffic via User-Agent pattern matching
[signals]
enabled = true
session_timeout_mins = 30
retention_days = 90
excluded_functions = ["health_check"]

Signal events are stored in monthly-partitioned tables. The collector buffers events in memory and flushes them in batches using PostgreSQL's UNNEST() for efficient writes.

Production Defaults

Most defaults are designed to be safe out of the box. The table below calls out the ones you must review before going to production.

SettingDefaultProduction guidance
auth.*unconfigured (no JWT validation)Auth is off by default. Every RPC endpoint is publicly callable unless you configure [auth]. Set jwt_secret (HS256) or jwks_url (RS256) before deploying anything user-facing.
gateway.cors_enabledfalseCORS is off. Enable it only when a browser client needs cross-origin access, and set explicit origins — never ["*"] on a credentialed API.
gateway.hstsfalseOff because local dev uses plain HTTP. Set hsts = true when terminating TLS at the gateway (not at a load balancer) to prevent protocol downgrade attacks.
gateway.trusted_proxies[] (empty)X-Forwarded-For is ignored and the raw peer IP is used for rate limiting and signals. If your app runs behind a load balancer or ingress controller, set trusted_proxies to its CIDR range so client IPs are resolved correctly.
rate_limit.*hybrid mode, no per-function limitsThe mode controls how counters are stored. Limits themselves are declared per-handler with #[forge::query(rate_limit = ...)]. With no annotations, no rate limiting is applied. Add per-function limits to any public or unauthenticated endpoint.
signals.anonymize_iptrueIPs are hashed by default. Set anonymize_ip = false only if you have a lawful basis for storing raw IPs (e.g. explicit consent or a fraud-investigation requirement). Raw IPs likely qualify as personal data under GDPR.

Set FORGE_ENV=production

AuthConfig::dev_mode() and AuthMiddleware::permissive() are test helpers that bypass JWT validation. They fail at construction with a ForgeError::Config when FORGE_ENV=production, so a stray dev override cannot accidentally ship. Set this variable on every production deployment:

FORGE_ENV=production

Patterns

Development

Development uses docker compose up --build to run the project. The forge.toml in generated projects uses ${DATABASE_URL} which is set by the Docker Compose environment:

[project]
name = "my-app"

[database]
url = "${DATABASE_URL}"

[gateway]
port = 9081

Production Single Node

[project]
name = "my-app"

[database]
url = "${DATABASE_URL}"
pool_size = 100

[gateway]
port = 9081

[auth]
jwt_algorithm = "RS256"
jwks_url = "${JWKS_URL}"
jwt_issuer = "${JWT_ISSUER}"
jwt_audience = "${JWT_AUDIENCE}"

[worker]
max_concurrent_jobs = 20

Production Multi-Node

API nodes:

[database]
url = "${DATABASE_URL}"
pool_size = 40
replica_urls = ["${DATABASE_REPLICA_URL}"]
read_from_replica = true

[node]
roles = ["gateway", "function"]

[cluster]
discovery = "postgres"

Worker nodes:

[database]
url = "${DATABASE_URL}"
pool_size = 30
statement_timeout = "10m"

[node]
roles = ["worker"]
worker_capabilities = ["general"]

[worker]
max_concurrent_jobs = 25

[cluster]
discovery = "postgres"

Specialized Workers

GPU processing node:

[node]
roles = ["worker"]
worker_capabilities = ["gpu"]

[worker]
max_concurrent_jobs = 4 # GPU memory limits concurrency
job_timeout = "2h" # 2 hours for training jobs

Under the Hood

Environment Variable Substitution

Variables match the pattern ${VAR_NAME} where VAR_NAME contains uppercase letters, numbers, and underscores:

let re = Regex::new(r"\$\{([A-Z_][A-Z0-9_]*)\}")?;

Substitution happens at parse time. Unset variables remain as literal ${VAR_NAME} strings (useful for detecting misconfiguration).

Workload Separation

Forge runs one primary connection pool. Bulkheading happens at the worker level: dedicated worker nodes with their own pool_size and max_concurrent_jobs budgets, plus statement_timeout to bound runaway queries. The connection layer is not where you isolate workloads — at scale, that's just a knob that consumes the same finite database connection budget under another name.

Cluster Discovery

Nodes discover each other through PostgreSQL:

SELECT * FROM forge_nodes WHERE last_heartbeat > NOW() - INTERVAL '15s'

Nodes insert their address on startup, update on heartbeat, and get cleaned up when dead_threshold passes. Additional infrastructure is not required.

Node Role Enforcement

Roles determine which subsystems start:

if config.node.roles.contains(&NodeRole::Gateway) {
start_http_server(&config.gateway).await?;
}
if config.node.roles.contains(&NodeRole::Worker) {
start_job_worker(&config.worker).await?;
}
if config.node.roles.contains(&NodeRole::Scheduler) {
start_cron_scheduler().await?;
}

Omitted roles mean those subsystems never start. A Worker-only node never binds the HTTP port. A Gateway-only node never polls the job queue.