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 = "100ms"

[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.

Pool Isolation (Bulkhead)

Separate connection pools prevent runaway workloads from starving others:

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

[database.pools.default]
size = 30
timeout = "30s"

[database.pools.jobs]
size = 15
timeout = "1m"
statement_timeout = "5m"

[database.pools.analytics]
size = 5
timeout = "2m"
statement_timeout = "10m"

[database.pools.observability]
size = 3
timeout = "5s"
statement_timeout = "10s"

Available pool names and what uses them:

PoolUsed By
defaultQueries, mutations, rate limiter, reactor, cluster coordination
jobsJob workers, cron runners, daemon processes, workflow executors
analyticsAvailable via db.analytics_pool() for user code
observabilityInternal metrics collection (pool utilization, slow query tracking)

Without pool isolation configured, everything shares the primary pool. With it configured, a spike in background job processing cannot starve user-facing query connections. Each pool enforces independent connection limits, checkout timeouts, and statement timeouts.

[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_secretsstring[][]Old HMAC secrets still accepted for validation during secret rotation
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

Add the outgoing secret to legacy_secrets while deploying the new one. Remove it after one access_token_ttl elapses so in-flight tokens issued under the old secret can still be validated.

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

[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
change_tracking_row_thresholdusize200Switch from row-level to table-level change tracking per table above this subscription count
[realtime]
sse_max_sessions = 10000
debounce_quiet_window = "50ms"
debounce_max_wait = "200ms"
max_concurrent_reexecutions = 64
resync_interval = "60s"

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 change_tracking_row_threshold trades memory for precision: tables with fewer than that many subscriptions track individual row changes; above it, the engine tracks at table granularity (faster, slightly more false-positive invalidations).

The old field names (debounce_quiet, debounce_max, listener_channel_buffer, adaptive_row_threshold) 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"100ms"Queue polling interval

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_ipboolfalseStore 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 on the analytics connection pool. The collector buffers events in memory and flushes them in batches using PostgreSQL's UNNEST() for efficient writes.

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}"
replica_urls = ["${DATABASE_REPLICA_URL}"]
read_from_replica = true

[database.pools.default]
size = 40

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

[cluster]
discovery = "postgres"

Worker nodes:

[database]
url = "${DATABASE_URL}"

[database.pools.jobs]
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).

Bulkhead Isolation

Connection pools isolate workloads:

┌─────────────────────────────────────────────────┐
│ PostgreSQL │
└─────────────────────────────────────────────────┘
▲ ▲ ▲
│ │ │
┌────┴────┐ ┌────┴────┐ ┌────┴────┐
│ default │ │ jobs │ │analytics│
│ 30 conn │ │ 15 conn │ │ 5 conn │
│ 30s TO │ │ 300s TO │ │ 600s TO │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Queries │ │ Jobs │ │ Reports │
│Mutations│ │ │ │ │
└─────────┘ └─────────┘ └─────────┘

A runaway batch job cannot exhaust connections reserved for user requests.

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.