Skip to main content

Environment Variables

Complete reference of all environment variables used by Forja. Variables are grouped by category.

Core Application

VariableDefaultRequiredDescription
APP__ENVIRONMENTdevelopmentYes (production)Set to the literal string production for production deployments. The string is case-sensitive — prod / Production will leave production-only boot guards disabled.
APP__HOST0.0.0.0NoBind address. Set to 127.0.0.1 to bind loopback only.
APP__PORT8000NoApplication port
APP__LOG_LEVELinfoNotracing-subscriber filter: error, warn, info, debug, trace
APP__ENABLE_TRACINGtrueNoToggle structured tracing output
LOG_FORMAT--NoSet to json to force JSON-formatted logs anywhere
RAILWAY_ENVIRONMENT--NoSet automatically by Railway; its mere presence switches logging to JSON format
PUBLIC_URLhttp://localhost:8000Yes (production)Public origin used to build absolute media URLs and the dashboard CSP. Must include scheme.
PORT--NoSome platforms (Railway) use this to detect the listening port. Forja itself reads APP__PORT; set both to the same value when deploying to platforms that probe PORT.

Database (PostgreSQL)

VariableDefaultRequiredDescription
DATABASE_URL--YesPostgreSQL connection string, e.g., postgres://user:pass@host:5432/dbname
APP__DATABASE__MAX_CONNECTIONS20NoMaximum number of connections in the pool
APP__DATABASE__MIN_CONNECTIONS2NoMinimum number of connections in the pool
APP__DATABASE__CONNECT_TIMEOUT_SECONDS5NoMaximum time (seconds) to wait when acquiring a new connection
APP__DATABASE__IDLE_TIMEOUT_SECONDS600NoTime (seconds) before an idle connection is closed and removed from the pool

Redis & Rate Limiting

VariableDefaultRequiredDescription
REDIS_URLredis://127.0.0.1:6379Yes (production)Redis connection string, e.g., redis://host:6379. Defaults to localhost for development. Effectively required in production: with the default fail-closed rate limiting, the service refuses to start when Redis is unreachable.
RATE_LIMIT_FAIL_MODEclosedNoBehavior when Redis is unavailable: closed rejects with 429 (security, default), open allows requests (availability). In APP__ENVIRONMENT=production the service refuses to start when Redis is unreachable and the mode is closed.
RATE_LIMIT_BURST_PER_SECOND100NoPer-API-key burst ceiling (requests/second), enforced independently of each key's calendar quotas. Raise it for sites whose static builds fan out into highly-parallel request storms. Values ≤ 0 are ignored (default applies).
RESPONSE_CACHE_TTL_SECS60NoTime-to-live (seconds) for the server-side response cache of public reads. Acts as a backstop behind explicit invalidation on content writes; lower it for fresher reads, raise it to absorb more build/crawl load. Caching is disabled entirely when REDIS_URL is unset. See Cache.
TRUST_PROXY_HEADERSfalseNoTrust X-Forwarded-For and X-Real-IP headers for real client IP extraction. Enable only behind a trusted reverse proxy.
PERMISSION_CACHE_TTL_SECS300NoTTL (seconds) for cached permission lookups in Redis (5 minutes by default)
Rate Limiting Behind a Reverse Proxy

If your Forja instance runs behind a reverse proxy (nginx, Caddy, HAProxy) on the same host, you must set TRUST_PROXY_HEADERS=true. Otherwise all requests appear to come from 127.0.0.1 and bypass IP-based rate limiting entirely.

When enabled, Forja reads the client IP from X-Forwarded-For (first entry) or X-Real-IP headers. Make sure your proxy sets these headers and strips any client-provided values to prevent spoofing.

Fail-Closed vs Fail-Open
  • closed (default): If Redis goes down, all rate-limited requests are rejected with HTTP 429. This prioritizes security — no requests can bypass rate limiting. In production the service refuses to start if Redis is unreachable; other environments boot with degraded availability and a warning log.
  • open: If Redis goes down, rate limits are bypassed and requests are allowed through. This prioritizes API availability at the cost of silently unthrottling every endpoint until Redis recovers. Choose this only if you have an independent rate-limiting layer (edge CDN, reverse proxy) or accept the unthrottled window.

The closed default closes a silent bypass: it prevents a Redis outage from letting unlimited traffic through for the duration of the outage.

Audit Log

VariableDefaultRequiredDescription
APP__AUDIT__RETENTION_DAYS365NoSystem-wide default retention period for audit logs (in days). Per-site overrides via audit_log_retention_days in site settings take precedence.

CORS

