Skip to content

Configuration

Railhook is configured through environment variables in the .env file next to docker-compose.yml. A variable you leave out keeps the default built into the Compose file. After editing .env, run ./railhook up -d. Compose recreates only the services whose settings changed.

Almost every variable is a timeout or a batch size whose default is fine. These are the exceptions: their defaults are development choices that become wrong once the instance is reachable.

Variable Why it matters
APP_ENV production makes the API refuse to start on unsafe configuration instead of running with it
WEBHOOK_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_SALT Encrypt every endpoint secret in the database. Back them up. Never change the salt once data exists
JWT_SECRET Signs sign-in sessions
POSTGRES_PASSWORD, DB_PASSWORD The first creates the database user and the second is what the API and worker connect with. They must be equal
REDIS_PASSWORD Protects the cache
APP_BASE_URL The URL people type. Verification, invite and reset links are built from it
CORS_ALLOWED_ORIGINS Must include the origin the dashboard is served from, or sign-in fails with “Invalid CORS request”
TUNNEL_INGRESS_BASE_URL The public base of source ingress URLs and CLI tunnel URLs. Unset, it follows APP_BASE_URL
EMAIL_ENABLED, EMAIL_FROM, SMTP_* With email off, accounts are created already verified and invites cannot be sent
RAILHOOK_PORT, RAILHOOK_BIND Where the one published port listens
WEBHOOK_TRUSTED_PROXIES The proxies whose X-Forwarded-For is believed. Empty trusts nothing
WEBHOOK_ALLOW_PRIVATE_IPS, WEBHOOK_ALLOWED_HOSTS Keep private addresses blocked. List a named internal host to allow just that one
DB_HOST and COMPOSE_PROFILES To use a managed PostgreSQL, set the DB_* variables and leave embedded-db out of the profiles
PLATFORM_ADMIN_TOKEN Unlocks the operator endpoints. Empty keeps them unreachable
API_REPLICAS 2 makes upgrades seamless, at the cost of roughly another 600 MB
250

Deployment mode (required)

VariableDefaultDescription
DB_MODEembedded

Options: embedded | external

COMPOSE_PROJECT_NAMErailhook

Compose project name. Pinned rather than left to Compose, which otherwise derives it from the name of the directory you cloned into - so the Docker network would be railhook_webhook-network for one person and something else for anyone whose folder is named differently. The monitoring stack joins that network *by name*, so a derived name silently leaves Prometheus with nothing to scrape.

DOCKER_REGISTRYunsetexample: ghcr.io/yourorg/railhook

Docker registry prefix for pre-built images (used by docker-compose.yml). Defaults to this project's own published images if left unset — ghcr.io/vadymkykalo/railhook-{api,worker,ui}. Set it to point at your own mirror or fork:

Application environment

VariableDefaultDescription
APP_ENVdevelopment

Options: development | staging | production

Note: In production mode, placeholder secrets will cause startup failure

APP_BASE_URLhttp://localhost:5173

Public frontend URL (used for emails, shared debug links, etc.). The UI container also reads it at startup as the origin its pages name about themselves: the canonical link, og:url / og:image and the sitemap.

Database - embedded mode (when DB_MODE=embedded)

VariableDefaultDescription
POSTGRES_IMAGEpostgres:16-alpine

Used when running PostgreSQL in Docker

POSTGRES_DBwebhook_platform

Used when running PostgreSQL in Docker

POSTGRES_USERwebhook_user

Used when running PostgreSQL in Docker

POSTGRES_PASSWORDwebhook_dev_pass_12345

Used when running PostgreSQL in Docker

Database - external mode (when DB_MODE=external)

VariableDefaultDescription
DB_HOSTpostgres

Used when connecting to external/managed PostgreSQL For embedded mode, leave as 'postgres' (Docker service name) For external mode, change to your database host

DB_PORT5432

Used when connecting to external/managed PostgreSQL For embedded mode, leave as 'postgres' (Docker service name) For external mode, change to your database host

DB_NAMEwebhook_platform

Used when connecting to external/managed PostgreSQL For embedded mode, leave as 'postgres' (Docker service name) For external mode, change to your database host

DB_USERwebhook_user

Used when connecting to external/managed PostgreSQL For embedded mode, leave as 'postgres' (Docker service name) For external mode, change to your database host

DB_PASSWORDwebhook_dev_pass_12345

Used when connecting to external/managed PostgreSQL For embedded mode, leave as 'postgres' (Docker service name) For external mode, change to your database host

DB_SSL_MODEdisable

Used when connecting to external/managed PostgreSQL For embedded mode, leave as 'postgres' (Docker service name) For external mode, change to your database host

Options: require | verify-full | disable

DB_JDBC_URLempty

Optional: Override full JDBC URL (if set, DB_HOST/PORT/NAME are ignored)

DB_POOL_MAX_SIZE20

Connection pool settings -- API only. The worker has its own WORKER_DB_POOL_MAX_SIZE / WORKER_DB_POOL_MIN_IDLE below: these two used to share this same name, which meant `make up` silently gave the worker the API's smaller pool instead of its own tuned one, no matter what either service's application.yml default claimed. Keep them separately named.

DB_POOL_MIN_IDLE10

Connection pool settings -- API only. The worker has its own WORKER_DB_POOL_MAX_SIZE / WORKER_DB_POOL_MIN_IDLE below: these two used to share this same name, which meant `make up` silently gave the worker the API's smaller pool instead of its own tuned one, no matter what either service's application.yml default claimed. Keep them separately named.

DB_POOL_CONNECTION_TIMEOUT30000

Connection pool settings -- API only. The worker has its own WORKER_DB_POOL_MAX_SIZE / WORKER_DB_POOL_MIN_IDLE below: these two used to share this same name, which meant `make up` silently gave the worker the API's smaller pool instead of its own tuned one, no matter what either service's application.yml default claimed. Keep them separately named.

WORKER_DB_POOL_MAX_SIZE40

Worker connection pool settings. Sized against the number of transactions that can actually be holding a connection at once, not the worker's raw thread count -- the worker runs ~70 nominal concurrent tasks (webhook.outgoing-pool-size=50 + webhook.incoming-pool-size =20 + a handful of scheduler threads), but each delivery attempt's dominant cost is the outbound HTTP call, which holds NO connection (WebhookDeliveryService's transactions are the 3-4 single-row claim/status-transition updates around it, each open for single-digit milliseconds). Retry/incoming-forward claiming is also centralized in one scheduler transaction per cycle (RetrySchedulerService/IncomingForwardRetryScheduler batch-claim, then fan out to the executor pool for the HTTP work), not fanned out across all worker threads at once. 40/20 has margin over the realistic simultaneous-holder count under this shape; if a future change makes worker transactions span the HTTP call (it must not -- see WebhookDeliveryService's own comments) or the executor pool sizes grow substantially, re-check hikari_connections_pending on /actuator/prometheus under load before assuming this still holds.

WORKER_DB_POOL_MIN_IDLE20

Worker connection pool settings. Sized against the number of transactions that can actually be holding a connection at once, not the worker's raw thread count -- the worker runs ~70 nominal concurrent tasks (webhook.outgoing-pool-size=50 + webhook.incoming-pool-size =20 + a handful of scheduler threads), but each delivery attempt's dominant cost is the outbound HTTP call, which holds NO connection (WebhookDeliveryService's transactions are the 3-4 single-row claim/status-transition updates around it, each open for single-digit milliseconds). Retry/incoming-forward claiming is also centralized in one scheduler transaction per cycle (RetrySchedulerService/IncomingForwardRetryScheduler batch-claim, then fan out to the executor pool for the HTTP work), not fanned out across all worker threads at once. 40/20 has margin over the realistic simultaneous-holder count under this shape; if a future change makes worker transactions span the HTTP call (it must not -- see WebhookDeliveryService's own comments) or the executor pool sizes grow substantially, re-check hikari_connections_pending on /actuator/prometheus under load before assuming this still holds.

Redis configuration (for distributed rate limiting & concurrency)

