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]
| 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.
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]
| 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 | array 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_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
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.
| 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 |
[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.
| 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 | "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]
| 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 | true | 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. 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.
| Setting | Default | Production 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_enabled | false | CORS is off. Enable it only when a browser client needs cross-origin access, and set explicit origins — never ["*"] on a credentialed API. |
gateway.hsts | false | Off 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 limits | The 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_ip | true | IPs 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.