VariableDefaultRequiredDescription
CORS_ALLOWED_ORIGINSemptyNo (yes, if you have a cross-origin admin SPA or consumer site)Comma-separated list of allowed origins, e.g. https://admin.example.com,https://blog.example.com. Empty means deny all cross-origin browser calls. Setting * in APP__ENVIRONMENT=production refuses to start — the wildcard is a dev-only affordance.
APP__SECURITY__CORS_ALLOWED_ORIGINSemptyNoAlias for CORS_ALLOWED_ORIGINS using the prefixed config convention. Either name works — set one, not both.
CORS in production

Set CORS_ALLOWED_ORIGINS to the exact origins your admin dashboard and any consumer-site frontends use. Leaving it empty denies all cross-origin browser calls (deny-all by default), and production rejects the wildcard (*) at startup, keeping CORS a strong defense-in-depth boundary for authenticated routes.

Authentication -- Clerk

VariableDefaultRequiredDescription
CLERK_SECRET_KEY--NoClerk secret key (sk_live_... or sk_test_...)
CLERK_PUBLISHABLE_KEY--NoClerk publishable key (pk_live_... or pk_test_...). Also used to auto-derive the FAPI domain and the expected issuer.
CLERK_JWKS_URL--NoClerk JWKS endpoint URL. Auto-derived from secret key if not set.
CLERK_FAPI_DOMAIN--NoOverride for the Clerk Frontend API domain used in CSP and issuer derivation. Normally auto-extracted from CLERK_PUBLISHABLE_KEY.
CLERK_EXPECTED_AUDIENCE--Yes (production with Clerk)Expected aud claim on incoming Clerk JWTs. Set this to the audience configured in your Clerk JWT template. Without it, tokens signed by keys in your JWKS are accepted regardless of which Clerk app or template minted them.
CLERK_EXPECTED_ISSUER--Yes (production with Clerk)Expected iss claim on incoming Clerk JWTs, e.g. https://clerk.your-app.com. The boot guard requires this to be set explicitly in production — it does not auto-derive from CLERK_PUBLISHABLE_KEY / CLERK_FAPI_DOMAIN, so set it explicitly. The value is https://<your-clerk-fapi-domain> — the same host that appears in CLERK_JWKS_URL.
SYSTEM_ADMIN_CLERK_IDS--NoComma-separated Clerk user IDs that receive Master permissions
Clerk JWT pinning in production

When CLERK_SECRET_KEY is set and APP__ENVIRONMENT=production, the service refuses to start unless both CLERK_EXPECTED_AUDIENCE and CLERK_EXPECTED_ISSUER are set explicitly. Without pinning, any token signed by keys in the configured JWKS authenticates — including tokens minted for an unrelated Clerk app that happens to share a JWKS endpoint. Non-production environments emit a warning and continue.

The iss value is https://<your-clerk-fapi-domain> — typically the same host that appears in CLERK_JWKS_URL. You can decode it from your publishable key locally:

echo "${CLERK_PUBLISHABLE_KEY#pk_*_}" | base64 -d
# → "clerk.your-app.com$" (drop trailing "$" → use as host in CLERK_EXPECTED_ISSUER)

Storage

VariableDefaultRequiredDescription
STORAGE_PROVIDERlocalNoStorage backend: local or s3
STORAGE_LOCAL_UPLOAD_DIR./uploadsNoDirectory where uploaded files are written (local provider). Relative to the backend working directory.
STORAGE_LOCAL_BASE_URL/uploadsNoURL prefix under which uploaded files are served (local provider)
STORAGE_S3_BUCKET--If S3S3 bucket name
STORAGE_S3_REGION--If S3AWS region (e.g., us-east-1)
STORAGE_S3_PREFIX--NoKey prefix for all uploads (e.g., media/)
STORAGE_S3_ENDPOINT--NoCustom S3 endpoint for non-AWS providers (MinIO, R2, Spaces)
AWS_ACCESS_KEY_ID--If S3AWS access key (standard SDK chain)
AWS_SECRET_ACCESS_KEY--If S3AWS secret key (standard SDK chain)

Preview Service

VariableDefaultRequiredDescription
APP__PREVIEW__BUILT_IN_TEMPLATES--NoComma-separated name/url pairs for built-in preview templates. Format: Name1|URL1,Name2|URL2, e.g. Blog|http://preview:4321.

Webhooks

VariableDefaultRequiredDescription
WEBHOOK_DEAD_RETENTION_DAYS30NoRetention (days) for dead webhook retry rows — deliveries that exhausted all retry attempts. Values ≤ 0 are ignored (default applies).