VariableDefaultDescription
REDIS_IMAGEredis:7-alpine
REDIS_HOSTredis
REDIS_PORT6379
REDIS_PASSWORDwebhook_redis_pass
REDIS_MAXMEMORY256mb

Kafka configuration

VariableDefaultDescription
KAFKA_BOOTSTRAP_SERVERSkafka:9092

For embedded mode, use kafka:9092 (Docker internal) For external mode, use your Kafka cluster URL

KAFKA_IMAGEapache/kafka:3.7.0

For embedded mode, use kafka:9092 (Docker internal) For external mode, use your Kafka cluster URL

KAFKA_NUM_PARTITIONS12

Number of partitions for highload (increase for more parallelism)

KAFKA_CONSUMER_GROUP_IDwebhook-worker

Consumer group IDs (prefix with env name on shared Kafka clusters, e.g. prod-webhook-worker)

KAFKA_INCOMING_GROUP_IDincoming-forward-worker

Consumer group IDs (prefix with env name on shared Kafka clusters, e.g. prod-webhook-worker)

KAFKA_DELIVERY_CONCURRENCY6

Worker consumer thread concurrency (should be ≤ KAFKA_NUM_PARTITIONS) Delivery consumer: outgoing webhook dispatch (higher = more throughput)

KAFKA_INCOMING_CONCURRENCY3

Incoming forward consumer: incoming webhook forwarding

KAFKA_PRODUCER_DELIVERY_TIMEOUT_MS120000

Kafka producer settings (api and worker) How long a single send may keep retrying before it is given up on. The producer is idempotent, so retries are unbounded in count and bounded by this instead — a leader election or a rolling broker restart is ridden out rather than failed. A send that does expire costs its outbox row one retry cycle; it is never lost.

KAFKA_PRODUCER_MAX_BLOCK_MS10000

How long send() may block waiting for cluster metadata. The outbox publisher calls it synchronously on a scheduled thread, and the api shares eight of those across every scheduled job it has — so Kafka's own 60s default takes them out one per poll.

KAFKA_CONSUMER_AUTO_OFFSET_RESETearliest

Kafka consumer settings (worker only)

KAFKA_CONSUMER_MAX_RETRIES3

Kafka consumer settings (worker only)

KAFKA_CONSUMER_RETRY_INTERVAL_MS5000

Kafka consumer settings (worker only)

Security (required - must change for production)

VariableDefaultDescription
WEBHOOK_ENCRYPTION_KEYdev_encryption_key_32_chars_min!

Webhook secret encryption key (32+ characters, alphanumeric + special chars) CHANGE THIS IN PRODUCTION!

WEBHOOK_ENCRYPTION_SALTdev_encryption_salt_16_chars

PBKDF2 salt for AES-256 key derivation (16+ characters, unique per deployment) CHANGE THIS IN PRODUCTION!

WEBHOOK_ENCRYPTION_KEYSempty

Multi-key encryption for zero-downtime key rotation (optional) Format: "version:key,version:key" — e.g. "1:old_key,2:new_key" Leave empty to use single WEBHOOK_ENCRYPTION_KEY as version 1

WEBHOOK_ENCRYPTION_KEY_ACTIVE_VERSION0

Active key version for new encryptions (0 = auto-detect highest version)

JWT_SECRETdev_jwt_secret_key_32_chars_minimum

JWT secret for API authentication (32+ characters) CHANGE THIS IN PRODUCTION!

PLATFORM_ADMIN_TOKENempty

