Конфігурація
Railhook налаштовується змінними середовища у файлі .env поруч із docker-compose.yml. Змінна, якої немає у файлі, зберігає типове значення з файлу Compose. Після зміни .env виконайте ./railhook up -d: Compose перестворить лише ті сервіси, чиї налаштування змінилися.
Що треба визначити
Section titled “Що треба визначити”Майже кожна змінна — це тайм-аут або розмір пакета, типове значення якого підходить. Нижче — винятки: їхні типові значення розраховані на розробку й стають хибними, щойно інстанс стає доступним ззовні.
| Змінна | Чому це важливо |
|---|---|
APP_ENV |
З production API відмовляється запускатися з небезпечною конфігурацією замість працювати з нею |
WEBHOOK_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_SALT |
Шифрують кожен секрет ендпоінта в базі даних. Зберігайте резервні копії. Ніколи не змінюйте сіль, коли дані вже є |
JWT_SECRET |
Підписує сесії входу |
POSTGRES_PASSWORD, DB_PASSWORD |
Перший створює користувача бази, другим підключаються API й воркер. Вони мають збігатися |
REDIS_PASSWORD |
Захищає кеш |
APP_BASE_URL |
Адреса, яку вводять користувачі. З неї будуються посилання для підтвердження, запрошення й скидання пароля |
CORS_ALLOWED_ORIGINS |
Має містити origin, з якого віддається панель, інакше вхід завершується помилкою «Invalid CORS request» |
TUNNEL_INGRESS_BASE_URL |
Публічна основа ingress-адрес джерел і адрес тунелів CLI. Якщо не задано, береться APP_BASE_URL |
EMAIL_ENABLED, EMAIL_FROM, SMTP_* |
Без пошти облікові записи створюються вже підтвердженими, а запрошення надіслати не можна |
RAILHOOK_PORT, RAILHOOK_BIND |
Де слухає єдиний опублікований порт |
WEBHOOK_TRUSTED_PROXIES |
Проксі, чиєму X-Forwarded-For можна вірити. Порожнє значення не довіряє нікому |
WEBHOOK_ALLOW_PRIVATE_IPS, WEBHOOK_ALLOWED_HOSTS |
Тримайте приватні адреси заблокованими. Щоб дозволити лише один внутрішній хост, вкажіть його назву |
DB_HOST і COMPOSE_PROFILES |
Для керованої PostgreSQL задайте змінні DB_* і приберіть embedded-db із профілів |
PLATFORM_ADMIN_TOKEN |
Відкриває операторські ендпоінти. Порожнє значення лишає їх недоступними |
API_REPLICAS |
2 робить оновлення безперервними ціною приблизно ще 600 МБ |
Усі змінні
Section titled “Усі змінні”Deployment mode (required)
| Змінна | Типово | Опис |
|---|---|---|
DB_MODE | embedded | Options: embedded | external |
COMPOSE_PROJECT_NAME | railhook | 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_REGISTRY | не заданоприклад: 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
| Змінна | Типово | Опис |
|---|---|---|
APP_ENV | development | Options: development | staging | production Note: In production mode, placeholder secrets will cause startup failure |
APP_BASE_URL | http://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)
| Змінна | Типово | Опис |
|---|---|---|
POSTGRES_IMAGE | postgres:16-alpine | Used when running PostgreSQL in Docker |
POSTGRES_DB | webhook_platform | Used when running PostgreSQL in Docker |
POSTGRES_USER | webhook_user | Used when running PostgreSQL in Docker |
POSTGRES_PASSWORD | webhook_dev_pass_12345 | Used when running PostgreSQL in Docker |
Database - external mode (when DB_MODE=external)
| Змінна | Типово | Опис |
|---|---|---|
DB_HOST | postgres | 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_PORT | 5432 | 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_NAME | webhook_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_USER | webhook_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_PASSWORD | webhook_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_MODE | disable | 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_URL | порожньо | Optional: Override full JDBC URL (if set, DB_HOST/PORT/NAME are ignored) |
DB_POOL_MAX_SIZE | 20 | 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_IDLE | 10 | 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_TIMEOUT | 30000 | 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_SIZE | 40 | 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_IDLE | 20 | 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)
| Змінна | Типово | Опис |
|---|---|---|
REDIS_IMAGE | redis:7-alpine | — |
REDIS_HOST | redis | — |
REDIS_PORT | 6379 | — |
REDIS_PASSWORD | webhook_redis_pass | — |
REDIS_MAXMEMORY | 256mb | — |
Kafka configuration
| Змінна | Типово | Опис |
|---|---|---|
KAFKA_BOOTSTRAP_SERVERS | kafka:9092 | For embedded mode, use kafka:9092 (Docker internal) For external mode, use your Kafka cluster URL |
KAFKA_IMAGE | apache/kafka:3.7.0 | For embedded mode, use kafka:9092 (Docker internal) For external mode, use your Kafka cluster URL |
KAFKA_NUM_PARTITIONS | 12 | Number of partitions for highload (increase for more parallelism) |
KAFKA_CONSUMER_GROUP_ID | webhook-worker | Consumer group IDs (prefix with env name on shared Kafka clusters, e.g. prod-webhook-worker) |
KAFKA_INCOMING_GROUP_ID | incoming-forward-worker | Consumer group IDs (prefix with env name on shared Kafka clusters, e.g. prod-webhook-worker) |
KAFKA_DELIVERY_CONCURRENCY | 6 | Worker consumer thread concurrency (should be ≤ KAFKA_NUM_PARTITIONS) Delivery consumer: outgoing webhook dispatch (higher = more throughput) |
KAFKA_INCOMING_CONCURRENCY | 3 | Incoming forward consumer: incoming webhook forwarding |
KAFKA_PRODUCER_DELIVERY_TIMEOUT_MS | 120000 | 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_MS | 10000 | 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_RESET | earliest | Kafka consumer settings (worker only) |
KAFKA_CONSUMER_MAX_RETRIES | 3 | Kafka consumer settings (worker only) |
KAFKA_CONSUMER_RETRY_INTERVAL_MS | 5000 | Kafka consumer settings (worker only) |
Security (required - must change for production)
| Змінна | Типово | Опис |
|---|---|---|
WEBHOOK_ENCRYPTION_KEY | dev_encryption_key_32_chars_min! | Webhook secret encryption key (32+ characters, alphanumeric + special chars) CHANGE THIS IN PRODUCTION! |
WEBHOOK_ENCRYPTION_SALT | dev_encryption_salt_16_chars | PBKDF2 salt for AES-256 key derivation (16+ characters, unique per deployment) CHANGE THIS IN PRODUCTION! |
WEBHOOK_ENCRYPTION_KEYS | порожньо | 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_VERSION | 0 | Active key version for new encryptions (0 = auto-detect highest version) |
JWT_SECRET | dev_jwt_secret_key_32_chars_minimum | JWT secret for API authentication (32+ characters) CHANGE THIS IN PRODUCTION! |
PLATFORM_ADMIN_TOKEN | порожньо | 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
| Змінна | Типово | Опис |
|---|---|---|
API_IMAGE_TAG | latest | — |
Worker service
| Змінна | Типово | Опис |
|---|---|---|
WORKER_IMAGE_TAG | latest | — |
Scheduling (api + worker)
| Змінна | Типово | Опис |
|---|---|---|
SCHEDULING_POOL_SIZE | 8 | 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
| Змінна | Типово | Опис |
|---|---|---|
RAILHOOK_PORT | 8080 | 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_BIND | 0.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_DOMAIN | порожньо | 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_EMAIL | порожньо | 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_TAG | latest | 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_URL | порожньо | 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_CONNECT | порожньо | 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_DOMAIN | порожньо | 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
| Змінна | Типово | Опис |
|---|---|---|
OUTBOX_POLL_INTERVAL_MS | 1000 | Poll interval for pending outbox messages (ms) |
OUTBOX_RETRY_INTERVAL_MS | 30000 | Retry interval for failed outbox messages (ms) |
OUTBOX_CLEANUP_INTERVAL_MS | 3600000 | Cleanup interval for PUBLISHED/DEAD outbox messages (ms, default 1 hour) |
OUTBOX_BATCH_SIZE | 100 | Batch size per poll cycle |
OUTBOX_MAX_RETRIES | 5 | Max publish retries before moving to DEAD status |
OUTBOX_DEAD_RETENTION_DAYS | 90 | Days to retain DEAD messages before deletion (allows manual re-drive) |
OUTBOX_SENDING_RECOVERY_SECONDS | 300 | Seconds before recovering stuck SENDING messages back to PENDING (must exceed Kafka delivery.timeout.ms) |
OUTBOX_BATCH_SEND_TIMEOUT_SECONDS | 30 | Timeout in seconds for waiting on batch Kafka send completion |
OUTBOX_MAX_PER_PROJECT | 30 | Max outbox messages claimed per project per batch (prevents noisy-neighbor in publishing) |
OUTBOX_MAX_PER_KEY | 10 | 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
| Змінна | Типово | Опис |
|---|---|---|
DATA_RETENTION_ATTEMPTS_DAYS | 90 | 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_DAYS | 14 | 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_DAYS | 30 | 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_DAYS | 90 | 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_DELIVERY | 10 | Maximum attempts to retain per delivery (keeps most recent N) |
DATA_RETENTION_BATCH_SIZE | 1000 | Batch size for cleanup operations |
DATA_RETENTION_CRON | 0 0 2 * * * | Cron schedule for cleanup (daily at 2 AM) |
DATA_RETENTION_LIMIT_CRON | 0 */30 * * * * | Cron schedule for limit enforcement (every 30 minutes) |
DATA_RETENTION_BURST_CLEANUP_CRON | 0 0 */4 * * * | Burst cleanup cron — catches daytime error spikes (every 4 hours) |
DATA_RETENTION_TABLE_METRICS_INTERVAL_MS | 900000 | How often to refresh table size gauges (ms, default 15 min) |
Partition maintenance
| Змінна | Типово | Опис |
|---|---|---|
PARTITION_MAINTENANCE_ENABLED | true | (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_MONTHS | 3 | (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_WEEKS | 3 | (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_CRON | 0 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
| Змінна | Типово | Опис |
|---|---|---|
GLOBAL_RATE_LIMIT_PER_SECOND | 5000 | Global platform rate limit (safety net, per instance, requests/sec) |
ORG_RATE_LIMIT_ENABLED | false | 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_SECOND | 200 | 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_ENABLED | true | 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_SECOND | 100 | Events per second per project (token bucket) |
AUTH_RATE_LIMIT_LOGIN_PER_MINUTE | 10 | Auth rate limiting (per IP and per email, per minute) |
AUTH_RATE_LIMIT_REGISTER_PER_MINUTE | 5 | Auth rate limiting (per IP and per email, per minute) |
Password authentication
| Змінна | Типово | Опис |
|---|---|---|
AUTH_BCRYPT_STRENGTH | 12 | 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_ENABLED | true | 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_THRESHOLD | 5 | Consecutive failures before the account locks. |
AUTH_LOCKOUT_INITIAL_SECONDS | 60 | First lockout, in seconds. Doubles with each further failure. |
AUTH_LOCKOUT_MAX_SECONDS | 900 | 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_MINUTES | 60 | 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_MS | 3600000 | How often expired user_sessions rows are purged. Housekeeping only: an expired session cannot authenticate anything either way. |
Webhook delivery
| Змінна | Типово | Опис |
|---|---|---|
WEBHOOK_MAX_PAYLOAD_SIZE_BYTES | 262144 | Maximum event payload size in bytes (default 256KB) |
WEBHOOK_MAX_FANOUT_PER_EVENT | 100 | Max deliveries per single event (fanout limit — prevents queue flood) |
WEBHOOK_PAYLOAD_COMPRESSION_THRESHOLD_BYTES | 1024 | Payload compression threshold - payloads larger than this will be gzip compressed (default 1KB) Reduces DB storage significantly for large payloads |
ORGANIZATION_SUSPENSION_CACHE_TTL_SECONDS | 60 | 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_SECOND | 50 | 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_REQUIRED | false | Require endpoint verification before allowing deliveries (default: false) Set to true to enforce proof-of-ownership for all new endpoints |
WEBHOOK_INGRESS_REPLAY_WINDOW_MINUTES | 5 | 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_IPS | false | 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_ENDPOINT | 5 | Max concurrent deliveries per endpoint (prevents endpoint overload) |
WEBHOOK_MAX_CONCURRENT_PER_TENANT | 20 | 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_SECONDS | 90 | 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_SIZE | 50 | Outgoing webhook delivery pool (isolated from incoming forwards) |
WEBHOOK_INCOMING_POOL_SIZE | 20 | Incoming forward pool (isolated from outgoing deliveries) |
WEBHOOK_ASYNC_SHUTDOWN_TIMEOUT_SECONDS | 60 | Graceful shutdown timeout for in-flight deliveries (seconds) |
CLIENT_ERROR_REPORTING_ENABLED | true | 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_MINUTE | 20 | 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_BYTES | 1048576 | 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_CONNECTIONS | 200 | HTTP connection pool for outgoing webhook delivery (shared by delivery + incoming forward) |
WEBHOOK_POOL_PENDING_ACQUIRE_TIMEOUT_SECONDS | 10 | HTTP connection pool for outgoing webhook delivery (shared by delivery + incoming forward) |
WEBHOOK_POOL_MAX_IDLE_TIME_SECONDS | 60 | HTTP connection pool for outgoing webhook delivery (shared by delivery + incoming forward) |
WEBHOOK_ALLOWED_HOSTS | порожньо | 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_PROXIES | порожньо | 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
| Змінна | Типово | Опис |
|---|---|---|
WEBHOOK_INGRESS_BASE_URL | http://localhost:8080 | Base URL for ingress endpoint URLs shown in admin UI |
WEBHOOK_INCOMING_MAX_PAYLOAD_SIZE_BYTES | 524288 | Maximum incoming payload size in bytes (default 512KB) |
WEBHOOK_INCOMING_RATE_LIMIT_PER_SECOND | 100 | 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_MS | 10000 | Incoming forward retry scheduler |
INCOMING_FORWARD_RETRY_BATCH_SIZE | 50 | Incoming forward retry scheduler |
INCOMING_FORWARD_RETRY_MAX_PER_DEST | 10 | Incoming forward retry scheduler |
INCOMING_FORWARD_RETRY_HIGH_WATERMARK | 3000 | Queue depth threshold for incoming forward retry governor |
DLQ_MONITORING_INTERVAL_MS | 60000 | DLQ monitoring interval (ms) |
DLQ_MONITORING_ADMIN_CLIENT_TIMEOUT_SECONDS | 10 | Timeout for the Kafka AdminClient calls DLQ monitoring makes each cycle (seconds) |
Ordering (FIFO delivery per endpoint)
| Змінна | Типово | Опис |
|---|---|---|
ORDERING_GAP_TIMEOUT_SECONDS | 60 | Gap timeout in seconds (proceed without previous sequence after this time) |
ORDERING_BUFFER_RESCHEDULE_DELAY_SECONDS | 5 | 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_HOURS | 24 | TTL for delivered sequence tracking in Redis (hours) |
ORDERING_BUFFER_TTL_MINUTES | 10 | TTL for ordering buffer in Redis (minutes) |
ORDERING_BUFFER_GAUGE_RESYNC_MS | 30000 | 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_SECONDS | 120 | 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_SIZE | 500 | 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_HOURS | 48 | How far back the API's periodic sequence/cursor reconciliation job looks for recently-active ordering-enabled endpoints (hours) |
ORDERING_SEQUENCE_RECONCILIATION_INTERVAL_MS | 900000 | How often the API's sequence/cursor reconciliation job runs (milliseconds) |
Event time machine (replay)
| Змінна | Типово | Опис |
|---|---|---|
REPLAY_BATCH_SIZE | 200 | Batch size for cursor-based event scanning (tune for DB load) |
REPLAY_BATCH_DELAY_MS | 50 | Delay in ms between batches (backpressure against Kafka/DB) |
REPLAY_MAX_EVENTS_PER_SESSION | 500000 | Maximum events per single replay session |
Retry scheduler
| Змінна | Типово | Опис |
|---|---|---|
RETRY_SCHEDULER_BATCH_SIZE | 100 | Batch size for processing pending retries (governor ceiling — actual batch adapts dynamically) |
RETRY_SCHEDULER_MAX_PER_ENDPOINT | 10 | Max retries claimed per endpoint per poll (fair scheduling) |
RETRY_SCHEDULER_MAX_PER_PROJECT | 30 | Max retries claimed per project per poll (prevents noisy-neighbor in retry queue) |
RETRY_SCHEDULER_HIGH_WATERMARK | 5000 | 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_SECONDS | 30 | Timeout in seconds for Kafka send confirmation |
RETRY_SCHEDULER_RESCHEDULE_DELAY_SECONDS | 60 | Delay in seconds before rescheduling failed sends |
RETRY_SCHEDULER_POLL_INTERVAL_MS | 10000 | 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
| Змінна | Типово | Опис |
|---|---|---|
STUCK_DELIVERY_THRESHOLD_MINUTES | 5 | Threshold in minutes - deliveries stuck in PROCESSING longer than this will be reset |
STUCK_DELIVERY_STRANDED_PENDING_THRESHOLD_MINUTES | 60 | 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_MS | 60000 | Check interval in milliseconds |
Stuck forward recovery (incoming forward attempts)
| Змінна | Типово | Опис |
|---|---|---|
STUCK_FORWARD_THRESHOLD_MINUTES | 5 | Threshold in minutes - forward attempts stuck in PROCESSING longer than this will be reset |
STUCK_FORWARD_STRANDED_PENDING_THRESHOLD_MINUTES | 60 | 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_MS | 60000 | Check interval in milliseconds |
Circuit breaker (Redis-backed, shared across worker pods)
| Змінна | Типово | Опис |
|---|---|---|
CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD | 50 | Failure rate percentage threshold to trip the circuit (0-100) |
CIRCUIT_BREAKER_MINIMUM_CALLS | 5 | Minimum number of calls before evaluating failure rate |
CIRCUIT_BREAKER_WAIT_DURATION_SECONDS | 30 | How long the circuit stays OPEN before allowing probes (seconds) |
CIRCUIT_BREAKER_WINDOW_TTL_SECONDS | 120 | TTL for failure/call counters sliding window (seconds) |
CIRCUIT_BREAKER_SLOW_CALL_THRESHOLD_MS | 10000 | 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_THRESHOLD | 80 | Percentage of slow calls to trip the circuit (0-100) |
Stale delivery escalation (hard-cap auto-DLQ)
| Змінна | Типово | Опис |
|---|---|---|
DELIVERY_ESCALATION_HARD_CAP_HOURS | 96 | 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_SIZE | 100 | Batch size per escalation cycle |
Stale forward escalation (hard-cap auto-DLQ, Incoming)
| Змінна | Типово | Опис |
|---|---|---|
FORWARD_ESCALATION_HARD_CAP_HOURS | 24 | 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_SIZE | 100 | 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_MS | 300000 | 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_MS | 300000 | How often to check for stale deliveries (ms, default 5 min) |
Queue depth metrics
| Змінна | Типово | Опис |
|---|---|---|
QUEUE_DEPTH_METRICS_INTERVAL_MS | 900000 | How often to refresh queue depth gauges (ms) — default 15 min |
QUEUE_DEPTH_METRICS_RETENTION_DAYS | 30 | Only count records created within this many days (limits scan scope) |
Test endpoints (Request Bin)
| Змінна | Типово | Опис |
|---|---|---|
TEST_ENDPOINT_BASE_URL | http://api:8080 | Base URL for generated test endpoint URLs |
TEST_ENDPOINT_MAX_PER_PROJECT | 10 | Maximum test endpoints per project |
TEST_ENDPOINT_MAX_TTL_HOURS | 72 | Maximum TTL in hours for test endpoints |
TEST_ENDPOINT_CLEANUP_INTERVAL_MS | 3600000 | Cleanup interval for expired endpoints (ms) |
Rules engine
| Змінна | Типово | Опис |
|---|---|---|
RULES_CACHE_REFRESH_MS | 30000 | Cache refresh interval for compiled rules (ms, default 30s) |
RULES_EXECUTION_LOG_RETENTION_DAYS | 7 | Retention for rule execution logs (days, default 7) |
Workflow engine
| Змінна | Типово | Опис |
|---|---|---|
WORKFLOW_POOL_CORE_SIZE | 4 | Thread pool for async workflow execution |
WORKFLOW_POOL_MAX_SIZE | 8 | Thread pool for async workflow execution |
WORKFLOW_POOL_QUEUE_CAPACITY | 50 | Thread pool for async workflow execution |
WORKFLOW_MAX_DURATION_SECONDS | 600 | Maximum total execution time for a single workflow run (seconds) |
WORKFLOW_MAX_RECURSION_DEPTH | 3 | Maximum recursion depth for workflow chains (workflow→CreateEvent→workflow) |
WORKFLOW_STUCK_THRESHOLD_MINUTES | 15 | Stuck execution recovery: threshold (minutes) and check interval (ms) |
WORKFLOW_RECOVERY_INTERVAL_MS | 120000 | Stuck execution recovery: threshold (minutes) and check interval (ms) |
WORKFLOW_RESUME_INTERVAL_MS | 5000 | 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_SIZE | 50 | 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_SECONDS | 30 | Per-node timeout limits (seconds) |
WORKFLOW_NODE_TIMEOUT_HTTP_SECONDS | 60 | Per-node timeout limits (seconds) |
WORKFLOW_NODE_TIMEOUT_SLACK_SECONDS | 60 | Per-node timeout limits (seconds) |
WORKFLOW_NODE_TIMEOUT_CREATE_EVENT_SECONDS | 30 | Per-node timeout limits (seconds) |
WORKFLOW_NODE_TIMEOUT_POOL_SIZE | 16 | Per-node timeout limits (seconds) |
WORKFLOW_SHUTDOWN_AWAIT_SECONDS | 30 | Graceful shutdown: how long to wait for in-flight workflows (seconds) |
WORKFLOW_TRIGGER_OUTBOX_POLL_INTERVAL_MS | 2000 | Workflow trigger outbox (durable at-least-once trigger via transactional outbox) |
WORKFLOW_TRIGGER_OUTBOX_BATCH_SIZE | 50 | Workflow trigger outbox (durable at-least-once trigger via transactional outbox) |
WORKFLOW_TRIGGER_OUTBOX_MAX_ATTEMPTS | 3 | Workflow trigger outbox (durable at-least-once trigger via transactional outbox) |
WORKFLOW_TRIGGER_OUTBOX_MAX_PER_PROJECT | 5 | Max workflow triggers claimed per project per batch (prevents one project from starving others) |
WORKFLOW_TRIGGER_OUTBOX_MAX_CONCURRENT_PER_PROJECT | 3 | Max concurrent workflow executions per project (prevents executor thread exhaustion) |
WORKFLOW_TRIGGER_OUTBOX_STALLED_AFTER_MINUTES | 15 | 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_MS | 300000 | 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
| Змінна | Типово | Опис |
|---|---|---|
DEVICE_AUTH_CLEANUP_INTERVAL_MS | 300000 | Cleanup interval for expired device auth codes (ms) |
Email verification
| Змінна | Типово | Опис |
|---|---|---|
EMAIL_ENABLED | false | 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_HOST | localhost | SMTP settings (only used when EMAIL_ENABLED=true) |
SMTP_PORT | 1025 | SMTP settings (only used when EMAIL_ENABLED=true) |
SMTP_USERNAME | порожньо | SMTP settings (only used when EMAIL_ENABLED=true) |
SMTP_PASSWORD | порожньо | SMTP settings (only used when EMAIL_ENABLED=true) |
SMTP_AUTH | false | SMTP settings (only used when EMAIL_ENABLED=true) |
SMTP_STARTTLS | false | SMTP settings (only used when EMAIL_ENABLED=true) |
Alert notifications
| Змінна | Типово | Опис |
|---|---|---|
ALERTS_NOTIFICATIONS_ENABLED | false | Enable sending real alert notifications via Slack/Webhook/Email (false = dry-run mode, logs notification details to console) |
ALERTS_EVALUATION_CRON | 0 * * * * * | 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)
| Змінна | Типово | Опис |
|---|---|---|
ENTITLEMENT_DEFAULT_RATE_LIMIT | 100 | 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_FANOUT | 50 | 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)
| Змінна | Типово | Опис |
|---|---|---|
CAPTCHA_SECRET_KEY | не задано | 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_URL | не заданоприклад: 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_KEY | не задано | 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_URL | не заданоприклад: 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
| Змінна | Типово | Опис |
|---|---|---|
GOOGLE_OAUTH_CLIENT_ID | не задано | "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_SECRET | не задано | "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
| Змінна | Типово | Опис |
|---|---|---|
BILLING_ENABLED | false | 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_PROVIDER | noop | 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_KEY | не заданоприклад: sk_test_... | Stripe settings (registered automatically when STRIPE_SECRET_KEY is set) |
STRIPE_WEBHOOK_SECRET | не заданоприклад: whsec_... | Stripe settings (registered automatically when STRIPE_SECRET_KEY is set) |
STRIPE_PRICE_MAP | не заданоприклад: 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_CRON | 0 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_ACCOUNT | не заданоприклад: your_merchant_account | WayForPay settings (registered automatically when WAYFORPAY_MERCHANT_ACCOUNT is set) |
WAYFORPAY_MERCHANT_SECRET | не заданоприклад: your_merchant_secret_key | WayForPay settings (registered automatically when WAYFORPAY_MERCHANT_ACCOUNT is set) |
WAYFORPAY_MERCHANT_DOMAIN | не заданоприклад: example.com | WayForPay settings (registered automatically when WAYFORPAY_MERCHANT_ACCOUNT is set) |
WAYFORPAY_SERVICE_URL | не заданоприклад: https://example.com/api/v1/billing/webhook/wayforpay | WayForPay settings (registered automatically when WAYFORPAY_MERCHANT_ACCOUNT is set) |
WAYFORPAY_PLAN_PRICES | не заданоприклад: starter_monthly=2900,starter_yearly=29000,pro_monthly=9900,pro_yearly=99000 | Format: plan_interval=amount_in_cents (interval: _monthly or _yearly) |
PRERENDER_LOCALE | en | 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)
| Змінна | Типово | Опис |
|---|---|---|
TUNNEL_INGRESS_BASE_URL | http://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_SECONDS | 120 | Heartbeat timeout — tunnel session marked stale after this many seconds without heartbeat |
TUNNEL_CLEANUP_INTERVAL_MS | 60000 | Stale session cleanup interval (ms) |
TUNNEL_REQUEST_LOG_RETENTION_DAYS | 7 | 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
| Змінна | Типово | Опис |
|---|---|---|
CORS_ALLOWED_ORIGINS | http://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
| Змінна | Типово | Опис |
|---|---|---|
LOG_LEVEL | INFO | Log level: TRACE | DEBUG | INFO | WARN | ERROR |
SWAGGER_ENABLED | false | Enable/disable Swagger UI (set to false in production) |
JVM tuning
| Змінна | Типово | Опис |
|---|---|---|
API_JAVA_OPTS | не заданоприклад: -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_OPTS | не заданоприклад: -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_ENABLED | true | Enable Prometheus metrics export |
Resource limits (Docker deploy.resources)
| Змінна | Типово | Опис |
|---|---|---|
POSTGRES_MEMORY_LIMIT | не заданоприклад: 1G | Override memory limits per service (used in docker-compose.yml) |
KAFKA_MEMORY_LIMIT | не заданоприклад: 1G | Override memory limits per service (used in docker-compose.yml) |
REDIS_MEMORY_LIMIT | не заданоприклад: 512m | Override memory limits per service (used in docker-compose.yml) |
API_MEMORY_LIMIT | не заданоприклад: 768m | Override memory limits per service (used in docker-compose.yml) |
WORKER_MEMORY_LIMIT | не заданоприклад: 768m | Override memory limits per service (used in docker-compose.yml) |
API_REPLICAS | 1 | 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_LIMIT | не заданоприклад: 128m | — |
Alerting (monitoring/alertmanager — see monitoring/README.md) — optional
| Змінна | Типово | Опис |
|---|---|---|
ALERTMANAGER_SLACK_WEBHOOK_URL | порожньо | 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_URL | порожньо | Generic webhook receiver (e.g. PagerDuty Events API v2, Opsgenie, a custom sink) |
ALERTMANAGER_EMAIL_TO | порожньо | 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_HOST | localhost | 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_PORT | 1025 | 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)
| Змінна | Типово | Опис |
|---|---|---|
LOKI_RETENTION_PERIOD | 336h | 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
| Змінна | Типово | Опис |
|---|---|---|
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_DAYS | 30 | 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_SECONDS | 86400 | How often the `db-backup` Compose service runs a backup, in seconds. Default: 86400 (daily), matching the Helm CronJob's default cadence. |
Жодна змінна не відповідає пошуку.