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]
| Option | Type | Default | Description |
|---|---|---|---|
name | string | "forge-app" | Project identifier |
version | string | "0.1.0" | Project version |
[database]
| Option | Type | Default | Description |
|---|---|---|---|
url | string | - | PostgreSQL connection URL |
pool_size | u32 | 50 | Connection pool size |
pool_timeout | string | "30s" | Pool checkout timeout |
statement_timeout | string | "30s" | Query timeout |
replica_urls | string[] | [] | Read replica URLs |
read_from_replica | bool | false | Route reads to replicas |
min_pool_size | u32 | 0 | Minimum connections kept open in the pool |
test_before_acquire | bool | true | Ping 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:
| Pool | Used By |
|---|---|
default | Queries, mutations, rate limiter, reactor, cluster coordination |
jobs | Job workers, cron runners, daemon processes, workflow executors |
analytics | Available via db.analytics_pool() for user code |
observability | Internal 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]
| Option | Type | Default | Description |
|---|---|---|---|
port | u16 | 9081 | HTTP port |
grpc_port | u16 | 9000 | Inter-node communication port |
max_connections | usize | 4096 | Maximum concurrent connections |
request_timeout | string | "30s" | Request timeout |
cors_enabled | bool | false | Enable CORS handling |
cors_origins | string[] | [] | Allowed CORS origins (use ["*"] for any) |
quiet_paths | string[] | ["/_api/health", "/_api/ready", ...] | Routes excluded from traces, metrics, and logs |
max_body_size | string | "20mb" | Total multipart body cap. Supports b, kb, mb, gb suffixes. Per-mutation max_size overrides this. |
max_file_size | string | "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
| Payload | Default | Configurable |
|---|---|---|
| JSON body | 1 MiB | Not configurable |
| Multipart total | 20 MiB | gateway.max_body_size in forge.toml |
| Per file | 10 MiB | gateway.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.
| Option | Type | Default | Description |
|---|---|---|---|
cert_path | string | - | Path to a PEM-encoded certificate chain file |
key_path | string | - | 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.
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.
| Option | Type | Default | Description |
|---|---|---|---|
max_concurrent | usize | 1000 | Maximum concurrent function executions |
timeout | string | "30s" | Function execution timeout |
memory_limit | usize | 536870912 | Memory 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.
| Option | Type | Default | Description |
|---|---|---|---|
secret_key | string | - | 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]
| Option | Type | Default | Description |
|---|---|---|---|
jwt_algorithm | string | "HS256" | Signing algorithm |
jwt_secret | string | - | Secret for HMAC algorithms (must be 32+ bytes) |
jwks_url | string | - | JWKS endpoint for RSA algorithms |
jwks_cache_ttl | string | "1h" | Public key cache duration |
jwt_issuer | string | - | Expected issuer claim (optional) |
jwt_audience | string | - | Expected audience claim; required when auth is enabled unless audience_required = false |
audience_required | bool | true | When true, jwt_audience must be set whenever auth is configured |
required_claims | string[] | ["exp", "sub"] | JWT claims that must be present in every token |
legacy_secrets | string[] | [] | Old HMAC secrets still accepted for validation during secret rotation |
jwt_leeway | string | "60s" | Clock-skew tolerance for exp/nbf validation |
access_token_ttl | string | "1h" | Access token lifetime |
refresh_token_ttl | string | "30d" | Refresh token lifetime |
session_ttl | string | "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:
| Provider | JWKS URL |
|---|---|
| Firebase | https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com |
| Auth0 | https://YOUR_DOMAIN.auth0.com/.well-known/jwks.json |
| Clerk | https://YOUR_DOMAIN.clerk.accounts.dev/.well-known/jwks.json |
| Supabase | https://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.
| Option | Type | Default | Description |
|---|---|---|---|
sse_max_sessions | usize | 10000 | Maximum concurrent SSE sessions across all clients |
subscription_max_per_session | usize | 50 | Maximum subscriptions per SSE session |
debounce_quiet_window | string | "50ms" | Coalesce DB changes arriving within this window before triggering re-execution |
debounce_max_wait | string | "200ms" | Force a flush after this much time even if changes keep arriving |
max_concurrent_reexecutions | usize | 64 | Bounded parallelism for query re-executions during an invalidation flush |
resync_interval | string | "60s" | Periodic re-evaluation of all active query groups to recover from dropped NOTIFY payloads. Set "0s" to disable. |
postgres_change_buffer_size | usize | 1024 | Broadcast channel buffer for raw change notifications arriving from PostgreSQL |
change_tracking_row_threshold | usize | 200 | Switch 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.
| Option | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Enable MCP endpoint |
path | string | "/mcp" | MCP endpoint path under /_api |
session_ttl | string | "1h" | MCP session lifetime |
allowed_origins | string[] | [] | Allowed Origin values |
require_protocol_version_header | bool | true | Require MCP-Protocol-Version header after initialize |
oauth | bool | false | Enable OAuth 2.1 Authorization Server for MCP clients |
Requires
auth.jwt_secretto be set whenoauthis 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]
| Option | Type | Default | Description |
|---|---|---|---|
max_concurrent_jobs | usize | 50 | Concurrent job limit per worker |
job_timeout | string | "1h" | Default job timeout |
poll_interval | string | "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]
| Option | Type | Default | Description |
|---|---|---|---|
mode | string | "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]
| Option | Type | Default | Description |
|---|---|---|---|
name | string | "default" | Cluster identifier |
discovery | string | "postgres" | Discovery method; the current runtime only implements postgres |
heartbeat_interval | string | "5s" | Heartbeat frequency |
dead_threshold | string | "15s" | Missing heartbeats before dead |
seed_nodes | string[] | [] | Static seed node addresses (for static discovery) |
dns_name | string | - | 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]
| Option | Type | Default | Description |
|---|---|---|---|
roles | string[] | all roles | Roles this node assumes |
worker_capabilities | string[] | ["general"] | Job routing capabilities |
Node Roles
| Role | Responsibility |
|---|---|
gateway | HTTP/gRPC endpoints, SSE subscriptions |
function | Query and mutation execution |
worker | Background job processing |
scheduler | Cron 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.
| Option | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Enable OTLP telemetry export |
otlp_endpoint | string | "http://localhost:4318" | OTLP collector endpoint (HTTP) |
service_name | string | project name | Service name in telemetry data |
enable_traces | bool | true | Export distributed traces |
enable_metrics | bool | true | Export metrics |
enable_logs | bool | true | Export logs via OTLP |
sampling_ratio | f64 | 0.1 | Trace sampling ratio (0.0 to 1.0). Default samples 10% to keep ingest costs bounded under load; bump to 1.0 in development. |
metrics_interval | string | "15s" | Metrics export interval |
log_level | string | "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.requestspan): method, route, status code, duration, trace ID, request ID - Function calls (
fn.executespan): function name, kind (query/mutation), duration - Job execution (
job.executespan): 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.
| Option | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Master switch for the signals pipeline |
auto_capture | bool | true | Auto-record RPC calls as events |
diagnostics | bool | true | Accept frontend error reports |
session_timeout_mins | u32 | 30 | Inactivity timeout before closing a session |
retention_days | u32 | 90 | Drop monthly partitions older than this |
anonymize_ip | bool | false | Store hashed visitor ID instead of raw IP |
batch_size | usize | 100 | Events per batch INSERT to PostgreSQL |
flush_interval_ms | u64 | 5000 | Max milliseconds between event buffer flushes |
excluded_functions | string[] | [] | Function names to skip in auto-capture |
bot_detection | bool | true | Tag 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.