Platform-admin operator credential for cluster-wide operator endpoints (/api/v1/admin/**): the back-office — list and search every organization, see its plan, billing status and counts, and suspend or reinstate it — plus encryption key rotation across ALL tenants. Deliberately independent of any org/JWT — org OWNER does NOT grant this. Presented via the X-Platform-Admin-Token header. Leave empty to keep the admin endpoints unreachable entirely (fail closed) until an operator needs them. CHANGE/SET THIS IN PRODUCTION before using the admin endpoints!

API service

VariableDefaultDescription
API_IMAGE_TAGlatest

Worker service

VariableDefaultDescription
WORKER_IMAGE_TAGlatest

Scheduling (api + worker)

VariableDefaultDescription
SCHEDULING_POOL_SIZE8

Size of the shared @Scheduled thread pool. Spring Boot defaults this to 1, which means a single slow cron (e.g. the materialized-view REFRESH, or an unbounded Kafka AdminClient call) blocks every other job on the JVM - including outbox dispatch. Keep at 8 or higher.

UI service

VariableDefaultDescription
RAILHOOK_PORT8080

The one port published to the host: the dashboard's nginx, which also proxies every API path. Set RAILHOOK_BIND=127.0.0.1 and let a reverse proxy in front hold 443 — that is what installing with --domain does, with the built-in Caddy, or with --domain --behind-proxy for a proxy you run. 8080 rather than 80 because this template is what a clone runs, and a developer's port 80 usually belongs to something else already. install.sh writes 80 into the .env it generates, since that is what a browser assumes.

RAILHOOK_BIND0.0.0.0

The one port published to the host: the dashboard's nginx, which also proxies every API path. Set RAILHOOK_BIND=127.0.0.1 and let a reverse proxy in front hold 443 — that is what installing with --domain does, with the built-in Caddy, or with --domain --behind-proxy for a proxy you run. 8080 rather than 80 because this template is what a clone runs, and a developer's port 80 usually belongs to something else already. install.sh writes 80 into the .env it generates, since that is what a browser assumes.

RAILHOOK_DOMAINempty

Domain and ACME contact for the built-in TLS terminator. It only runs under the `tls` Compose profile; COMPOSE_PROFILES=tls turns it on.

ACME_EMAILempty

Domain and ACME contact for the built-in TLS terminator. It only runs under the `tls` Compose profile; COMPOSE_PROFILES=tls turns it on.

UI_IMAGE_TAGlatest

UI_PORT is gone: the dashboard is published on RAILHOOK_PORT above, by both the published-image path and the build overlay. Nothing read UI_PORT.

VITE_API_URLempty

BUILD-TIME ONLY. Vite inlines VITE_* into the bundle, so these are passed as build args (docker-compose.build.yml -> ui.build.args) and take effect only when the UI image is rebuilt. On the pre-built images they cannot be changed at all.

Leave VITE_API_URL empty: the UI's nginx proxies /api/, /ws/tunnel, /hook/ and /ingress/ to the api service, so the browser talks to its own origin and needs no CORS. Set it only when the API is genuinely served from another origin — then add that origin to CORS_ALLOWED_ORIGINS too.

VITE_CSP_EXTRA_CONNECTempty

Extra origins for CSP connect-src (space-separated). Auto-includes VITE_API_URL + localhost in dev. Example for prod: VITE_CSP_EXTRA_CONNECT=https://api.example.com wss://api.example.com

RAILHOOK_CONTACT_DOMAINempty

Domain behind the sales@ and support@ addresses on /contact and in the footer, e.g. example.com.

Not a VITE_ value: the UI container reads it when it starts, so it works on the published images and takes effect when the ui container is recreated (docker compose up -d ui), with no rebuild.

Empty means no mail addresses are offered at all. That is the right default for a self-hosted install: a deployment someone runs for their own company has no sales desk, and an address that reaches nobody is worse than an absent one. The GitHub issues and documentation cards are shown either way, because they are true everywhere. The hosted cloud sets its own domain here.

Outbox publisher

VariableDefaultDescription
OUTBOX_POLL_INTERVAL_MS1000

Poll interval for pending outbox messages (ms)

OUTBOX_RETRY_INTERVAL_MS30000

Retry interval for failed outbox messages (ms)

OUTBOX_CLEANUP_INTERVAL_MS3600000

Cleanup interval for PUBLISHED/DEAD outbox messages (ms, default 1 hour)

OUTBOX_BATCH_SIZE100

Batch size per poll cycle

OUTBOX_MAX_RETRIES5

Max publish retries before moving to DEAD status

OUTBOX_DEAD_RETENTION_DAYS90

Days to retain DEAD messages before deletion (allows manual re-drive)

OUTBOX_SENDING_RECOVERY_SECONDS300

Seconds before recovering stuck SENDING messages back to PENDING (must exceed Kafka delivery.timeout.ms)

OUTBOX_BATCH_SEND_TIMEOUT_SECONDS30

Timeout in seconds for waiting on batch Kafka send completion

OUTBOX_MAX_PER_PROJECT30

Max outbox messages claimed per project per batch (prevents noisy-neighbor in publishing)

OUTBOX_MAX_PER_KEY10

Max outbox messages claimed per Kafka key — that is, per endpoint — per batch. This is the per-endpoint announcement ceiling, and it binds harder than it reads: with OUTBOX_POLL_INTERVAL_MS at 1000 it means ten events per second to any one endpoint, no matter how large OUTBOX_BATCH_SIZE is. Measured at exactly that by load/ingest.js. Raise it if you have one very busy endpoint and few others; leave it if you have many, because what it buys is that one endpoint's burst cannot stall everyone else's.

Data retention policies

VariableDefaultDescription
DATA_RETENTION_ATTEMPTS_DAYS90

Published outbox rows are not tuned here: OutboxPublisherService owns that cleanup and reads OUTBOX_DEAD_RETENTION_DAYS above.

Days to keep delivery attempts (cleanup runs daily at 2 AM) Differential retention: successful attempts (2xx) deleted faster, errors kept longer for debugging

DATA_RETENTION_SUCCESSFUL_ATTEMPTS_DAYS14

Published outbox rows are not tuned here: OutboxPublisherService owns that cleanup and reads OUTBOX_DEAD_RETENTION_DAYS above.

Days to keep delivery attempts (cleanup runs daily at 2 AM) Differential retention: successful attempts (2xx) deleted faster, errors kept longer for debugging

DATA_RETENTION_INCOMING_EVENTS_DAYS30

Published outbox rows are not tuned here: OutboxPublisherService owns that cleanup and reads OUTBOX_DEAD_RETENTION_DAYS above.

Days to keep delivery attempts (cleanup runs daily at 2 AM) Differential retention: successful attempts (2xx) deleted faster, errors kept longer for debugging

DATA_RETENTION_EVENTS_DAYS90

How long an Event — and, through ON DELETE CASCADE, its Deliveries and their Attempts — is kept. Set to -1 to keep everything forever.

This is the only setting that bounds the two largest tables. Per-plan retention (RetentionCleanupScheduler) does nothing while BILLING_ENABLED=false, which is the self-hosted default, so before this existed events and deliveries grew without limit while delivery_attempts partitions were still dropped at DATA_RETENTION_ATTEMPTS_DAYS — losing the detail and keeping the bulk.

90 matches DATA_RETENTION_ATTEMPTS_DAYS on purpose: keeping a delivery longer than the attempts that explain it buys rows nobody can act on.

DATA_RETENTION_MAX_ATTEMPTS_PER_DELIVERY10

Maximum attempts to retain per delivery (keeps most recent N)

DATA_RETENTION_BATCH_SIZE1000

Batch size for cleanup operations

DATA_RETENTION_CRON0 0 2 * * *

Cron schedule for cleanup (daily at 2 AM)

DATA_RETENTION_LIMIT_CRON0 */30 * * * *

Cron schedule for limit enforcement (every 30 minutes)

DATA_RETENTION_BURST_CLEANUP_CRON0 0 */4 * * *

Burst cleanup cron — catches daytime error spikes (every 4 hours)

DATA_RETENTION_TABLE_METRICS_INTERVAL_MS900000

How often to refresh table size gauges (ms, default 15 min)

Partition maintenance

VariableDefaultDescription
PARTITION_MAINTENANCE_ENABLEDtrue

(delivery_attempts, tunnel_request_log — see V052/V053 migrations and PartitionMaintenanceService). Creates partitions ahead of need and drops ones fully past the DATA_RETENTION_* days above (O(1) DROP TABLE instead of DELETE). Safe to leave at the defaults for self-hosters.

PARTITION_MAINTENANCE_DELIVERY_ATTEMPTS_LOOKAHEAD_MONTHS3

(delivery_attempts, tunnel_request_log — see V052/V053 migrations and PartitionMaintenanceService). Creates partitions ahead of need and drops ones fully past the DATA_RETENTION_* days above (O(1) DROP TABLE instead of DELETE). Safe to leave at the defaults for self-hosters.

PARTITION_MAINTENANCE_TUNNEL_LOG_LOOKAHEAD_WEEKS3

(delivery_attempts, tunnel_request_log — see V052/V053 migrations and PartitionMaintenanceService). Creates partitions ahead of need and drops ones fully past the DATA_RETENTION_* days above (O(1) DROP TABLE instead of DELETE). Safe to leave at the defaults for self-hosters.

PARTITION_MAINTENANCE_CRON0 45 1 * * *

(delivery_attempts, tunnel_request_log — see V052/V053 migrations and PartitionMaintenanceService). Creates partitions ahead of need and drops ones fully past the DATA_RETENTION_* days above (O(1) DROP TABLE instead of DELETE). Safe to leave at the defaults for self-hosters.

Rate limiting

VariableDefaultDescription
GLOBAL_RATE_LIMIT_PER_SECOND5000

Global platform rate limit (safety net, per instance, requests/sec)

ORG_RATE_LIMIT_ENABLEDfalse

One organization's share of the dashboard/API traffic, separate from the platform-wide limit above. Off by default and meant for a shared installation: with a single global bucket, one tenant looping over their deliveries spends everyone's budget and the rest get 429s for something they did not do. On a self-hosted install every tenant is yours, so this is a Redis round trip per request buying nothing — leave it off.

ORG_RATE_LIMIT_PER_SECOND200

One organization's share of the dashboard/API traffic, separate from the platform-wide limit above. Off by default and meant for a shared installation: with a single global bucket, one tenant looping over their deliveries spends everyone's budget and the rest get 429s for something they did not do. On a self-hosted install every tenant is yours, so this is a Redis round trip per request buying nothing — leave it off.

GLOBAL_RATE_LIMIT_ENABLEDtrue

One organization's share of the dashboard/API traffic, separate from the platform-wide limit above. Off by default and meant for a shared installation: with a single global bucket, one tenant looping over their deliveries spends everyone's budget and the rest get 429s for something they did not do. On a self-hosted install every tenant is yours, so this is a Redis round trip per request buying nothing — leave it off.

EVENT_INGESTION_RATE_LIMIT_PER_SECOND100

Events per second per project (token bucket)

AUTH_RATE_LIMIT_LOGIN_PER_MINUTE10

Auth rate limiting (per IP and per email, per minute)

AUTH_RATE_LIMIT_REGISTER_PER_MINUTE5

Auth rate limiting (per IP and per email, per minute)

Password authentication

VariableDefaultDescription
AUTH_BCRYPT_STRENGTH12

BCrypt work factor for stored password hashes. A doubling scale: each step is twice the work for an attacker grinding a leaked users table, and twice the work for one login. 12 is roughly a quarter-second on a typical server, paid once per sign-in. BCrypt records the cost inside each hash, so raising or lowering this never invalidates hashes already stored -- existing passwords keep verifying at whatever cost they were written with, and are rewritten at the new one the next time they change. Range 4-31; the API refuses to start outside it.

AUTH_LOCKOUT_ENABLEDtrue

Account lockout after consecutive failed sign-ins. Rate limiting bounds how FAST an account can be guessed at; this bounds HOW MANY. It is held in Postgres rather than Redis on purpose: the auth rate limiter falls back to a per-instance in-memory bucket when Redis is down, which multiplies its real ceiling by the replica count, and a lockout with that property is not one.

Every lockout expires by itself and a password reset clears it immediately, so locking a known email address costs its owner minutes, not access -- see AccountLockoutService for the full reasoning. Set AUTH_LOCKOUT_ENABLED=false only if something in front of the API already bounds attempts per account.

AUTH_LOCKOUT_THRESHOLD5

Consecutive failures before the account locks.

AUTH_LOCKOUT_INITIAL_SECONDS60

First lockout, in seconds. Doubles with each further failure.

AUTH_LOCKOUT_MAX_SECONDS900

Cap on the doubling. Beyond about this, a longer window stops helping a defender and only helps somebody locking a stranger out.

AUTH_LOCKOUT_FAILURE_WINDOW_MINUTES60

Failures older than this are not "consecutive" any more and are forgotten, so occasional typos months apart never add up to a lockout.

AUTH_SESSION_CLEANUP_INTERVAL_MS3600000

How often expired user_sessions rows are purged. Housekeeping only: an expired session cannot authenticate anything either way.

Webhook delivery

VariableDefaultDescription
WEBHOOK_MAX_PAYLOAD_SIZE_BYTES262144

Maximum event payload size in bytes (default 256KB)

WEBHOOK_MAX_FANOUT_PER_EVENT100

Max deliveries per single event (fanout limit — prevents queue flood)

WEBHOOK_PAYLOAD_COMPRESSION_THRESHOLD_BYTES1024

Payload compression threshold - payloads larger than this will be gzip compressed (default 1KB) Reduces DB storage significantly for large payloads

ORGANIZATION_SUSPENSION_CACHE_TTL_SECONDS60

How long a suspension decision is cached before a node re-reads it. Suspension is checked on the write path of every request, ingest included, so this is what keeps that from being a row read per event. An operator's suspend or reinstate takes effect immediately on the node that served it and within this window on the others.

WEBHOOK_PROJECT_RATE_LIMIT_PER_SECOND50

Per-project delivery rate limit (deliveries/sec) — prevents noisy-neighbor.

Not the plan's rate limit. A plan's rateLimitPerSecond caps what a customer may send *in* and is enforced at ingest; this caps what one project may take *out* of a shared worker pool, so its right value follows from WEBHOOK_OUTGOING_POOL_SIZE rather than from what anyone paid. Raising it for a large tenant lets that tenant starve the others.

WEBHOOK_ENDPOINT_VERIFICATION_REQUIREDfalse

Require endpoint verification before allowing deliveries (default: false) Set to true to enforce proof-of-ownership for all new endpoints

WEBHOOK_INGRESS_REPLAY_WINDOW_MINUTES5

How long a received signature is remembered, so the same webhook is not processed twice. For a provider whose signature carries a timestamp this is duplicate suppression and five minutes is generous. For a source verified with a raw-hex generic HMAC it is the only bound on replay: that shape signs the body alone, so a captured request stays verifiable for as long as the secret lives and nothing else stops it being sent again. Raise it for such a source, at the cost of Redis holding one key per signature for the window.

WEBHOOK_ALLOW_PRIVATE_IPSfalse

Allow webhooks to private IPs. Off by default (P0-14c) — a self-hoster who copies .env.dist -> .env and runs `make up` should not silently expose an SSRF hole (e.g. an endpoint pointed at http://169.254.169.254/ reading cloud metadata). Set to true only if you specifically need to forward to internal/private targets in local development, and never in production (ProductionSafetyValidator enforces this at startup when APP_ENV=production).

WEBHOOK_MAX_CONCURRENT_PER_ENDPOINT5

Max concurrent deliveries per endpoint (prevents endpoint overload)

WEBHOOK_MAX_CONCURRENT_PER_TENANT20

The most attempts one ORGANIZATION may have in flight at once, across every endpoint it owns. The per-endpoint cap above bounds one receiver to a slice of WEBHOOK_OUTGOING_POOL_SIZE; on its own that does nothing about a tenant with many slow receivers, because each stays inside its own slice and their sum is the whole worker — at which point no other organization is delivered to at all.

A permit is held only while a request is actually in flight, so healthy traffic never reaches this: fast endpoints hand permits straight back. It bites exactly when calls are slow, which is when it should.

Sized against WEBHOOK_OUTGOING_POOL_SIZE: 20 of 50 lets a single tenant use most of an idle worker while leaving room for two more to be busy at the same time. Raise it if you run one large tenant; lower it if you run many small ones.

WEBHOOK_CONCURRENCY_PERMIT_LEASE_SECONDS90

Lease time (seconds) on a concurrency permit — bounds how long a crashed/stuck delivery can hold a permit before Redis reclaims it. Must exceed the max per-delivery HTTP timeout (60s, see clampTimeout) with margin for pre-request work (decrypt, mTLS setup, transform).

WEBHOOK_OUTGOING_POOL_SIZE50

Outgoing webhook delivery pool (isolated from incoming forwards)

WEBHOOK_INCOMING_POOL_SIZE20

Incoming forward pool (isolated from outgoing deliveries)

WEBHOOK_ASYNC_SHUTDOWN_TIMEOUT_SECONDS60

Graceful shutdown timeout for in-flight deliveries (seconds)

CLIENT_ERROR_REPORTING_ENABLEDtrue

Dashboard error reporting.

When the dashboard hits an error it cannot recover from, the browser posts it back here and it is written to this installation's own logs — the same logs as everything else, so with APP_ENV=production it arrives as JSON and lands in Loki beside the rest. Nothing is sent to any third party; there is no account to create and no DSN to configure.

Turn it off and the endpoint still answers, it just keeps nothing. The per-user cap is what stops a component that throws on every render from writing a log line per frame.

CLIENT_ERROR_REPORTS_PER_USER_PER_MINUTE20

Dashboard error reporting.

When the dashboard hits an error it cannot recover from, the browser posts it back here and it is written to this installation's own logs — the same logs as everything else, so with APP_ENV=production it arrives as JSON and lands in Loki beside the rest. Nothing is sent to any third party; there is no account to create and no DSN to configure.

Turn it off and the endpoint still answers, it just keeps nothing. The per-user cap is what stops a component that throws on every render from writing a log line per frame.

WEBHOOK_MAX_RESPONSE_BODY_BYTES1048576

How much of a receiver's response body to buffer, in bytes. Anything past this is dropped and the Attempt is decided by the status code alone — a receiver that accepts the webhook and answers 2xx with a huge body is a successful delivery, not a failed one. Only the first 10 KiB is ever stored, so raising this buys nothing but memory per in-flight attempt.

WEBHOOK_POOL_MAX_CONNECTIONS200

HTTP connection pool for outgoing webhook delivery (shared by delivery + incoming forward)

WEBHOOK_POOL_PENDING_ACQUIRE_TIMEOUT_SECONDS10

HTTP connection pool for outgoing webhook delivery (shared by delivery + incoming forward)

WEBHOOK_POOL_MAX_IDLE_TIME_SECONDS60

HTTP connection pool for outgoing webhook delivery (shared by delivery + incoming forward)

WEBHOOK_ALLOWED_HOSTSempty

Hosts exempted from the private-IP check, comma-separated. Empty is the default and means no exemption - not "all allowed": a public host needs no entry here, and every other rule (scheme, cloud-metadata denylist) applies to a listed host regardless.

This is the supported way to forward to one named internal service while leaving WEBHOOK_ALLOW_PRIVATE_IPS=false everywhere else. Entries are matched against the host as written in the endpoint URL, so a name, not the address it resolves to.

WEBHOOK_TRUSTED_PROXIESempty

Trusted reverse-proxy peers (comma-separated IPs and/or CIDR ranges), e.g.

  WEBHOOK_TRUSTED_PROXIES=10.0.0.5,172.20.0.0/16

X-Forwarded-For / X-Real-IP are honoured ONLY when the direct TCP connection to the API comes from one of these addresses; the header is otherwise ignored and the real socket peer address is used. This governs the IP used for auth rate limiting (login/register/refresh/reset-password), audit logs, and incoming-webhook source IP checks.

Empty (default) trusts nothing — safe whenever the API is reachable directly, as in the base docker-compose.yml here. If you put a reverse proxy, load balancer, or ingress controller in front (as the UI's nginx does), set this to that proxy's address/CIDR, or every request will be bucketed under the proxy's own IP instead of the real client's — getting this wrong in either direction either defeats rate limiting (trusting an untrusted peer) or breaks it for everyone behind one shared IP (trusting nothing when you should).

Incoming webhooks

VariableDefaultDescription
WEBHOOK_INGRESS_BASE_URLhttp://localhost:8080

Base URL for ingress endpoint URLs shown in admin UI

WEBHOOK_INCOMING_MAX_PAYLOAD_SIZE_BYTES524288

Maximum incoming payload size in bytes (default 512KB)

WEBHOOK_INCOMING_RATE_LIMIT_PER_SECOND100

Per-source ingress rate limit for a source that sets none of its own. Without it the only backstop on the public /ingress/{token} path was the single platform-wide bucket (GLOBAL_RATE_LIMIT_PER_SECOND), shared by every tenant. Kept equal to EVENT_INGESTION_RATE_LIMIT_PER_SECOND: a received webhook costs what an ingested event costs. Set to 0 to disable the default (a source's own limit still applies).

INCOMING_FORWARD_RETRY_POLL_INTERVAL_MS10000

Incoming forward retry scheduler

INCOMING_FORWARD_RETRY_BATCH_SIZE50

Incoming forward retry scheduler

INCOMING_FORWARD_RETRY_MAX_PER_DEST10

Incoming forward retry scheduler

INCOMING_FORWARD_RETRY_HIGH_WATERMARK3000

Queue depth threshold for incoming forward retry governor

DLQ_MONITORING_INTERVAL_MS60000

DLQ monitoring interval (ms)

DLQ_MONITORING_ADMIN_CLIENT_TIMEOUT_SECONDS10

Timeout for the Kafka AdminClient calls DLQ monitoring makes each cycle (seconds)

Ordering (FIFO delivery per endpoint)

VariableDefaultDescription
ORDERING_GAP_TIMEOUT_SECONDS60

Gap timeout in seconds (proceed without previous sequence after this time)

ORDERING_BUFFER_RESCHEDULE_DELAY_SECONDS5

Fallback poll interval (seconds) for a delivery parked behind an outstanding sequence. The fast path republishes a buffered delivery the moment the sequence ahead of it completes; this only covers deliveries that reach the buffer after that trigger fired. Lower drains an out-of-order burst faster at the cost of more DB polling.

ORDERING_DELIVERED_SEQ_TTL_HOURS24

TTL for delivered sequence tracking in Redis (hours)

ORDERING_BUFFER_TTL_MINUTES10

TTL for ordering buffer in Redis (minutes)

ORDERING_BUFFER_GAUGE_RESYNC_MS30000

How often webhook_ordering_buffer_size resyncs against Redis via a key-pattern SCAN (ms). See OrderingBufferService#resyncBufferSizeGauge -- this replaced a per-call gauge registration that only ever reported the first endpoint's buffer, forever.

ORDERING_STRANDED_SEQUENCE_AFTER_SECONDS120

An ordered Delivery gets its sequence number just after the ingest transaction commits, not inside it: the number comes from Redis, and a Delivery the customer was already told about must not be undone because a counter was unreachable. A process that dies in that window leaves the row with no number for ever, and the worker then delivers it unordered without saying so. The reconciler sweeps those and backfills them.

The delay is what keeps the sweep off rows whose backfill is still in flight. Raise it if your ingest path is slow; lowering it below a few seconds makes the sweep race the ingest. Watch webhook_sequence_stranded_total — anything but zero means ingest processes are dying mid-request.

ORDERING_STRANDED_SEQUENCE_BATCH_SIZE500

An ordered Delivery gets its sequence number just after the ingest transaction commits, not inside it: the number comes from Redis, and a Delivery the customer was already told about must not be undone because a counter was unreachable. A process that dies in that window leaves the row with no number for ever, and the worker then delivers it unordered without saying so. The reconciler sweeps those and backfills them.

The delay is what keeps the sweep off rows whose backfill is still in flight. Raise it if your ingest path is slow; lowering it below a few seconds makes the sweep race the ingest. Watch webhook_sequence_stranded_total — anything but zero means ingest processes are dying mid-request.

ORDERING_SEQUENCE_RECONCILIATION_LOOKBACK_HOURS48

How far back the API's periodic sequence/cursor reconciliation job looks for recently-active ordering-enabled endpoints (hours)

ORDERING_SEQUENCE_RECONCILIATION_INTERVAL_MS900000

How often the API's sequence/cursor reconciliation job runs (milliseconds)

Event time machine (replay)

VariableDefaultDescription
REPLAY_BATCH_SIZE200

Batch size for cursor-based event scanning (tune for DB load)

REPLAY_BATCH_DELAY_MS50

Delay in ms between batches (backpressure against Kafka/DB)

REPLAY_MAX_EVENTS_PER_SESSION500000

Maximum events per single replay session

Retry scheduler

VariableDefaultDescription
RETRY_SCHEDULER_BATCH_SIZE100

Batch size for processing pending retries (governor ceiling — actual batch adapts dynamically)

RETRY_SCHEDULER_MAX_PER_ENDPOINT10

Max retries claimed per endpoint per poll (fair scheduling)

RETRY_SCHEDULER_MAX_PER_PROJECT30

Max retries claimed per project per poll (prevents noisy-neighbor in retry queue)

RETRY_SCHEDULER_HIGH_WATERMARK5000

Queue depth threshold — when pending retries exceed this, governor caps batch size to prevent retry storm after outages (AIMD congestion control)

RETRY_SCHEDULER_SEND_TIMEOUT_SECONDS30

Timeout in seconds for Kafka send confirmation

RETRY_SCHEDULER_RESCHEDULE_DELAY_SECONDS60

Delay in seconds before rescheduling failed sends

RETRY_SCHEDULER_POLL_INTERVAL_MS10000

Poll interval in milliseconds. This is the baseline the adaptive governor scales from, not a fixed period: it polls at this rate under light load, backs off to 3x when the retry queue is empty, and tightens to 1/2x and 1/5x as the backlog grows.

Stuck delivery recovery

VariableDefaultDescription
STUCK_DELIVERY_THRESHOLD_MINUTES5

Threshold in minutes - deliveries stuck in PROCESSING longer than this will be reset

STUCK_DELIVERY_STRANDED_PENDING_THRESHOLD_MINUTES60

Threshold in minutes - PENDING deliveries with next_retry_at wiped but never claimed into PROCESSING (belt-and-braces recovery) will be reset. Keep well above the normal dispatch window so freshly ingested deliveries are never swept.

STUCK_DELIVERY_CHECK_INTERVAL_MS60000

Check interval in milliseconds

Stuck forward recovery (incoming forward attempts)

VariableDefaultDescription
STUCK_FORWARD_THRESHOLD_MINUTES5

Threshold in minutes - forward attempts stuck in PROCESSING longer than this will be reset

STUCK_FORWARD_STRANDED_PENDING_THRESHOLD_MINUTES60

Threshold in minutes - forward attempts left PENDING with next_retry_at never set (the dispatch or replay Kafka message was lost) will be handed back to the retry scheduler. Keep well above the normal dispatch window so freshly received webhooks are never swept.

STUCK_FORWARD_CHECK_INTERVAL_MS60000

Check interval in milliseconds

Circuit breaker (Redis-backed, shared across worker pods)

VariableDefaultDescription
CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD50

Failure rate percentage threshold to trip the circuit (0-100)

CIRCUIT_BREAKER_MINIMUM_CALLS5

Minimum number of calls before evaluating failure rate

CIRCUIT_BREAKER_WAIT_DURATION_SECONDS30

How long the circuit stays OPEN before allowing probes (seconds)

CIRCUIT_BREAKER_WINDOW_TTL_SECONDS120

TTL for failure/call counters sliding window (seconds)

CIRCUIT_BREAKER_SLOW_CALL_THRESHOLD_MS10000

Latency threshold to classify a call as "slow" (ms). Endpoints consistently responding slower than this will trip the circuit even if they return 2xx.

CIRCUIT_BREAKER_SLOW_CALL_RATE_THRESHOLD80

Percentage of slow calls to trip the circuit (0-100)

Stale delivery escalation (hard-cap auto-DLQ)

VariableDefaultDescription
DELIVERY_ESCALATION_HARD_CAP_HOURS96

Deliveries stuck in PENDING longer than this are escalated to DLQ (hours), regardless of attempt_count/max_attempts. Must stay >= the worst-case span of BOTH default ladders (see RetryLadderDefaults) or the last retry tiers never get a chance to fire — RetrySchedulerService fails startup if they don't agree. The outgoing ladder's worst case with full jitter is ~83h; 96h leaves headroom.

DELIVERY_ESCALATION_BATCH_SIZE100

Batch size per escalation cycle

Stale forward escalation (hard-cap auto-DLQ, Incoming)

VariableDefaultDescription
FORWARD_ESCALATION_HARD_CAP_HOURS24

The Incoming counterpart of DELIVERY_ESCALATION_HARD_CAP_HOURS. Forwards outstanding longer than this — measured from when the webhook arrived, not from the newest attempt row — are escalated to DLQ regardless of attempt count. Before this existed the Incoming direction had only a stuck-PROCESSING reset and never gave up on anything.

Deliberately its own cap rather than reusing the delivery one: the incoming ladder is shorter by design (5 attempts to 6h, ~11h worst case with full jitter), so the 96h delivery cap would leave a dead Forward sitting for three days past ladder exhaustion. RetrySchedulerService fails startup if the incoming ladder does not fit inside this.

FORWARD_ESCALATION_BATCH_SIZE100

The Incoming counterpart of DELIVERY_ESCALATION_HARD_CAP_HOURS. Forwards outstanding longer than this — measured from when the webhook arrived, not from the newest attempt row — are escalated to DLQ regardless of attempt count. Before this existed the Incoming direction had only a stuck-PROCESSING reset and never gave up on anything.

Deliberately its own cap rather than reusing the delivery one: the incoming ladder is shorter by design (5 attempts to 6h, ~11h worst case with full jitter), so the 96h delivery cap would leave a dead Forward sitting for three days past ladder exhaustion. RetrySchedulerService fails startup if the incoming ladder does not fit inside this.

FORWARD_ESCALATION_INTERVAL_MS300000

The Incoming counterpart of DELIVERY_ESCALATION_HARD_CAP_HOURS. Forwards outstanding longer than this — measured from when the webhook arrived, not from the newest attempt row — are escalated to DLQ regardless of attempt count. Before this existed the Incoming direction had only a stuck-PROCESSING reset and never gave up on anything.

Deliberately its own cap rather than reusing the delivery one: the incoming ladder is shorter by design (5 attempts to 6h, ~11h worst case with full jitter), so the 96h delivery cap would leave a dead Forward sitting for three days past ladder exhaustion. RetrySchedulerService fails startup if the incoming ladder does not fit inside this.

DELIVERY_ESCALATION_INTERVAL_MS300000

How often to check for stale deliveries (ms, default 5 min)

Queue depth metrics

VariableDefaultDescription
QUEUE_DEPTH_METRICS_INTERVAL_MS900000

How often to refresh queue depth gauges (ms) — default 15 min

QUEUE_DEPTH_METRICS_RETENTION_DAYS30

Only count records created within this many days (limits scan scope)

Test endpoints (Request Bin)

VariableDefaultDescription
TEST_ENDPOINT_BASE_URLhttp://api:8080

Base URL for generated test endpoint URLs

TEST_ENDPOINT_MAX_PER_PROJECT10

Maximum test endpoints per project

TEST_ENDPOINT_MAX_TTL_HOURS72

Maximum TTL in hours for test endpoints

TEST_ENDPOINT_CLEANUP_INTERVAL_MS3600000

Cleanup interval for expired endpoints (ms)

Rules engine

VariableDefaultDescription
RULES_CACHE_REFRESH_MS30000

Cache refresh interval for compiled rules (ms, default 30s)

RULES_EXECUTION_LOG_RETENTION_DAYS7

Retention for rule execution logs (days, default 7)

Workflow engine

VariableDefaultDescription
WORKFLOW_POOL_CORE_SIZE4

Thread pool for async workflow execution

WORKFLOW_POOL_MAX_SIZE8

Thread pool for async workflow execution

WORKFLOW_POOL_QUEUE_CAPACITY50

Thread pool for async workflow execution

WORKFLOW_MAX_DURATION_SECONDS600

Maximum total execution time for a single workflow run (seconds)

WORKFLOW_MAX_RECURSION_DEPTH3

Maximum recursion depth for workflow chains (workflow→CreateEvent→workflow)

WORKFLOW_STUCK_THRESHOLD_MINUTES15

Stuck execution recovery: threshold (minutes) and check interval (ms)

WORKFLOW_RECOVERY_INTERVAL_MS120000

Stuck execution recovery: threshold (minutes) and check interval (ms)

WORKFLOW_RESUME_INTERVAL_MS5000

A delay node suspends its execution rather than sleeping on a pool thread, and this is how often suspended executions are checked for being due. It is the resolution of a delay, so a delay is always "at least N seconds", never exactly N — which is what a delay in a workflow means anyway.

WORKFLOW_RESUME_BATCH_SIZE50

How many due executions one tick resumes. Matters more than the interval: a burst all becoming due in the same second must not hand the workflow pool more than it can take, so the remainder spills to the next tick in due order.

WORKFLOW_NODE_TIMEOUT_DEFAULT_SECONDS30

Per-node timeout limits (seconds)

WORKFLOW_NODE_TIMEOUT_HTTP_SECONDS60

Per-node timeout limits (seconds)

WORKFLOW_NODE_TIMEOUT_SLACK_SECONDS60

Per-node timeout limits (seconds)

WORKFLOW_NODE_TIMEOUT_CREATE_EVENT_SECONDS30

Per-node timeout limits (seconds)

WORKFLOW_NODE_TIMEOUT_POOL_SIZE16

Per-node timeout limits (seconds)

WORKFLOW_SHUTDOWN_AWAIT_SECONDS30

Graceful shutdown: how long to wait for in-flight workflows (seconds)

WORKFLOW_TRIGGER_OUTBOX_POLL_INTERVAL_MS2000

Workflow trigger outbox (durable at-least-once trigger via transactional outbox)

WORKFLOW_TRIGGER_OUTBOX_BATCH_SIZE50

Workflow trigger outbox (durable at-least-once trigger via transactional outbox)

WORKFLOW_TRIGGER_OUTBOX_MAX_ATTEMPTS3

Workflow trigger outbox (durable at-least-once trigger via transactional outbox)

WORKFLOW_TRIGGER_OUTBOX_MAX_PER_PROJECT5

Max workflow triggers claimed per project per batch (prevents one project from starving others)

WORKFLOW_TRIGGER_OUTBOX_MAX_CONCURRENT_PER_PROJECT3

Max concurrent workflow executions per project (prevents executor thread exhaustion)

WORKFLOW_TRIGGER_OUTBOX_STALLED_AFTER_MINUTES15

A claimed row goes PROCESSING and only its own poller can move it on. If that poller dies mid-workflow, nothing else picks the row up: the batch claim reads PENDING and the cleanup job deletes DONE. This sweep returns rows abandoned in PROCESSING to the queue. Keep the threshold above the longest workflow you actually run, or a live one gets reclaimed underneath itself and executes twice.

WORKFLOW_TRIGGER_OUTBOX_STALLED_SWEEP_MS300000

A claimed row goes PROCESSING and only its own poller can move it on. If that poller dies mid-workflow, nothing else picks the row up: the batch claim reads PENDING and the cleanup job deletes DONE. This sweep returns rows abandoned in PROCESSING to the queue. Keep the threshold above the longest workflow you actually run, or a live one gets reclaimed underneath itself and executes twice.

Device authorization

VariableDefaultDescription
DEVICE_AUTH_CLEANUP_INTERVAL_MS300000

Cleanup interval for expired device auth codes (ms)

Email verification

VariableDefaultDescription
EMAIL_ENABLEDfalse

Enable sending real emails (false = log verification URL to console).

This also decides whether email verification is enforced at all. With it off, a new account is marked verified the moment it registers: there is no channel that could carry a token to the address, and a gate with no key is just a locked-out user. With it on, registration leaves the account unverified and the API refuses every write from it until the address is confirmed -- reads still work, so the dashboard can say so.

Turn it on for any deployment where registration is open to strangers. It is the difference between an address someone owns and an address someone typed.

EMAIL_FROM[email protected]

From address for verification emails.

Set this to a domain you actually own before turning EMAIL_ENABLED on. Mail from a domain you do not control fails SPF and DKIM at the receiver, so verification mail lands in spam or is rejected outright — and the user is left staring at a screen telling them to check an inbox nothing will reach. example.com is a placeholder, not a default that works.

SMTP_HOSTlocalhost

SMTP settings (only used when EMAIL_ENABLED=true)

SMTP_PORT1025

SMTP settings (only used when EMAIL_ENABLED=true)

SMTP_USERNAMEempty

SMTP settings (only used when EMAIL_ENABLED=true)

SMTP_PASSWORDempty

SMTP settings (only used when EMAIL_ENABLED=true)

SMTP_AUTHfalse

SMTP settings (only used when EMAIL_ENABLED=true)

SMTP_STARTTLSfalse

SMTP settings (only used when EMAIL_ENABLED=true)

Alert notifications

VariableDefaultDescription
ALERTS_NOTIFICATIONS_ENABLEDfalse

Enable sending real alert notifications via Slack/Webhook/Email (false = dry-run mode, logs notification details to console)

ALERTS_EVALUATION_CRON0 * * * * *

How often alert rules are evaluated. Every rule is measured over its own window, so this is the resolution of "how late can an alert be", not the window itself. A rule fires once per crossing and stays quiet until its event is resolved, so a faster cadence does not mean more notifications.

Entitlements (limits when billing is off)

VariableDefaultDescription
ENTITLEMENT_DEFAULT_RATE_LIMIT100

Default entitlement limits when BILLING_ENABLED=false (self-hosted sane defaults) Prevents Integer.MAX_VALUE (effectively no limit) which can cause cascading failures

ENTITLEMENT_DEFAULT_MAX_FANOUT50

Default entitlement limits when BILLING_ENABLED=false (self-hosted sane defaults) Prevents Integer.MAX_VALUE (effectively no limit) which can cause cascading failures

Captcha (registration)

VariableDefaultDescription
CAPTCHA_SECRET_KEYunset

Empty is the default and means no challenge at all: a self-hosted registration page has nobody to challenge, and sending its visitors to a third party to prove otherwise would be a worse default than accepting them.

Set it wherever registration is open to strangers. The auth rate limit is per address, and an address is the one thing a signup farm has plenty of. With APP_ENV=production and BILLING_ENABLED=true the API refuses to start without this.

Cloudflare Turnstile by default; hCaptcha speaks the same siteverify shape, so pointing CAPTCHA_VERIFY_URL at https://api.hcaptcha.com/siteverify is all that differs. Verification fails closed: if the provider is unreachable, registration is refused rather than waved through.

CAPTCHA_VERIFY_URLunsetexample: https://challenges.cloudflare.com/turnstile/v0/siteverify

Empty is the default and means no challenge at all: a self-hosted registration page has nobody to challenge, and sending its visitors to a third party to prove otherwise would be a worse default than accepting them.

Set it wherever registration is open to strangers. The auth rate limit is per address, and an address is the one thing a signup farm has plenty of. With APP_ENV=production and BILLING_ENABLED=true the API refuses to start without this.

Cloudflare Turnstile by default; hCaptcha speaks the same siteverify shape, so pointing CAPTCHA_VERIFY_URL at https://api.hcaptcha.com/siteverify is all that differs. Verification fails closed: if the provider is unreachable, registration is refused rather than waved through.

CAPTCHA_SITE_KEYunset

The dashboard's half. Without a site key the registration page renders no challenge and sends no token, which is what makes the server-side default consistent rather than a submit button that never works.

Read by the UI container when it starts, so it works on the published image: set it and run `docker compose up -d ui`, no rebuild. Turn both halves on together — CAPTCHA_SECRET_KEY on the API with no site key on the page means no token is sent and every registration is refused.

CAPTCHA_SCRIPT_URLunsetexample: https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit

The dashboard's half. Without a site key the registration page renders no challenge and sends no token, which is what makes the server-side default consistent rather than a submit button that never works.

Read by the UI container when it starts, so it works on the published image: set it and run `docker compose up -d ui`, no rebuild. Turn both halves on together — CAPTCHA_SECRET_KEY on the API with no site key on the page means no token is sent and every registration is refused.

Sign in with google

VariableDefaultDescription
GOOGLE_OAUTH_CLIENT_IDunset

"Continue with Google" on the sign-in and registration pages. Off unless both are set, and then the button is not shown and the endpoints answer 404.

One button signs in and signs up: a Google account that matches no Railhook account gets one on the spot, verified by Google, with its own organization named after its Google Workspace domain (or the person, for a personal account). An account that already signed up with a password and the same address gains Google as a second way in.

Create an OAuth client of type "Web application" in Google Cloud Console (APIs & Services → Credentials) and add this authorized redirect URI, exactly, built from APP_BASE_URL:

  <APP_BASE_URL>/api/v1/auth/oauth/google/callback

The consent screen needs the openid, email and profile scopes and nothing else.

GOOGLE_OAUTH_CLIENT_SECRETunset

"Continue with Google" on the sign-in and registration pages. Off unless both are set, and then the button is not shown and the endpoints answer 404.

One button signs in and signs up: a Google account that matches no Railhook account gets one on the spot, verified by Google, with its own organization named after its Google Workspace domain (or the person, for a personal account). An account that already signed up with a password and the same address gains Google as a second way in.

Create an OAuth client of type "Web application" in Google Cloud Console (APIs & Services → Credentials) and add this authorized redirect URI, exactly, built from APP_BASE_URL:

  <APP_BASE_URL>/api/v1/auth/oauth/google/callback

The consent screen needs the openid, email and profile scopes and nothing else.

Billing / plans

VariableDefaultDescription
BILLING_ENABLEDfalse

Enable plan-based quota enforcement (false = self-hosted, all unlimited).

This flag is the whole of what separates a hosted deployment from a self-hosted one: no separate build, no profile, no licence key. With APP_ENV=production, turning it on also requires EMAIL_ENABLED=true and CAPTCHA_SECRET_KEY, and the API refuses to start otherwise -- a quota-limited tier behind unverified addresses and no challenge is a tier anyone can mint accounts on.

BILLING_DEFAULT_PROVIDERnoop

Default billing provider: noop | stripe | wayforpay When BILLING_ENABLED=false, provider is ignored (NoOp used automatically). With BILLING_ENABLED=true and noop the deployment runs the free plan only: its quotas are enforced, the catalog lists no priced plan, and checkout is refused.

STRIPE_SECRET_KEYunsetexample: sk_test_...

Stripe settings (registered automatically when STRIPE_SECRET_KEY is set)

STRIPE_WEBHOOK_SECRETunsetexample: whsec_...

Stripe settings (registered automatically when STRIPE_SECRET_KEY is set)

STRIPE_PRICE_MAPunsetexample: starter_monthly=price_xxx,starter_yearly=price_xxy,pro_monthly=price_yyy,pro_yearly=price_yyz

Format: plan_interval=stripe_price_id (interval: _monthly or _yearly)

BILLING_RECONCILIATION_CRON0 0 */6 * * *

Billing reconciliation — syncs local state with external provider (Stripe only). WayForPay is merchant-initiated so our local state IS the source of truth. Cron: every 6 hours by default.

WAYFORPAY_MERCHANT_ACCOUNTunsetexample: your_merchant_account

WayForPay settings (registered automatically when WAYFORPAY_MERCHANT_ACCOUNT is set)

WAYFORPAY_MERCHANT_SECRETunsetexample: your_merchant_secret_key

WayForPay settings (registered automatically when WAYFORPAY_MERCHANT_ACCOUNT is set)

WAYFORPAY_MERCHANT_DOMAINunsetexample: example.com

WayForPay settings (registered automatically when WAYFORPAY_MERCHANT_ACCOUNT is set)

WAYFORPAY_SERVICE_URLunsetexample: https://example.com/api/v1/billing/webhook/wayforpay

WayForPay settings (registered automatically when WAYFORPAY_MERCHANT_ACCOUNT is set)

WAYFORPAY_PLAN_PRICESunsetexample: starter_monthly=2900,starter_yearly=29000,pro_monthly=9900,pro_yearly=99000

Format: plan_interval=amount_in_cents (interval: _monthly or _yearly)

PRERENDER_LOCALEen

The locale the UI's public pages are rendered into static HTML as, at image build time. There is one set of URLs for two locales — the language is chosen client-side — so this has to be the one the canonical tags and sitemap describe. Build-time only: it has no effect on a running container.

Tunnel (CLI local webhook tunnels)

VariableDefaultDescription
TUNNEL_INGRESS_BASE_URLhttp://localhost:8080

Public base URL for incoming-source and tunnel URLs (handed to providers and the CLI). Left unset, docker-compose.yml uses APP_BASE_URL. Set it only when webhooks reach Railhook on a different host than the dashboard, e.g. https://hooks.example.com

TUNNEL_HEARTBEAT_TIMEOUT_SECONDS120

Heartbeat timeout — tunnel session marked stale after this many seconds without heartbeat

TUNNEL_CLEANUP_INTERVAL_MS60000

Stale session cleanup interval (ms)

TUNNEL_REQUEST_LOG_RETENTION_DAYS7

Tunnel request log retention (days). tunnel_request_log is partitioned weekly, so retention is a DROP TABLE of whole expired partitions by the partition maintenance job (PARTITION_MAINTENANCE_CRON), not a row-by-row delete of its own.

CORS configuration

VariableDefaultDescription
CORS_ALLOWED_ORIGINShttp://localhost:8080,http://localhost:5173

The stack is served on RAILHOOK_PORT, so that origin has to be here: the browser sends `Origin` on same-origin POSTs too, and Spring's allow-list rejects anything it does not list — which made a template-default install answer every login with "Invalid CORS request". 5173 is the Vite dev server for `npm run dev`; nothing in this stack has ever served on 3000.

Observability

VariableDefaultDescription
LOG_LEVELINFO

Log level: TRACE | DEBUG | INFO | WARN | ERROR

SWAGGER_ENABLEDfalse

Enable/disable Swagger UI (set to false in production)

JVM tuning

VariableDefaultDescription
API_JAVA_OPTSunsetexample: -Xms256m -Xmx384m

Extra JVM flags, APPENDED to the tuning baked into the images (G1GC, UseContainerSupport, MaxRAMPercentage=75%, InitialRAMPercentage=50%). They append rather than replace: an explicit -Xmx here still wins over MaxRAMPercentage, but leaving these unset cannot erase the container tuning.

WORKER_JAVA_OPTSunsetexample: -Xms384m -Xmx512m

Extra JVM flags, APPENDED to the tuning baked into the images (G1GC, UseContainerSupport, MaxRAMPercentage=75%, InitialRAMPercentage=50%). They append rather than replace: an explicit -Xmx here still wins over MaxRAMPercentage, but leaving these unset cannot erase the container tuning.

METRICS_ENABLEDtrue

Enable Prometheus metrics export

Resource limits (Docker deploy.resources)

VariableDefaultDescription
POSTGRES_MEMORY_LIMITunsetexample: 1G

Override memory limits per service (used in docker-compose.yml)

KAFKA_MEMORY_LIMITunsetexample: 1G

Override memory limits per service (used in docker-compose.yml)

REDIS_MEMORY_LIMITunsetexample: 512m

Override memory limits per service (used in docker-compose.yml)

API_MEMORY_LIMITunsetexample: 768m

Override memory limits per service (used in docker-compose.yml)

WORKER_MEMORY_LIMITunsetexample: 768m

Override memory limits per service (used in docker-compose.yml)

API_REPLICAS1

How many API containers run at once.

One serves perfectly well, and it is what the smallest supported host has room for. Two is what makes an upgrade seamless: `railhook upgrade` brings the replacement up and waits for it to answer before taking the old one out, so there is no gap. With one there is nothing to carry the traffic meanwhile, and an upgrade costs about twenty seconds of 502 — that is the JVM starting, and no proxy setting shortens it.

Costs roughly another 600MB. Measured on a 4 vCPU / 8GB host: 32s of 502 per upgrade at one replica, none at all at two.

UI_MEMORY_LIMITunsetexample: 128m

Alerting (monitoring/alertmanager — see monitoring/README.md) — optional

VariableDefaultDescription
ALERTMANAGER_SLACK_WEBHOOK_URLempty

Consumed by monitoring/alertmanager/alertmanager.yml via envsubst at container start (see monitoring/docker-compose.yml). Leave unset and Alertmanager still runs — alerts just land only in its own UI (http://localhost:9093) and the null "blackhole" receiver, which is exactly as useless as having no Alertmanager, so set at least one of these for a real deployment.

Slack: an "Incoming Webhook" URL from https://api.slack.com/messaging/webhooks

ALERTMANAGER_SLACK_CHANNEL#railhook-alerts

Consumed by monitoring/alertmanager/alertmanager.yml via envsubst at container start (see monitoring/docker-compose.yml). Leave unset and Alertmanager still runs — alerts just land only in its own UI (http://localhost:9093) and the null "blackhole" receiver, which is exactly as useless as having no Alertmanager, so set at least one of these for a real deployment.

Slack: an "Incoming Webhook" URL from https://api.slack.com/messaging/webhooks

ALERTMANAGER_WEBHOOK_URLempty

Generic webhook receiver (e.g. PagerDuty Events API v2, Opsgenie, a custom sink)

ALERTMANAGER_EMAIL_TOempty

Email receiver — set these to the same values as SMTP_* above if you want alert emails to go through the same relay as transactional email

ALERTMANAGER_EMAIL_FROM[email protected]

Email receiver — set these to the same values as SMTP_* above if you want alert emails to go through the same relay as transactional email

ALERTMANAGER_SMTP_HOSTlocalhost

Email receiver — set these to the same values as SMTP_* above if you want alert emails to go through the same relay as transactional email

ALERTMANAGER_SMTP_PORT1025

Email receiver — set these to the same values as SMTP_* above if you want alert emails to go through the same relay as transactional email

Log aggregation (monitoring/loki, monitoring/promtail — see monitoring/README.md)

VariableDefaultDescription
LOKI_RETENTION_PERIOD336h

How long Loki keeps ingested log lines before compacting them away (Go duration, e.g. 336h = 14d). Independent of the DATA_RETENTION_* days above — those govern Postgres tables, this governs the separate log store, so a self-hoster's disk doesn't fill silently.

Backup configuration

VariableDefaultDescription
BACKUP_DIR./backups

Used by `make backup-db` / `make restore-db` (embedded and external DB modes, see deploy/scripts/db-backup.sh) and by the `db-backup` Compose service, which runs a backup on this schedule automatically whenever `make up` starts the embedded-db profile. Mirrors deploy/helm/railhook/templates/db-backup-cronjob.yaml for the Kubernetes path.

BACKUP_RETENTION_DAYS30

Used by `make backup-db` / `make restore-db` (embedded and external DB modes, see deploy/scripts/db-backup.sh) and by the `db-backup` Compose service, which runs a backup on this schedule automatically whenever `make up` starts the embedded-db profile. Mirrors deploy/helm/railhook/templates/db-backup-cronjob.yaml for the Kubernetes path.

DB_BACKUP_INTERVAL_SECONDS86400

How often the `db-backup` Compose service runs a backup, in seconds. Default: 86400 (daily), matching the Helm CronJob's default cadence.