Application-Level Encryption

VariableDefaultRequiredDescription
ENCRYPTION_KEYdev-only fallbackYes (production)Base64-encoded 32-byte AES-256-GCM key used to encrypt AI provider API keys at rest. Generate with: openssl rand -base64 32. Falls back to a built-in dev key when unset; production refuses this fallback. AI_ENCRYPTION_KEY is accepted as a legacy alias.

Document Encryption

VariableDefaultRequiredDescription
DOCUMENT_ENCRYPTION_KEY--NoBase64-encoded 32-byte key for wrapping document encryption keys. Enables admin recovery of password-protected documents. Generate with: openssl rand -base64 32
DOCUMENT_ENCRYPTION_KEY_OLD--NoPrevious encryption key, used during key rotation. Documents encrypted with this key are lazily re-wrapped on next access.
Key Rotation

To rotate the document encryption key:

  1. Set DOCUMENT_ENCRYPTION_KEY_OLD to the current key value
  2. Set DOCUMENT_ENCRYPTION_KEY to the new key
  3. Deploy — documents are lazily re-wrapped on access
  4. After all documents have been accessed, remove DOCUMENT_ENCRYPTION_KEY_OLD

User downloads are never affected by key rotation — files are encrypted with the user's password, not the server key. The server key only enables admin recovery.

TLS

VariableDefaultRequiredDescription
TLS_CERT_PATH--NoPath to TLS certificate file (PEM format)
TLS_KEY_PATH--NoPath to TLS private key file (PEM format)
tip

If you deploy behind a reverse proxy or a platform like Railway that handles TLS termination at the edge, you do not need to set these variables.

Advanced Security Tuning

Request-size, rate-limit, and anomaly-detection knobs from the APP__SECURITY__* family. The defaults are sensible for most deployments — only change these when you have a concrete reason.

VariableDefaultDescription
APP__SECURITY__MAX_BODY_SIZE10485760 (10 MB)Maximum request body size (bytes)
APP__SECURITY__MAX_JSON_SIZE15728640 (15 MB)Maximum JSON payload size (bytes) — sized to fit base64-encoded uploads up to 10 MB
APP__SECURITY__MAX_FORM_SIZE10485760 (10 MB)Maximum form payload size (bytes)
APP__SECURITY__MAX_FILE_SIZE52428800 (50 MB)Maximum file upload size (bytes)
APP__SECURITY__RATE_LIMIT_PER_SECOND50IP-based rate limit: requests per second
APP__SECURITY__RATE_LIMIT_PER_MINUTE500IP-based rate limit: requests per minute
APP__SECURITY__RATE_LIMIT_BURST20IP-based rate limit: burst allowance
APP__SECURITY__MAX_JSON_DEPTH10Maximum nesting depth accepted in JSON payloads
APP__SECURITY__MAX_ARRAY_ITEMS1000Maximum array length accepted in JSON payloads
APP__SECURITY__REQUEST_TIMEOUT_SECONDS30Per-request timeout (seconds)
APP__SECURITY__ANOMALY_DETECTION_ENABLEDtrueEnable anomaly detection for API keys
APP__SECURITY__ANOMALY_HOURLY_MULTIPLIER5.0Flag keys whose hourly traffic exceeds this multiple of their baseline
APP__SECURITY__ANOMALY_DAILY_MULTIPLIER3.0Flag keys whose daily traffic exceeds this multiple of their baseline
APP__SECURITY__ANOMALY_ERROR_RATE_THRESHOLD0.5Error-rate ratio above which a key is flagged
APP__SECURITY__ANOMALY_MIN_REQUESTS20Minimum requests before anomaly detection evaluates a key
APP__SECURITY__AUTH_RATE_LIMIT_MAX_FAILURES5Failed auth attempts allowed within the window before throttling
APP__SECURITY__AUTH_RATE_LIMIT_WINDOW_SECONDS900Window (seconds) for counting failed auth attempts
APP__SECURITY__AUTH_RATE_LIMIT_BAN_MAX_FAILURES20Failed attempts within the ban window that trigger a temporary ban
APP__SECURITY__AUTH_RATE_LIMIT_BAN_WINDOW_SECONDS3600Window (seconds) for the temporary auth ban

Demo Mode

VariableDefaultRequiredDescription
DEMO_MODEfalseNoWhen true, auto-seeds a demo site on startup and auto-joins newly registered Clerk users to it. Intended for hosted demos; never enable in production.

Imprint (Impressum)

Operator legal details served at runtime by GET /api/v1/imprint and shown on the signed-out Welcome page. Required for any publicly reachable instance under EU law. The required trio (IMPRINT_OPERATOR_NAME, IMPRINT_ADDRESS, IMPRINT_EMAIL) must all be set for the imprint to be considered configured and the footer Imprint link to appear; otherwise the endpoint returns { "configured": false } and the link stays hidden. Never commit real operator details — set these per deployment only.

VariableDefaultRequiredDescription
IMPRINT_OPERATOR_NAME--For imprintLegal name of the operator
IMPRINT_ADDRESS--For imprintPostal address
IMPRINT_EMAIL--For imprintContact email
IMPRINT_PHONE--NoContact phone
IMPRINT_VAT--NoVAT / UID number
IMPRINT_REGISTER--NoCommercial register / Firmenbuch entry
IMPRINT_RESPONSIBLE--NoPerson responsible for content

Test Database

VariableDefaultRequiredDescription
TEST_DATABASE_URLpostgres://forja:forja@localhost:5432/forja_testFor integration testsPostgreSQL connection string for the test database. Requires PostgreSQL 16+ — see Testing.

Admin Dashboard (Vite Build)

These variables are used at build time when compiling the React admin dashboard.

VariableDefaultRequiredDescription
VITE_CLERK_PUBLISHABLE_KEY--NoClerk publishable key for the admin SPA
VITE_API_BASE_URL/api/v1NoBackend API base URL baked into the admin bundle. The default same-origin path works when the backend serves the dashboard; override only for a split deployment.

Frontend Templates

These variables are used by frontend templates (e.g., the Astro blog template) to connect to the backend API.

VariableDefaultRequiredDescription
CMS_API_URL--Yes (for templates)Backend API base URL, e.g., http://localhost:8000/api/v1
CMS_API_KEY--Yes (for templates)API key with at least Read permission
CMS_SITE_ID--Yes (for templates)UUID of the site to display

Example .env File

# Core
APP__ENVIRONMENT=development
APP__HOST=0.0.0.0
APP__PORT=8000
APP__LOG_LEVEL=info
APP__ENABLE_TRACING=true
PUBLIC_URL=http://localhost:8000

# Database
DATABASE_URL=postgres://forja:forja@localhost:5432/forja

# Redis & Rate Limiting
REDIS_URL=redis://localhost:6379
# RATE_LIMIT_FAIL_MODE=closed # "closed" (default) or "open"

# Audit log retention (default: 365 days, per-site overrides in site settings)
# APP__AUDIT__RETENTION_DAYS=365
# TRUST_PROXY_HEADERS=false # set true behind a reverse proxy

# CORS — deny-all by default; list your admin SPA + any consumer site origins.
# Wildcard ("*") is refused in production; keep it scoped to dev.
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000

# Clerk (optional, but all four lines together are required in production)
# CLERK_SECRET_KEY=sk_test_...
# CLERK_PUBLISHABLE_KEY=pk_test_...
# CLERK_EXPECTED_AUDIENCE=my-forja-app
# CLERK_EXPECTED_ISSUER=https://clerk.your-app.com # required in production
# SYSTEM_ADMIN_CLERK_IDS=user_...

# Storage (default: local)
# STORAGE_PROVIDER=s3
# STORAGE_S3_BUCKET=my-bucket
# STORAGE_S3_REGION=us-east-1
# STORAGE_S3_PREFIX=media/

# Application-level encryption (required in production)
# Generate with: openssl rand -base64 32
# ENCRYPTION_KEY=

# Document encryption (enables admin recovery for password-protected docs)
# Generate with: openssl rand -base64 32
# DOCUMENT_ENCRYPTION_KEY=
# DOCUMENT_ENCRYPTION_KEY_OLD= # set during key rotation

# TLS (optional, not needed behind a reverse proxy)
# TLS_CERT_PATH=/path/to/cert.pem
# TLS_KEY_PATH=/path/to/key.pem

# Demo mode (auto-seed demo site, auto-join new users) — never enable in production
# DEMO_MODE=false

# Imprint / Impressum — operator legal details (required trio: NAME + ADDRESS + EMAIL)
# IMPRINT_OPERATOR_NAME=Acme GmbH
# IMPRINT_ADDRESS=Hauptstraße 1, 1010 Wien, Austria
# IMPRINT_EMAIL=legal@acme.example
# IMPRINT_PHONE=+43 1 2345678
# IMPRINT_VAT=ATU12345678
# IMPRINT_REGISTER=FN 123456a, Handelsgericht Wien
# IMPRINT_RESPONSIBLE=Jane Doe