Environment Variables Reference
Dieser Inhalt ist in Ihrer Sprache noch nicht verfügbar.
This guide covers all environment variables available in Onetime Secret v0.24+.
Environment Variables
Section titled “Environment Variables”Set these in your .env file or environment or add them to your docker commands or docker-compose.yml file. All variables are optional unless marked as required.
A trailing # Since vX.Y.Z records the release a variable first shipped in;
# Since unreleased means it ships in the next one. A variable with no
marker predates v0.24.0. The marker is a comment about the variable, not
part of its value — drop it when copying a line, since docker run --env-file and docker compose --env-file take the whole rest of the line
as the value rather than stripping a trailing comment.
# Onetime Secret - Full Environment Variable Reference## This file documents every supported environment variable.# For quick-start configuration, see .env.example instead.## Variables marked:# * [derived] are generated by `rake ots:init` via HKDF from SECRET.# * [independent] are randomly generated by `rake ots:init` and must be backed up separately.# * [federation] must be set manually and shared identically across all regions.Secrets & Cryptography
Section titled “Secrets & Cryptography”# Root secret. All derived secrets use HKDF (RFC 5869) with# this as input keying material. The only value needed for# disaster recovery. Treat it like the database itself: back it# up in a secret manager and never regenerate it casually — see# docs/runbooks/secret-rotation.md for what depends on it and# how to rotate it safely.SECRET=
# Boot-time SECRET verifier policy (site.secret_verifier_mode).# The app stores an HKDF-derived verifier in the datastore and checks# it on boot so a changed SECRET is detected instead of silently making# existing secrets undecryptable. warn (default) | enforce | off.#SECRET_VERIFIER_MODE=warn # Since v0.26.0
# Previous SECRET value(s), decrypt-only, for key rotation# (comma-separated, oldest first — append the outgoing SECRET on each# rotation). Old ciphertexts keep decrypting while new writes use the# current SECRET. Keep each entry for at least the longest secret TTL# plus receipt TTL, then drop it. See docs/runbooks/secret-rotation.md.#SECRET_PREVIOUS= # Since v0.26.0
# [derived] Rack session signing key#SESSION_SECRET= # Since v0.24.0
# [derived] Secret link verification key (Familia::VerifiableIdentifier).# Used to generate secret/receipt keys. Derived from SECRET via HKDF.#IDENTIFIER_SECRET= # Since v0.24.0
# [independent] HMAC key for Rodauth auth operations (TOTP, login tokens).# Cannot be recovered from SECRET. Back this up.#AUTH_SECRET= # Since v0.24.0
# [independent] Argon2 pepper for password hashing (full auth mode).# Folded into every password hash via Rodauth's argon2_secret.# Cannot be recovered from SECRET. WARNING: Changing this invalidates# ALL existing password hashes. Back this up.# See: apps/web/auth/config/features/argon2.rb#ARGON2_SECRET= # Since v0.24.0
# [federation] Cross-region subscription federation.# Must be identical across all regions in a multi-region deploy.# Not auto-generated — set manually and share across instances.#FEDERATION_SECRET= # Since v0.24.0
# [independent] Obfuscates numeric account IDs in email-verification# links and remember-me cookies.## Also keys the pseudonymous Sentry diagnostics references# (Onetime::Utils::DiagnosticsRef): the actor reference is derived from the# customer's external identifier and the organization reference from the# organization objid, both HMAC'd under this secret and truncated. Sentry# receives only the opaque reference, never the identifier it was derived# from. Those references support issue correlation and affected-account# counts within this installation; they are not for product usage analytics# or behavioral profiling, and a keyed pseudonym is still potentially# personal data. Because both pre-images are minted per installation,# separately provisioned installations do not correlate — do not copy this# secret between installations. Rotating it re-keys every reference, so# correlation with already-reported events breaks; under Sentry's retention# window that discontinuity ages out on its own. With no secret set, no# references are emitted at all.#ACCOUNT_ID_SECRET= # Since v0.26.0
# Previous AUTH_SECRET value, set only while rotating AUTH_SECRET# (Rodauth hmac_old_secret). Tokens HMAC'd under the outgoing secret# keep validating during the rotation window; unset when done. Full# auth mode (AUTHENTICATION_MODE=full) only. No default.#AUTH_OLD_SECRET= # Since v0.24.0
# [deprecated] Not consumed by this app — appears only in the vendored# Rodauth reference doc. The Rodauth HMAC secret is read from# AUTH_SECRET instead (hmac_secret_env_key 'AUTH_SECRET' in# apps/web/auth/config/base.rb). Set AUTH_SECRET; this var is ignored.#RODAUTH_HMAC_SECRET= # Since v0.26.0
# HMAC key the familia gem reads to sign secret/receipt identifiers# (Familia::VerifiableIdentifier). When unset, boot fills it from# IDENTIFIER_SECRET, else HKDF-derives it from SECRET (purpose# :identifier). A set-but-blank value is kept and rejected by familia.# Set only to override; normally manage IDENTIFIER_SECRET instead.#VERIFIABLE_ID_HMAC_SECRET= # Since v0.24.0Connections
Section titled “Connections”REDIS_URL='redis://maindb:5212/0?timeout=5'VALKEY_URL=AUTH_DATABASE_URL=postgresql://onetime_user:CHANGEME@authdb/onetime_auth # Since v0.24.0AUTH_DATABASE_URL_MIGRATIONS=postgresql://onetime_migrator:CHANGEME@authdb/onetime_auth # Since v0.24.0
# Password for the bundled Valkey/Redis datastore. REQUIRED by both# docker/compose/docker-compose.simple.yml and docker-compose.full.yml, which# set `valkey-server --requirepass` and refuse to start unless this is set. The# compose file threads the SAME value into the connection URL, so also embed# it in REDIS_URL/VALKEY_URL for non-compose deployments# (redis://:PASSWORD@host:6379/0). Use a URL-safe value (hex/alphanumeric — no# @ : / # so it needs no URL-encoding); generate one with:# echo "VALKEY_PASSWORD=$(openssl rand -hex 32)" >> .envVALKEY_PASSWORD= # Since v0.26.0
# RabbitMQ (job system). Broker credentials are REQUIRED by the bundled# docker/compose/docker-compose.full.yml — BOTH must be set or the stack# fails fast at `docker compose up`, before any container starts, with a# clear "RABBITMQ_USER must be set" / "RABBITMQ_PASS must be set" message.## This is deliberate and applies to the FULL STACK REGARDLESS of JOBS_ENABLED:# the broker, worker-email, and scheduler always run (they sit idle when# jobs are off — see docker/README.md "Background Jobs"), so their AMQP# identity must exist even in the default (jobs-off) configuration. The full# stack is a production deployment; this mirrors the SECRET fail-fast guard.## These two values seed the broker's default user AND are woven into the# app/worker/scheduler AMQP URLs by the compose file, so you do NOT set# RABBITMQ_URL yourself for the full stack (see the note below).# Never ship the historic guest/guest pair: RabbitMQ's 'guest' account is a# well-known default and (on newer RabbitMQ) is refused over non-loopback.RABBITMQ_USER= # Since v0.26.0RABBITMQ_PASS= # Since v0.26.0
# Full AMQP URL read directly by the app when it publishes jobs# (JOBS_ENABLED=true), via jobs.rabbitmq_url. Must carry the SAME credentials# as RABBITMQ_USER / RABBITMQ_PASS above. Format: amqp://USER:PASS@host:5672/vhost## NOTE: docker/compose/docker-compose.full.yml sets RABBITMQ_URL on the app,# worker-email, AND scheduler services itself — deriving it from RABBITMQ_USER /# RABBITMQ_PASS and pointing at the in-network broker (amqp://…@rabbitmq:5672,# default vhost '/'). That compose-set value overrides this one, so leave this# placeholder alone for the full stack. Set it only for non-compose or# external-broker deployments.RABBITMQ_URL=amqp://CHANGEME:CHANGEME@mqhost:5672/dev # Since v0.24.0
# Size of the shared Valkey/Redis connection pool used for all# Familia model operations (count of connections). Default: 25.#FAMILIA_POOL_SIZE=25 # Since v0.24.0
# Seconds to wait when checking a connection out of the Valkey/Redis# connection pool before raising a timeout error. Default: 5.#FAMILIA_POOL_TIMEOUT=5 # Since v0.24.0
# Fallback alias for VALKEY_DBS_CUSTOM_DOMAIN# (redis.dbs.custom_domain); the VALKEY_ variant wins when both are# set. Logical database number 0-15. Default: 0.#REDIS_DBS_CUSTOM_DOMAIN=0
# Fallback alias for VALKEY_DBS_CUSTOMER (redis.dbs.customer); the# VALKEY_ variant wins when both are set. Logical database number# 0-15. Default: 0.#REDIS_DBS_CUSTOMER=0
# Fallback alias for VALKEY_DBS_FEEDBACK (redis.dbs.feedback); the# VALKEY_ variant wins when both are set. Logical database number# 0-15. Default: 0.#REDIS_DBS_FEEDBACK=0
# Fallback alias for VALKEY_DBS_METADATA (redis.dbs.metadata); the# VALKEY_ variant wins when both are set. Logical database number# 0-15. Default: 0.#REDIS_DBS_METADATA=0
# Fallback alias for VALKEY_DBS_SECRET (redis.dbs.secret); the# VALKEY_ variant wins when both are set. Logical database number# 0-15. Default: 0.#REDIS_DBS_SECRET=0
# Logical database number for custom-domain records# (redis.dbs.custom_domain). 0-15, default 0. Takes precedence over# REDIS_DBS_CUSTOM_DOMAIN. Note: this mapping is currently# informational (shown in the boot banner); all models connect to the# database in the connection URL.#VALKEY_DBS_CUSTOM_DOMAIN=0 # Since v0.24.0
# Logical database number for customer records (redis.dbs.customer).# 0-15, default 0. Takes precedence over REDIS_DBS_CUSTOMER. Note:# this mapping is currently informational (shown in the boot banner);# all models connect to the database in the connection URL.#VALKEY_DBS_CUSTOMER=0 # Since v0.24.0
# Logical database number for feedback records (redis.dbs.feedback).# 0-15, default 0. Takes precedence over REDIS_DBS_FEEDBACK. Note:# this mapping is currently informational (shown in the boot banner);# all models connect to the database in the connection URL.#VALKEY_DBS_FEEDBACK=0 # Since v0.24.0
# Logical database number for metadata (receipt) records# (redis.dbs.metadata). 0-15, default 0. Takes precedence over# REDIS_DBS_METADATA. Note: this mapping is currently informational# (shown in the boot banner); all models connect to the database in# the connection URL.#VALKEY_DBS_METADATA=0 # Since v0.24.0
# Logical database number for secret records (redis.dbs.secret).# 0-15, default 0. Takes precedence over REDIS_DBS_SECRET. Note:# this mapping is currently informational (shown in the boot banner);# all models connect to the database in the connection URL.#VALKEY_DBS_SECRET=0 # Since v0.24.0Site Identity & Core
Section titled “Site Identity & Core”# Public hostname. Include port if non-standard.HOST=localhost:3000SSL=true
# Explicit override for the session cookie's Secure flag; wins over any# SSL-derived value. In production SSL=false alone no longer disables Secure# (the app fails closed); set this to false ONLY for a deliberately plain-HTTP# deployment (the e2e lanes use it). Unset: derived from SSL / environment.#SESSION_COOKIE_SECURE= # Since v0.26.11
# Runtime environmentRACK_ENV=productionNODE_ENV=productionSTDOUT_SYNC=falseSecret TTL Configuration
Section titled “Secret TTL Configuration”# Available TTL choices for users (space-separated seconds).# Example: '300 3600 86400 604800 2592000'# 5m 1h 1d 7d 30d#TTL_OPTIONS=
# Default TTL when not specified (seconds). Default: 604800 (7 days)#DEFAULT_TTL=604800
# Maximum TTL for secrets created without an account (seconds).# Default: 604800 (7 days). Max: 31536000 (365 days).# Clamped between 1 second and 365 days; invalid or non-positive values use the# default. The effective ceiling is the lower of this and the TTL_OPTIONS# maximum. Self-hosted deployments may raise it above 7 days; on deployments# with billing enabled the free-tier plan limit applies as an additional# ceiling, so anonymous callers never outrank an authenticated free-tier user.## On a billing-enabled deployment this variable also sets the free-tier# secret_lifetime fallback used when plan state is unavailable (default 1209600,# 14 days, matching free_v1 in etc/billing.yaml), so it moves both values.## Replaces PLAN_TTL_ANONYMOUS, which is still read as a deprecated alias for the# anonymous ceiling when TTL_MAX_ANONYMOUS is unset, but no longer moves the# free-tier fallback. Rename it.# @see https://github.com/onetimesecret/onetimesecret/issues/2390#TTL_MAX_ANONYMOUS=604800 # Since v0.26.3Secret Options
Section titled “Secret Options”# Rate limit on ANONYMOUS secret creation (site.secret_options.# create_rate_limit), counted per masked client IP (/24 IPv4, /48 IPv6)# across every creation entry point (V1/V2/V3 conceal and generate).# Authenticated callers are never charged. Enabled by default; set# ENABLED=false to opt out. The cap is deliberately loose — the bucket# is a whole /24 (office or campus NAT), and behind an unconfigured# reverse proxy it is the entire deployment — so raise MAX_PER_IP for# dense-NAT populations rather than tightening it.# See lib/onetime/security/conceal_secret_rate_limiter.rb.#SECRET_CREATE_RATE_LIMIT_ENABLED=true # Since v0.26.11# Anonymous creations permitted per window from one masked IP. Default: 500.#SECRET_CREATE_RATE_LIMIT_MAX_PER_IP=500 # Since v0.26.11# Counting window in seconds. Default: 3600 (1 hour).#SECRET_CREATE_RATE_LIMIT_WINDOW=3600 # Since v0.26.11# Lockout duration in seconds once the cap is hit. Default: 3600 (1 hour).#SECRET_CREATE_RATE_LIMIT_LOCKOUT=3600 # Since v0.26.11
# Require complexity in secret passphrases# (site.secret_options.passphrase.enforce_complexity). When true, the# V2 API rejects passphrases missing an uppercase letter, lowercase# letter, number, or symbol. Ignored by the V1 API (preserves v0.23.4# behavior). Default: false; set to 'true' to enable.#PASSPHRASE_ENFORCE_COMPLEXITY=false
# Maximum passphrase length in characters# (site.secret_options.passphrase.maximum_length). Secret creation# rejects longer passphrases on both V1 and V2 APIs. Default: 128.#PASSPHRASE_MAX_LENGTH=128
# Minimum passphrase length in characters# (site.secret_options.passphrase.minimum_length). Enforced on both# V1 and V2 APIs when a passphrase is provided. Default: 4.#PASSPHRASE_MIN_LENGTH=4
# Require a passphrase on every secret# (site.secret_options.passphrase.required). When true, creating a# secret without a passphrase fails on both V1 and V2 APIs.# Default: false; set to 'true' to enable.#PASSPHRASE_REQUIRED=false
# Exclude visually ambiguous characters (0, O, o, 1, l, I, i) from# generated passwords (site.secret_options.password_generation# .character_sets.exclude_ambiguous). Generate requests may override# character_sets per call. Default: true; set to 'false' to allow# ambiguous characters.#PASSWORD_GEN_EXCLUDE_AMBIGUOUS=true
# Default length in characters for generated passwords# (site.secret_options.password_generation.default_length). Used when# a generate request does not specify its own length. Default: 12.#PASSWORD_GEN_LENGTH=12
# Include lowercase letters (a-z) in generated passwords# (site.secret_options.password_generation.character_sets.lowercase).# When multiple sets are enabled, at least one character from each is# guaranteed. Default: true; set to 'false' to exclude.#PASSWORD_GEN_LOWERCASE=true
# Server-enforced ceiling on the requested length of a generated password# (site.secret_options.password_generation.maximum_length). Oversized# `length` values are rejected before allocation, closing a memory-exhaustion# DoS on the anonymous generate endpoints. Matches the frontend Zod max so the# client and server limits stay in lockstep. Default: 128.#PASSWORD_GEN_MAX_LENGTH=128 # Since v0.26.0
# Include numbers (0-9) in generated passwords# (site.secret_options.password_generation.character_sets.numbers).# When multiple sets are enabled, at least one character from each is# guaranteed. Default: true; set to 'false' to exclude.#PASSWORD_GEN_NUMBERS=true
# Include symbols (!@#$%^&* etc.) in generated passwords# (site.secret_options.password_generation.character_sets.symbols).# When multiple sets are enabled, at least one character from each is# guaranteed. Default: true; set to 'false' to exclude.#PASSWORD_GEN_SYMBOLS=true
# Include uppercase letters (A-Z) in generated passwords# (site.secret_options.password_generation.character_sets.uppercase).# When multiple sets are enabled, at least one character from each is# guaranteed. Default: true; set to 'false' to exclude.#PASSWORD_GEN_UPPERCASE=true
# Maximum secret content size in characters# (site.secret_options.content.maximum_length). Server-enforced# ceiling on the secret body for V2 and incoming secrets; also the# single source of truth for the client-side textarea limit. The V1# API uses a fixed 10000-char limit instead. Default: 10000.#SECRET_MAX_LENGTH=10000 # Since v0.26.0
# Window in seconds after creation during which a generated# password's value may be revealed on the creator's receipt page# (site.secret_options.generated_value_display_ttl). Applies only to# generated secrets; the reveal is claimed atomically so it happens# at most once. Set to 0 to disable receipt-page display.# Default: 60.#GENERATED_VALUE_DISPLAY_TTL=60 # Since v0.24.0EMAILER_MODE=smtp
SMTP_HOST=SMTP_PORT=587SMTP_USERNAME=SMTP_PASSWORD=SMTP_AUTH=loginSMTP_TLS=true
FROM_EMAIL=secure@onetimesecret.comFROM_NAME=secure@onetimesecret.com
EMAILER_REGION=
FEEDBACK_TO_EMAIL= # Since v0.25.2
VERIFIER_EMAIL=secure@onetimesecret.comVERIFIER_DOMAIN=onetimesecret.com
EMAILER_SHOW_LOGO=false # Since v0.25.6
# AWS access key for SES. Fallback credential for SES transactional# delivery when emailer.user (SMTP_USERNAME) is unset, and for SES# sender-domain provisioning when CUSTOM_MAIL_SES_ACCESS_KEY_ID is# unset. No default.#AWS_ACCESS_KEY_ID= # Since v0.24.0
# AWS region for SES API clients: transactional delivery and the# suppression-list feedback sync. Fallback when the config region is# unset — emailer.region (EMAILER_REGION) for delivery,# email_providers.ses.region (CUSTOM_MAIL_SES_REGION) for the# feedback client. Default: us-east-1.#AWS_REGION=us-east-1 # Since v0.24.0
# AWS secret key paired with AWS_ACCESS_KEY_ID. Fallback for SES# transactional delivery when emailer.pass (SMTP_PASSWORD) is unset,# and for SES sender-domain provisioning when# CUSTOM_MAIL_SES_SECRET_ACCESS_KEY is unset. No default.#AWS_SECRET_ACCESS_KEY= # Since v0.24.0
# [deprecated] No effect. Formerly read by the legacy auth mailer# (removed); auth emails are delivered by the unified Onetime::Mail# system. To log emails instead of sending, use EMAILER_MODE=logger.#EMAIL_DELIVERY_MODE= # Since v0.24.0
# [deprecated] No effect. Formerly read by the legacy auth mailer# (removed). The live auth config hardcodes Rodauth's# email_subject_prefix to '' because templates carry their own# subject prefixes. No replacement.#EMAIL_SUBJECT_PREFIX= # Since v0.24.0
# [deprecated] Use FROM_EMAIL instead. Legacy fallback for the sender# address (emailer.from); read only when FROM_EMAIL is unset. When# both are unset the placeholder CHANGEME@example.com is used.#FROM=
# Reply-To address for outbound email (emailer.reply_to). Falls back# to FROM_EMAIL when unset. Note: the current delivery path resolves# reply-to from template data or a domain's custom sender config, so# this config value has no runtime reader today.#REPLYTO_EMAIL= # Since v0.24.0
# SendGrid API key for transactional email delivery, used when the# provider resolves to sendgrid (EMAILER_MODE=sendgrid, or# auto-detected from an emailer.sendgrid_api_key config value).# Resolution order: emailer.sendgrid_api_key, then emailer.pass# (SMTP_PASSWORD), then this variable. No default.#SENDGRID_API_KEY= # Since v0.24.0
# HELO domain for SMTP delivery (emailer.domain). Passed to the Mail# gem's :domain setting when non-empty; identifies this server to the# receiving SMTP server. Optional; unset by default (the mail library# then uses its own HELO default).#SMTP_DOMAIN= # Since v0.24.0Authentication
Section titled “Authentication”# 'full' enables Rodauth-based auth with PostgreSQL.# Other modes may use simpler auth flows.AUTHENTICATION_MODE=full # Since v0.24.0
AUTH_ENABLED=trueAUTH_SIGNUP=trueAUTH_SIGNIN=true
# false (default): new accounts must verify their email address before# signing in — requires a working SMTP/email provider config.# true: accounts are usable immediately after signup; the right choice for# private/team instances or installs without an email provider.AUTH_AUTOVERIFY=false
# When true, the homepage secret form requires a signed-in user (site# navigation and branding remain visible). Default: false.#AUTH_REQUIRED=false
# Full-mode featuresAUTH_EMAIL_AUTH_ENABLED=true # Since v0.24.0AUTH_LOCKOUT_ENABLED=true # Since v0.24.0AUTH_MFA_ENABLED=false # Since v0.24.0AUTH_WEBAUTHN_ENABLED=false # Since v0.24.0AUTH_PASSWORD_REQUIREMENTS_ENABLED=true # Since v0.24.0AUTH_ACTIVE_SESSIONS_ENABLED=true # Since v0.24.0AUTH_VERIFY_ACCOUNT_ENABLED=true # Since v0.24.0
# Restrict sign-in to a single authentication method. This is an access# control, not a display preference: every other method's routes 404 on# the restricted host, not just hide.# Set at most ONE of these to 'true'. The result maps to# full.restrict_to in auth config (values: password, email_auth,# webauthn, sso). Setting more than one is a FATAL BOOT ERROR naming# every flag set — there is no "restricted to two methods".# The named method must actually be available (feature enabled and# credentials present); if it is not, that is also a FATAL BOOT ERROR.# Post-boot or per-host unavailability degrades to "sign-in# unavailable" — never back to showing all methods.# If none are set, all enabled methods are shown (default).#AUTH_PASSWORD_ONLY=false # Since v0.25.0#AUTH_EMAIL_AUTH_ONLY=false # Since v0.25.0#AUTH_WEBAUTHN_ONLY=false # Since v0.25.0#AUTH_SSO_ONLY=false # Since v0.24.5
# Restrict account creation to specific email domains, comma-separated# (site.authentication.allowed_signup_domains), e.g. "company.com,# partner.org". Applies to password AND SSO signups; a custom domain's# own SignupConfig takes precedence when present. Empty/unset# (default): signups allowed from any domain.#ALLOWED_SIGNUP_DOMAIN=
# Rate limiting for reset-password requests (#3872;# site.authentication.reset_request_rate_limit). Throttles POST# /auth/reset-password-request per client IP and per submitted login# BEFORE any account lookup, bounding the sampling throughput of the# accepted timing residual from #3857. Applies in BOTH auth modes: full# mode enforces it from the Rodauth before_reset_password_request_route# hook, simple mode (the default) from the shared reset-request logic.# Enabled by# default (protective); set to the exact string 'false' to opt out —# other falsey-looking values ('0', 'no', 'off') leave it ENABLED, the# same convention as the other *_ENABLED != 'false' knobs in# etc/defaults/config.defaults.yaml.# See lib/onetime/security/reset_request_rate_limiter.rb.#RESET_REQUEST_RATE_LIMIT_ENABLED=true # Since v0.26.3
# Requests permitted per window from a single client IP before the# per-IP tier locks out# (site.authentication.reset_request_rate_limit.max_per_ip). Default: 10.#RESET_REQUEST_RATE_LIMIT_MAX_PER_IP=10 # Since v0.26.3# NOTE: this tier keys on the RESOLVED client IP. With TRUSTED_PROXY_ENABLED# unset/false (the default) that is REMOTE_ADDR, which behind a reverse proxy# is the proxy's own address for every request — collapsing the tier into one# deployment-wide bucket (this many resets/window for ALL users combined).# Set TRUSTED_PROXY_ENABLED=true, or raise this cap and lean on# RESET_REQUEST_RATE_LIMIT_MAX_PER_EMAIL, which is unaffected.# To clear a stuck lockout: `bin/ots ratelimit keys` only PRINTS valkey-cli# command text (it never touches the datastore itself), so pipe it —# valkey-cli --scan --pattern 'reset_request:locked:ip:*'# bin/ots ratelimit keys reset_request_ip <masked-ip> | grep -v '^#' | valkey-cli# <masked-ip> is the STORED subject: the privacy-masked address (/24 IPv4,# /48 IPv6), not the raw address and not the /16-obscured form the lockout# log line prints. A colonel can instead POST /api/colonel/ratelimit/reset# with kind=reset_request_ip and subject=<masked-ip>, which deletes the same# keys and records an admin audit event.
# Higher backstop cap per submitted login, catching IP-rotating callers# (site.authentication.reset_request_rate_limit.max_per_email).# Default: 30.#RESET_REQUEST_RATE_LIMIT_MAX_PER_EMAIL=30 # Since v0.26.3
# Counting window in seconds for both rate-limit tiers# (site.authentication.reset_request_rate_limit.window).# Default: 3600 (1 hour).#RESET_REQUEST_RATE_LIMIT_WINDOW=3600 # Since v0.26.3
# Lockout duration in seconds once a tier's cap is hit# (site.authentication.reset_request_rate_limit.lockout).# Default: 3600 (1 hour).#RESET_REQUEST_RATE_LIMIT_LOCKOUT=3600 # Since v0.26.3
# Rate limiting for unauthenticated account creation# (site.authentication.create_account_rate_limit). POST /auth/create-account# previously had no limiter in EITHER auth mode, leaving unthrottled account# creation: one customer record (which carries no TTL) plus one welcome email# per distinct address, with subaddressing folding many addresses onto one# mailbox. This setting applies to both modes — full mode enforces it from the# Rodauth before_create_account_route hook, simple mode from the shared# CreateAccount logic class — so the knob means the same thing whichever mode# a deployment runs.# Enabled by default (protective); set to the exact string 'false' to opt out# — other falsey-looking values ('0', 'no', 'off') leave it ENABLED, the same# convention as the other *_ENABLED != 'false' knobs.# See lib/onetime/security/create_account_rate_limiter.rb.#CREATE_ACCOUNT_RATE_LIMIT_ENABLED=true # Since v0.26.3
# Signups permitted per window from a single client IP before the limiter# locks out (site.authentication.create_account_rate_limit.max_per_ip).# Default: 10.#CREATE_ACCOUNT_RATE_LIMIT_MAX_PER_IP=10 # Since v0.26.3# Single tier, keyed on IP alone — there is no per-address backstop and there# cannot be one: every request in the abuse pattern carries a fresh address,# so an address-keyed tier would mint one bucket per request and cap nothing.# NOTE: the same collapse condition as the reset-request tier applies, and it# bites harder here. With TRUSTED_PROXY_ENABLED unset/false (the default) the# resolved IP is REMOTE_ADDR, which behind a reverse proxy is the proxy's own# address for every request — so the cap becomes this many signups/window for# the WHOLE deployment, and a lockout blocks SIGNUP for every new visitor# (not just a recovery flow for existing users). Set TRUSTED_PROXY_ENABLED=true,# or raise this cap; there is no second tier to fall back on.# To clear a stuck lockout: `bin/ots ratelimit keys` only PRINTS valkey-cli# command text (it never touches the datastore itself), so pipe it —# valkey-cli --scan --pattern 'create_account:locked:ip:*'# bin/ots ratelimit keys create_account_ip <masked-ip> | grep -v '^#' | valkey-cli# <masked-ip> is the STORED subject: the privacy-masked address (/24 IPv4,# /48 IPv6), not the raw address and not the /16-obscured form the lockout# log line prints. A colonel can instead POST /api/colonel/ratelimit/reset# with kind=create_account_ip and subject=<masked-ip>, which deletes the same# keys and records an admin audit event.
# Counting window in seconds# (site.authentication.create_account_rate_limit.window).# Default: 3600 (1 hour).#CREATE_ACCOUNT_RATE_LIMIT_WINDOW=3600 # Since v0.26.3
# Lockout duration in seconds once the cap is hit# (site.authentication.create_account_rate_limit.lockout).# Default: 3600 (1 hour).#CREATE_ACCOUNT_RATE_LIMIT_LOCKOUT=3600 # Since v0.26.3
# Remember-me: persistent login across browser sessions via the# "Remember me" checkbox (auth: full.features.remember_me). Cookie# lifetime is Rodauth's default 14 days. Full auth mode only.# Default: true. Set to 'false' to disable.#AUTH_REMEMBER_ME_ENABLED=true # Since v0.24.0
# Reserved: auth service base URL for internal requests (auth:# full.service_url). Currently inert — the config line is commented# out in etc/defaults/auth.defaults.yaml (example default# http://127.0.0.1:3000/auth) and the external-auth token validator# that would consume a service_url is an unimplemented placeholder.#AUTH_SERVICE_URL= # Since v0.24.0
# Enables Rodauth's webauthn_autofill feature: passkey autofill# (browser conditional UI) on the login form (auth:# full.features.webauthn_autofill). Default: false. Only the literal# string 'true' enables — any other value (including '1', 'yes', or# mere presence) leaves it off. Requires AUTH_WEBAUTHN_ENABLED=true# and AUTHENTICATION_MODE=full.#AUTH_WEBAUTHN_AUTOFILL=false # Since v0.26.5
# Enables Rodauth's webauthn_verify_account feature: account email# verification sets up a WebAuthn credential instead of a password# (passwordless signup) (auth: full.features.webauthn_verify_account).# Default: false. Only the literal string 'true' enables — any other# value (including '1', 'yes', or mere presence) leaves it off.# Requires AUTH_WEBAUTHN_ENABLED=true and AUTHENTICATION_MODE=full;# leave AUTH_VERIFY_ACCOUNT_ENABLED on.#AUTH_WEBAUTHN_VERIFY_ACCOUNT=false # Since v0.26.5Organizations
Section titled “Organizations”# Toggle on to show the Organizations context switcher in the UI, next to# the equivalent Domain context switcher. This allows users to update their# default organization settings and add additional organizations to their# account. When billing is enabled, the subscription plans are per-org.ENABLE_ORGS=false # Since v0.25.0
# When enabled, allows organizations with the manage_sso entitlement to# configure SSO for each of their custom domains.ORGS_SSO_ENABLED=false # Since v0.25.0
# When enabled, allows organizations with the custom_mail_sender entitlement# to configure custom sender identity (from name, from address) for their# domains. Uses installation-level email provider credentials.ORGS_CUSTOM_MAIL_ENABLED=false # Since v0.25.0
# When enabled, allows organizations with the incoming_secrets entitlement# to configure incoming secret receiving for their custom domains. Secrets# sent to the domain are routed to the organization's inbox.ORGS_INCOMING_SECRETS_ENABLED=false # Since v0.25.0
# When enabled (the default), organizations with the audit_logs entitlement# can view their secret activity trail (UI + list API). Unlike the flags# above, this defaults ON because the trail works out of the box — set to# false to exclude the feature. Event collection is not affected.ORGS_AUDIT_LOGS_ENABLED=true # Since v0.26.4
# Whether organization secret-activity events are recorded at all (GDPR# data minimization). Enabled by default; set to false to pause collection.# Pausing stops new events from being recorded — existing events stay# readable (pair with ORGS_AUDIT_LOGS_ENABLED=false to also hide them).SECRET_ACTIVITY_COLLECT=true # Since v0.26.4
# Newest secret-activity events retained per organization (floor: 100).# LOWERING this value DELETES (not hides) the oldest events past the new# cap in each org's trail, applied lazily on that org's next recorded# event. Setting SECRET_ACTIVITY_COLLECT=false does not trigger the trim.SECRET_ACTIVITY_MAX_EVENTS=10000 # Since v0.26.4Custom Mail Sender DNS Settings
Section titled “Custom Mail Sender DNS Settings”# These settings control DNS record generation for DKIM, SPF, and related# email authentication when setting up custom mail sender domains.# The provider is selected by CUSTOM_MAIL_PROVIDER (above); these settings# configure that provider's DNS record values.
# Provider for custom mail sender domain provisioning (DNS/DKIM).# Decouples domain provisioning from the sending transport above.# Valid values: ses, sendgrid, lettermint, smtp2go, smtp# If unset, falls back to EMAILER_MODE.#CUSTOM_MAIL_PROVIDER= # Since v0.25.8
# --- AWS SES ---# Select SES as the sender-domain provisioning provider with# CUSTOM_MAIL_PROVIDER=ses (above). See docs/architecture/custom-mail-sender-ses.md# for the full domain-level setup, credentials, and IAM permissions.## AWS region for SES sender-domain provisioning (the SESv2 API client used to# create/get/delete email identities). This is INDEPENDENT of EMAILER_REGION,# which configures the install-level transactional mailer — so you can run, e.g.,# SMTP for transactional email and SES for sender provisioning.# Data-residency regions: ca-central-1 (Canada), ap-southeast-2 (Sydney),# eu-west-1 (Ireland). Confirm SES is available in your target region.# Default: us-east-1#CUSTOM_MAIL_SES_REGION=us-east-1 # Since v0.25.8## AWS credentials for the SES provisioning API, kept independent of the SMTP# transactional mailer. If you run authenticated SMTP for delivery# (EMAILER_MODE=smtp with SMTP_USERNAME/SMTP_PASSWORD), set these (or the# standard AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, which they fall back to)# so the SMTP login is NOT used as the AWS key for SES. Leave unset only when# SES is also the delivery backend (EMAILER_MODE=ses) and the emailer already# carries the AWS keys.#CUSTOM_MAIL_SES_ACCESS_KEY_ID=AKIA... # Since v0.25.9#CUSTOM_MAIL_SES_SECRET_ACCESS_KEY=... # Since v0.25.9
# --- Lettermint ---# Lettermint has TWO separate APIs with different auth:# 1. Sending API - uses x-lettermint-token header (project token)# 2. Team API - uses Authorization: Bearer header (team token)## Project token for Lettermint Sending API (email delivery).# Required when EMAILER_MODE=lettermintLETTERMINT_API_TOKEN= # Since v0.24.0
# Team token for Lettermint Team API (domain provisioning).# Required for custom mail sender domain management (ORGS_CUSTOM_MAIL_ENABLED=true)# https://dash.lettermint.co/team/api-tokensLETTERMINT_TEAM_TOKEN= # Since v0.25.0
# API base URL (for enterprise/on-premise deployments).# Default: https://api.lettermint.co/v1#LETTERMINT_BASE_URL=https://api.lettermint.co/v1 # Since v0.24.0
# --- SMTP2GO ---# SMTP2GO (Christchurch, NZ) backs the NZ region. A single API key# covers API-based sending and sender-domain verification, authenticated# via the X-Smtp2go-Api-Key header.## API key from Sending > API Keys in the SMTP2GO dashboard.# Format: `api-` followed by 32 characters.# Required when EMAILER_MODE=smtp2go or CUSTOM_MAIL_PROVIDER=smtp2goSMTP2GO_API_KEY= # Since v0.26.5
# API base URL override; rarely needed.# Default: https://api.smtp2go.com/v3#SMTP2GO_BASE_URL=https://api.smtp2go.com/v3 # Since v0.26.5
# Return-path subdomain prefix for SMTP2GO sender-domain DNS records# (email_providers.smtp2go.returnpath_subdomain). Becomes a CNAME record# on the customer's sender domain (bounce.yourdomain.com) so the envelope# sender aligns for SPF. Default: bounce#CUSTOM_MAIL_SMTP2GO_RETURNPATH_SUBDOMAIN=bounce # Since v0.26.5
# Tracking subdomain prefix for SMTP2GO sender-domain DNS records# (email_providers.smtp2go.tracking_subdomain). Becomes a CNAME record# on the customer's sender domain (track.yourdomain.com) for open/click# tracking. Default: track#CUSTOM_MAIL_SMTP2GO_TRACKING_SUBDOMAIN=track # Since v0.26.5
# Accept SMTP2GO messages asynchronously for faster API responses.# When enabled, synchronous rejection details are unavailable; use webhooks# for delivery results. Leave disabled for sender-configuration test emails# and only enable if you aren't running the background workers and you# find that requests the trigger an email send are taking too long to load.# Default: false#CUSTOM_MAIL_SMTP2GO_FASTACCEPT=false # Since v0.26.5
# --- SendGrid ---# Coming soon...
# SPF CNAME subdomain prefix for Lettermint sender-domain DNS records# (email_providers.lettermint.spf_cname_prefix). 'lm-bounces' creates# lm-bounces.yourdomain.com pointing at the SPF CNAME target below.# Default: lm-bounces#CUSTOM_MAIL_LETTERMINT_SPF_CNAME_PREFIX=lm-bounces # Since v0.25.8
# SPF CNAME target domain for Lettermint sender-domain DNS records# (email_providers.lettermint.spf_cname_target). Lettermint maintains# the SPF record at this target. Default: bounces.lmta.net#CUSTOM_MAIL_LETTERMINT_SPF_CNAME_TARGET=bounces.lmta.net # Since v0.25.8
# Branding subdomain prefix for SendGrid sender-domain DNS records# (email_providers.sendgrid.subdomain); appears in the generated# CNAME record names. Letters, digits, and hyphens only. Default: em#CUSTOM_MAIL_SENDGRID_SUBDOMAIN=em # Since v0.25.8SSO / OmniAuth
Section titled “SSO / OmniAuth”# Master toggle for SSO. Must be true for any provider below.AUTH_SSO_ENABLED=false # Since v0.24.0
# [deprecated] Use per-provider DISPLAY_NAME vars instead.#SSO_DISPLAY_NAME= # Since v0.24.0
# --- Generic OIDC provider ---# Required: OIDC_ISSUER, OIDC_CLIENT_ID# Optional: OIDC_CLIENT_SECRET (omit for PKCE-only flows)OIDC_ISSUER= # Since v0.24.0OIDC_CLIENT_ID= # Since v0.24.0OIDC_CLIENT_SECRET= # Since v0.24.0#OIDC_ROUTE_NAME=oidc # Since v0.24.0# Default false. Opt-in email-based SSO linking; single-tenant trusted-IdP only.#OIDC_TRUST_EMAIL_FOR_LINKING=false # Since v0.26.2
# --- Microsoft Entra ID ---# Required: ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRETENTRA_TENANT_ID= # Since v0.24.3ENTRA_CLIENT_ID= # Since v0.24.3ENTRA_CLIENT_SECRET= # Since v0.24.3#ENTRA_ROUTE_NAME=entra # Since v0.24.3#ENTRA_DISPLAY_NAME=Microsoft # Since v0.24.3# Default false. Opt-in email-based SSO linking; single-tenant trusted-IdP only.#ENTRA_TRUST_EMAIL_FOR_LINKING=false # Since v0.26.2
# --- GitHub ---# Required: GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRETGITHUB_CLIENT_ID= # Since v0.24.3GITHUB_CLIENT_SECRET= # Since v0.24.3#GITHUB_ROUTE_NAME=github # Since v0.24.3#GITHUB_DISPLAY_NAME=GitHub # Since v0.24.3# Default false. Opt-in email-based SSO linking; single-tenant trusted-IdP only.#GITHUB_TRUST_EMAIL_FOR_LINKING=false # Since v0.26.2
# --- Google ---# Required: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRETGOOGLE_CLIENT_ID= # Since v0.24.3GOOGLE_CLIENT_SECRET= # Since v0.24.3#GOOGLE_ROUTE_NAME=google # Since v0.24.3#GOOGLE_DISPLAY_NAME=Google # Since v0.24.3# Default false. Opt-in email-based SSO linking; single-tenant trusted-IdP only.#GOOGLE_TRUST_EMAIL_FOR_LINKING=false # Since v0.26.2
# Extra origins for the CSP form-action directive (space-separated).# IdP origins are auto-derived on two levels: each active platform# provider's origin is added at boot, and a custom domain's tenant SSO# issuer origin is added per-request from its per-domain SSO config# (#4173) — so Chromium does not block the SSO form-POST redirect on# either surface. Configure sovereign Microsoft Entra as generic OIDC# with its sovereign issuer; that origin is derived automatically. Set# this only when the auto-derived origin is wrong or incomplete: an OIDC# issuer whose authorization_endpoint lives on a different origin than# the issuer, or a per-request tenant derivation gap. The tenant widening# depends on the resolved display domain: if the domain record is# unreachable at request time it is skipped silently (browser-console-only# symptom), and this override is the manual fallback for such gap cases.# See #3848, #4173.#SSO_FORM_ACTION_ORIGINS= # Since v0.26.2
# Login-button ordering for SSO providers: a comma/space-separated list of# route names (e.g. "google, github, oidc"). Listed providers appear first# in the given order; unlisted ones keep their registry order after them.# Unset = registry order (lib/onetime/sso_provider/registry.rb).#SSO_PROVIDER_ORDER= # Since v0.26.5
# Multi-tenant SSO fallback policy (auth:# full.sso.allow_platform_fallback_for_tenants). When true, custom# domains without their own per-domain SSO config may piggyback on the# platform's ENV-based SSO credentials — convenient for single-tenant,# a security concern in multi-tenant. Default: false ('true' enables).#SSO_ALLOW_PLATFORM_FALLBACK=false # Since v0.25.0
# Global/deprecated fallback for the per-provider *_TRUST_EMAIL_FOR_LINKING vars# above (single-OIDC case; a per-provider var, when set, takes precedence).# Default false. Opt-in email-based SSO linking; single-tenant trusted-IdP only.## Opting a single provider OUT of a global true requires an EXPLICIT =false:# with SSO_TRUST_EMAIL_FOR_LINKING=true, a provider whose *_TRUST_EMAIL_FOR_LINKING# is simply omitted INHERITS the global true. To disable one provider, set it# explicitly, e.g. GITHUB_TRUST_EMAIL_FOR_LINKING=false.#SSO_TRUST_EMAIL_FOR_LINKING=false # Since v0.26.2
# [deprecated] Not consumed by this app — appears only in the vendored# Rodauth reference doc. Use GITHUB_CLIENT_ID: the GitHub SSO# provider's OAuth client id (with GITHUB_CLIENT_SECRET).#GITHUB_KEY= # Since v0.26.0
# [deprecated] Not consumed by this app — appears only in the vendored# Rodauth reference doc. Use GITHUB_CLIENT_SECRET: the GitHub SSO# provider's OAuth client secret (with GITHUB_CLIENT_ID).#GITHUB_SECRET= # Since v0.26.0
# [deprecated] Not consumed by this app — appears only in the vendored# Rodauth reference doc. Use GOOGLE_CLIENT_ID: the Google SSO# provider's OAuth client id (with GOOGLE_CLIENT_SECRET).#GOOGLE_KEY= # Since v0.26.0
# [deprecated] Not consumed by this app — appears only in the vendored# Rodauth reference doc. Use GOOGLE_CLIENT_SECRET: the Google SSO# provider's OAuth client secret (with GOOGLE_CLIENT_ID).#GOOGLE_SECRET= # Since v0.26.0Stripe Billing
Section titled “Stripe Billing”STRIPE_API_KEY= # Since v0.24.0PUBLIC_STRIPE_API_KEY= # Since v0.24.1STRIPE_WEBHOOK_SIGNING_SECRET= # Since v0.24.0
# Bare host of a Stripe custom Checkout domain (live-mode only), e.g.# pay.onetimesecret.com. When set, the frontend allowlists checkout URLs# served from this host in addition to checkout.stripe.com. Leave unset# unless a Stripe custom domain is configured. Wins over billing.yaml# `checkout_host`. Validated at boot when billing is enabled — the check runs# in Billing::Initializers::StripeSetup (BillingConfig#validate_checkout_host!),# which is skipped when billing is disabled. A malformed value# (scheme/path/userinfo/out-of-range port) fails startup, not a later checkout.#STRIPE_CHECKOUT_HOST= # Since v0.26.1
# Enable Stripe automatic tax on checkout sessions. 'true'/'1' enable,# 'false'/'0' disable, unset or blank means off; any other value fails# startup (Onetime::ConfigError) — an unrecognized token used to silently# disable tax collection. Deployment-level policy applied by# BillingConfig#automatic_tax? to all checkout sessions — not a per-link# choice. Wins over billing.yaml `automatic_tax`. Requires Stripe Tax to be# configured in the Dashboard (tax registrations + product tax codes) first.#STRIPE_AUTOMATIC_TAX= # Since v0.26.3
# Stripe payment method configuration ID (pmc_...) to pin on checkout# sessions. When set, checkout uses this specific configuration instead# of the Dashboard default. Wins over billing.yaml# `payment_method_configuration`. Leave unset to use the Dashboard# default. A blank-but-set value is treated as explicitly unset and does# NOT fall back to the billing.yaml key. A malformed value (anything not# starting with pmc_) fails startup (Onetime::ConfigError) when billing# is enabled, instead of failing at a customer's first checkout. Find# IDs at Settings → Payments → Payment methods.#STRIPE_PAYMENT_METHOD_CONFIGURATION= # Since v0.26.5
# Billing catalog currency as a lowercase ISO 4217 code (billing.yaml# `currency` key, via ERB in etc/billing.yaml copied from# etc/examples/billing.example.yaml). Wins over CURRENCY when both are# set; BillingConfig falls back to 'cad' when neither is set.#BILLING_CURRENCY=cad # Since v0.24.0
# Master switch for Stripe billing (billing.yaml `enabled`). When set,# 'true'/'1' enable and 'false'/'0' disable, overriding the config file;# blank counts as explicitly off; any other value fails startup# (Onetime::ConfigError — BILLING_ENABLED=1 used to silently mean off).# When unset, billing.yaml decides — the example config enables# billing whenever the file exists; with no billing.yaml, billing# is off.#BILLING_ENABLED= # Since v0.24.0
# Alias for BILLING_CURRENCY, checked second in the same ERB chain in# etc/billing.yaml (from etc/examples/billing.example.yaml). Prefer# BILLING_CURRENCY, which wins when both are set. Lowercase ISO 4217# code; effective default cad.#CURRENCY=cad # Since v0.25.3Custom Domains
Section titled “Custom Domains”DOMAINS_ENABLED=falseDEFAULT_DOMAIN=
# Comma-separated hosts offered in the link-domain picker. For installs# whose canonical host is an internal platform address that must keep# serving the app but must never be offered to customers. Every entry# also joins the canonical host set, so those hosts serve normally.# unset -> the picker offers the canonical domain (default)# set -> exactly these hosts; canonical only if listed# set-but-empty -> boot error (this is why the line below is commented# out rather than left bare like DEFAULT_DOMAIN above)# A set list naming only hosts that cannot be parsed (e.g. an internal# hostname with no public suffix) is also a boot error, and the message# echoes the entries so the typo is easy to spot. Mixing a good host# with a bad one boots; the bad one is dropped and logged.#LINK_DOMAINS=links.example.com,short.example.net # Since v0.26.5
# Validation strategy: passthrough (default), approximated, caddy_on_demandDOMAINS_VALIDATION_STRATEGY=passthrough # Since v0.24.0
# Approximated provider settings (when strategy=approximated)APPROXIMATED_API_KEY=APPROXIMATED_PROXY_IP= # Since v0.24.0APPROXIMATED_PROXY_HOST= # Since v0.24.0APPROXIMATED_PROXY_NAME= # Since v0.24.0APPROXIMATED_VHOST_TARGET= # Since v0.24.0
# Internal ACME 'ask' endpoint for Caddy on-demand TLS, used by the# caddy_on_demand validation strategy (features.domains.acme.enabled).# When true, the ACME app auto-mounts inside the main process at# /api/internal/acme (localhost-only). When false, run it standalone:# rackup apps/internal/acme/config.ru. Default: false.#ACME_ENDPOINT_ENABLED=false # Since v0.24.0
# Bind address for the standalone ACME endpoint process# (features.domains.acme.listen_address). Only applies when running# rackup apps/internal/acme/config.ru (ACME_ENDPOINT_ENABLED=false);# should stay on localhost. HOST env / rackup -o override it.#ACME_LISTEN_ADDRESS=127.0.0.1 # Since v0.24.0
# Port for the standalone ACME endpoint process# (features.domains.acme.port). Caddy's on_demand_tls ask directive# points here (e.g. http://127.0.0.1:12020/api/internal/acme/ask).# Standalone mode only; PORT env / rackup -p override it.#ACME_PORT=12020 # Since v0.24.0
# When true, secret creation naming an unverified custom share_domain# is rejected with a form error (features.domains.require_verified).# Canonical domains are unaffected. The check fires whenever a custom# share_domain resolves to a domain record — it is not gated by# DOMAINS_ENABLED. Default: false.#DOMAINS_REQUIRE_VERIFIED=false # Since v0.25.2Feature Flags
Section titled “Feature Flags”REGIONS_ENABLED=false# This instance's jurisdiction ID (e.g. EU).JURISDICTION=# Available jurisdictions as ID:domain pairs (e.g., EU:eu.example.com,CA:ca.example.com)JURISDICTIONS= # Since v0.25.5
I18N_ENABLED=trueI18N_DEFAULT_LOCALE=en
# Enable the incoming secrets feature on the CANONICAL domain (anonymous users# send encrypted secrets to pre-configured recipients via /incoming). This flag# does NOT gate custom domains — those are governed by the org's incoming_secrets# entitlement + ORGS_INCOMING_SECRETS_ENABLED. To emergency-disable incoming on# custom domains, revoke the entitlement or set ORGS_INCOMING_SECRETS_ENABLED=false.INCOMING_ENABLED=falseINCOMING_RECIPIENT_1=
FOOTER_LINKS=truePRICING_URL=/pricing # Since v0.24.1# Legal & policy URLs (site.legal, #4278). TERMS_URL / PRIVACY_URL also drive# the signup consent links and the branded reveal footer; all six render in# the footer "Legals" column when FOOTER_LINKS=true. A URL that is unset or# blank makes the link absent everywhere — no placeholder, no dead link.# Absolute URLs (https://...) open in a new tab; relative paths (e.g. /terms)# resolve in-app.TERMS_URL=/termsPRIVACY_URL=/privacyDPA_URL= # Since v0.26.8COOKIE_URL= # Since v0.26.8AUP_URL= # Since v0.26.8SECURITY_URL= # Since v0.26.8STATUS_URL=ABOUT_URL=/aboutCONTACT_URL=/feedback
# Footer "Docs" link (public pages). Also sets docs_host in the bootstrap# payload — the documentation host exposed to the frontend, which replaced# the retired site.support.host (#1461).# Default when unset: https://docs.onetimesecret.com/#DOCS_URL=https://docs.onetimesecret.com/ # Since v0.25.0
# Documentation/support host. Sets site.support.host in the bootstrap# payload. Note: this does NOT repoint the workspace footer links below —# those resolve to WORKSPACE_API_DOCS_URL / WORKSPACE_BRANDING_GUIDE_URL# (or their built-in defaults). Default: unset.#SUPPORT_HOST=
# Workspace footer links (authenticated users)# Master toggle — workspace footer links are OFF unless set to 'true'.#WORKSPACE_LINKS=false # Since v0.25.0# Override the API Docs and Branding Guide link URLs. Leave blank to use# the built-in defaults (https://api.onetimesecret.com/ and# https://docs.onetimesecret.com/).WORKSPACE_API_DOCS_URL= # Since v0.24.6WORKSPACE_BRANDING_GUIDE_URL= # Since v0.24.6WORKSPACE_FEEDBACK_URL=/feedback # Since v0.24.6# Show the workspace feedback link. Set to 'false' to hide. Default: true.#WORKSPACE_FEEDBACK_ENABLED=true # Since v0.25.5
# Show version number in all footers (default: true)# Set to 'false' to hide version across all footer componentsFOOTER_VERSION_ENABLED=true # Since v0.25.5
# Date display format (internationalization.date_format). Accepts a# preset keyword or a raw date-fns pattern. Setting this alone controls# both date-only and date+time display. Presets: locale (browser-native,# default) | iso8601 | us | eu | eu-dot | uk | long.#I18N_DATE_FORMAT=locale # Since v0.24.6
# Date+time display format (internationalization.datetime_format).# Same preset keywords as I18N_DATE_FORMAT, or a raw date-fns pattern.# Only set when date+time contexts need a different format: left at# the default 'locale', the frontend falls back to the datetime# variant of I18N_DATE_FORMAT (so one setting covers both).#I18N_DATETIME_FORMAT=locale # Since v0.24.6API Access
Section titled “API Access”# Whether the JSON API is advertised as enabled# (site.interface.api.enabled). Only surfaced to the web frontend# via the bootstrap config (the account API settings page shows a# disabled notice); it does NOT block /api/* requests server-side.# Default: true; set to 'false' to mark the API disabled in the UI.#API_ENABLED=true
# Allow anonymous callers to burn (destroy unread) a secret via the# guest API (site.interface.api.guest_routes.burn). Checked only# when API_GUEST_ROUTES_ENABLED is true; authenticated requests are# never gated. Default: true; set to 'false' to disable.#API_GUEST_BURN=true
# Allow anonymous callers to create a secret from their own value# via the guest API (site.interface.api.guest_routes.conceal).# Checked only when API_GUEST_ROUTES_ENABLED is true; authenticated# requests are never gated. Default: true; 'false' to disable.#API_GUEST_CONCEAL=true
# Allow anonymous callers to create a random generated secret via# the guest API (site.interface.api.guest_routes.generate). Checked# only when API_GUEST_ROUTES_ENABLED is true; authenticated requests# are never gated. Default: true; set to 'false' to disable.#API_GUEST_GENERATE=true
# Allow anonymous callers to view secret receipts (creator-side# status/metadata, single or batch) via the guest API# (site.interface.api.guest_routes.receipt). Checked only when# API_GUEST_ROUTES_ENABLED is true; authenticated requests are# never gated. Default: true; set to 'false' to disable.#API_GUEST_RECEIPT=true
# Allow anonymous callers to reveal (decrypt and consume) a secret# via the guest API (site.interface.api.guest_routes.reveal).# Checked only when API_GUEST_ROUTES_ENABLED is true; authenticated# requests are never gated. Default: true; 'false' to disable.#API_GUEST_REVEAL=true
# Global toggle for anonymous (guest) API access# (site.interface.api.guest_routes.enabled). When false, all guest# secret operations are rejected with GUEST_ROUTES_DISABLED# regardless of the per-operation flags below. Authenticated# requests are unaffected. Default: true; 'false' to disable.#API_GUEST_ROUTES_ENABLED=true
# Allow anonymous callers to retrieve a secret via the guest API# show endpoint — metadata, plus the decrypted value when called# with continue=true (site.interface.api.guest_routes.show).# Checked only when API_GUEST_ROUTES_ENABLED is true; authenticated# requests are never gated. Default: true; 'false' to disable.#API_GUEST_SHOW=trueIncoming Secrets
Section titled “Incoming Secrets”# Optional passphrase applied to every secret submitted through the# incoming form (features.incoming.default_passphrase). Recipients must# enter it to reveal the secret. Global — applies to custom-domain# submissions too (not per-domain yet). Empty (default) = no passphrase.#INCOMING_DEFAULT_PASSPHRASE=
# Lifetime in seconds for secrets created via the incoming form on the# canonical domain (features.incoming.default_ttl). Coerced to integer# seconds at boot; custom domains use their per-domain IncomingConfig# instead. Default: 604800 (7 days).#INCOMING_DEFAULT_TTL=604800
# Maximum character length for the memo/subject field on the incoming# form, canonical domain (features.incoming.memo_max_length). Longer# memos are truncated server-side. Custom domains use their per-domain# IncomingConfig. Default: 50 characters.#INCOMING_MEMO_MAX_LENGTH=50
# Second pre-configured recipient for canonical-domain incoming secrets# (features.incoming.recipients). Format: email[,display name]; name# defaults to the email's local part. Emails are hashed with site.secret# at boot and never exposed in API responses. Requires# INCOMING_ENABLED=true. See INCOMING_RECIPIENT_1.#INCOMING_RECIPIENT_2=
# Third pre-configured recipient for canonical-domain incoming secrets# (features.incoming.recipients). Format: email[,display name].# Requires INCOMING_ENABLED=true. See INCOMING_RECIPIENT_1.#INCOMING_RECIPIENT_3=
# Fourth pre-configured recipient for canonical-domain incoming secrets# (features.incoming.recipients). Format: email[,display name]. Slot 4# is the last env-wired slot; additional recipients must be listed in# etc/config.yaml directly. Requires INCOMING_ENABLED=true.# See INCOMING_RECIPIENT_1.#INCOMING_RECIPIENT_4=
# Rate limiting for anonymous incoming-secret submissions (AZ9;# features.incoming.rate_limit). Throttles per client IP and per# client-supplied recipient hash BEFORE any secret is created or email is# sent. Enabled by default (protective); set to 'false' to opt out.# See lib/onetime/security/incoming_rate_limiter.rb. Default: enabled.#INCOMING_RATE_LIMIT_ENABLED=true # Since v0.26.0
# Submissions permitted per window from a single client IP before the# per-IP tier locks out (features.incoming.rate_limit.max_per_ip).# Default: 10.#INCOMING_RATE_LIMIT_MAX_PER_IP=10 # Since v0.26.0
# Higher backstop cap per recipient hash, catching IP-rotating spam# (features.incoming.rate_limit.max_per_recipient). Default: 30.#INCOMING_RATE_LIMIT_MAX_PER_RECIPIENT=30 # Since v0.26.0
# Counting window in seconds for both rate-limit tiers# (features.incoming.rate_limit.window). Default: 3600 (1 hour).#INCOMING_RATE_LIMIT_WINDOW=3600 # Since v0.26.0
# Lockout duration in seconds once a tier's cap is hit# (features.incoming.rate_limit.lockout). Default: 3600 (1 hour).#INCOMING_RATE_LIMIT_LOCKOUT=3600 # Since v0.26.0Reverse Proxy / Client IP
Section titled “Reverse Proxy / Client IP”# Site-wide client IP resolution (config site.network.trusted_proxy).# Affects ban checks, session tracking, audit logs, rate limiting, and the# Colonel "Your Current IP" display.## WARNING: Only enable when the app is behind a trusted reverse proxy, direct# client access is blocked by firewall, and the proxy strips/overwrites# client-provided forwarding headers.
# Turns proxy-aware IP resolution on. The mode, CIDR, and depth settings# below do nothing while this is false.## When false (default), forwarded headers are ignored and the client IP is# REMOTE_ADDR (the address that opened the connection). That is right only# when clients connect to the app directly: put a proxy in front and# REMOTE_ADDR is the proxy, so every request looks like it came from one# address. When true, the client IP is read out of the forwarded headers# using the mode below. Behind a proxy you trust, set this to true. Behind one# you don't trust to overwrite X-Forwarded-For, set true and set the mode# below to depth, which picks the client by its position in the chain.TRUSTED_PROXY_ENABLED=false # Since v0.25.5
# How to resolve client IP behind proxies. Default: filter.# - filter: CIDR-walk. Walk the forwarded chain LEFT-TO-RIGHT and return the# first entry that is not a trusted proxy — i.e. the LEFTMOST non-proxy# entry becomes the client IP. Trusted = RFC1918/loopback/link-local# (10.x, 127.x, 169.254.x, 172.16-31.x, 192.168.x, ::1, fc00::/7,# fe80::/10) plus every range in TRUSTED_PROXY_CIDRS. Works for most# k8s/cloud deployments where proxies have internal IPs.## SECURITY: because the leftmost entry wins, the edge proxy MUST OVERWRITE# X-Forwarded-For with the real peer address, otherwise a client-supplied# entry is returned as the client IP and IP rate limits/bans are spoofable:# nginx: proxy_set_header X-Forwarded-For $remote_addr;# Caddy: header_up X-Forwarded-For {client_ip}# ({client_ip} honours Caddy's own trusted_proxies, so it resolves the real# visitor when Caddy is itself behind a declared CDN; with none declared it# is the direct peer, same as {remote_host}. See etc/examples/Caddyfile-example.)# Use depth mode when you cannot make the edge overwrite. Combining the# overwrite above with depth mode is correct ONLY for TRUSTED_PROXY_DEPTH=1# (the overwrite leaves exactly one attested entry, which depth 1 selects);# a depth of 2 or more needs each counted hop to append one entry, and the# overwrite collapses that chain, so resolution falls back to the peer.## Note: filter mode reads the X-Forwarded-For family only (X-Forwarded-For,# X-Real-IP, X-Client-IP) and REJECTS any other TRUSTED_PROXY_HEADER at boot.# - depth: position-based; skip exactly TRUSTED_PROXY_DEPTH rightmost hops.# Counts positions from the RIGHT of the chain, so extra leftmost entries# (forged or from farther upstream) never shift the selection. Requires a# FIXED hop count with each counted hop appending exactly one entry: a# chain shorter than the depth falls back to the peer, and a depth larger# than the real hop count selects a client-supplied entry. Use for proxies# with public IPs that filter mode would misread as the client, or when# you need deterministic hop selection regardless of IP class.## Matched case-insensitively and canonicalized (as TRUSTED_PROXY_HEADER is).# Anything outside the closed set falls back to filter — the safer mode — and# WARNs at boot naming the value; the startup `trusted_proxy:` posture line# always reports the mode actually in force, not the value configured here.TRUSTED_PROXY_MODE=filter # Since v0.25.5
# depth mode only: which header to read the forwarding chain from.# Default: X-Forwarded-For. The accepted set is closed — exactly one of:# - X-Forwarded-For the de facto standard, written by nginx, Caddy, HAProxy,# ALB/ELB, Cloudflare, Fastly, and most k8s ingresses# - Forwarded RFC 7239 (for=/by=/proto=), emitted by HAProxy and Apache# when configured for it; still uncommon in practice# - Both read Forwarded first, fall back to X-Forwarded-For# Case-insensitive, so `forwarded` is accepted and canonicalized. Any other# value fails the boot with an ArgumentError rather than silently resolving# from the wrong header.## Vendor client-IP headers are NOT selectable here — CF-Connecting-IP,# True-Client-IP and friends are never read for client-IP resolution. They# also carry a single address rather than a chain, so there are no hops to# count. (Vendor GEO headers such as CF-IPCountry are a separate concern with# its own resolution path; this setting does not affect them.) If your# edge only sets one of those, have it write the chain into X-Forwarded-For:# Cloudflare: enable "Add visitor IP headers" (sets X-Forwarded-For), or# transform-rule X-Forwarded-For = cf.connecting_ip# Filter mode reads the X-Forwarded-For family only (X-Forwarded-For,# X-Real-IP, X-Client-IP) and never RFC 7239 Forwarded: with# TRUSTED_PROXY_MODE=filter, any value here other than X-Forwarded-For FAILS# THE BOOT with a message naming both settings. Leave it at the default (or# unset) unless the mode is depth.TRUSTED_PROXY_HEADER=X-Forwarded-For # Since v0.25.5
# filter mode only: additional CIDR ranges to trust as proxies beyond the# RFC1918 defaults (e.g. a CDN/proxy with public IPs). Comma-separated.# Example: 203.0.113.0/24,2001:db8::/32## SECURITY: listing a range here is a full infrastructure-trust grant, not# just client-IP trust. Requests arriving from these ranges also have their# forwarded HOST headers honored (X-Forwarded-Host, X-Original-Host,# Apx-Incoming-Host) for custom-domain detection. The proxy MUST# therefore set or strip those headers itself rather than pass them through# from clients — a pass-through proxy lets a client pick the tenant domain# the app renders. This is the same requirement stated above for# X-Forwarded-For, extended to the host headers. RFC 7239 Forwarded's host=# parameter is never a host source: an edge that carries the public host# only there must preserve Host or send X-Forwarded-Host instead.#TRUSTED_PROXY_CIDRS= # Since v0.25.5
# depth mode only: number of proxy hops to skip from the right. Default: 1.# 1 = standard single reverse proxy (nginx/Caddy)# 2 = CDN -> reverse proxy -> app# For direct/no-proxy deployments set TRUSTED_PROXY_ENABLED=false above# rather than depth: 0.TRUSTED_PROXY_DEPTH=1 # Since v0.25.5
# ---- Country-level geo resolution (config site.network.geo, #3989) ----------# Otto resolves an ISO-3166-1 alpha-2 country code into# env['otto.privacy.geo_country'] (or '**' unknown — never a guess), consumed# by the login/MFA new-sign-in alert emails, the Colonel session sidecar, and# — opt-in — the org Secret Activity trail (SECRET_ACTIVITY_GEO_COUNTRY_ENABLED).# Geo is ON by default; nothing here is required to get country from a CDN.## TRUST MODEL: vendor GEO headers (Cloudflare CF-IPCountry, CloudFront, Fastly,# Akamai, Azure, Vercel) are honored ONLY in FILTER mode with# TRUSTED_PROXY_ENABLED=true and the CDN's ranges in TRUSTED_PROXY_CIDRS. Out of# the box, or in DEPTH mode, header geo is NOT trusted and country is '**'.
# Optional app-level geo header, checked before the built-in vendor headers.# FILTER MODE ONLY (setting it under depth mode trips Otto's boot-time# depth/geo_header conflict, so it is ignored there). Blank = built-ins only.#GEO_HEADER= # Since v0.26.5
# Optional path to a local MaxMind country database (.mmdb) for deployments# without a geo-tagging CDN (direct-connect, or depth mode). Looked up on the# ALREADY-MASKED IP. Works in all modes. Requires the optional 'maxmind-db'# gem; a bad path / missing gem fails at boot. Blank = disabled.#GEO_DB_PATH= # Since v0.26.5
# Country column on the org Secret Activity trail (config# features.secret_activity.geo_country_enabled). DEFAULT-OFF / opt-in — the# inverse of the other secret-activity flags — pending counsel review of# org-tier geo exposure. Set to true to enable once reviewed.#SECRET_ACTIVITY_GEO_COUNTRY_ENABLED=false # Since v0.26.5
# Assume HTTPS at the origin (config site.network.assume_https). Opt-in and# INDEPENDENT of TRUSTED_PROXY_*. Upgrade-only: when true, requests that do not# already look like HTTPS are marked as HTTPS before any downstream consumer# reads the scheme (Secure session cookie, HttpOrigin, CSRF, HSTS, scheme# redirects). Only enable when a real TLS-terminating proxy fronts the origin# AND it does not forward X-Forwarded-Proto: https (e.g. Cloudflare Tunnel).# Standard nginx/Caddy/ALB setups forward the scheme and do not need this.# Never enable on a directly-reachable origin. Keep SSL=true (site.ssl) when# this is on: pairing ASSUME_HTTPS=true with site.ssl:false makes generated# share/email/redirect links emit http:// while clients use https. Default: false.ASSUME_HTTPS=false # Since v0.26.2
# Admin (Colonel) host allowlist (config site.admin.allowed_hosts).# Comma-separated hostnames that serve /colonel and /api/colonel; any other host# gets the same 404. Unset (default) = the canonical hosts (DEFAULT_DOMAIN# / HOST) plus their www. variants, so tenant custom domains and LINK_DOMAINS# stop serving the admin console. Set but BLANK ("" or only whitespace/commas)# = the same canonical fallback, plus a boot WARN (#4127): a written allowlist# that names nothing is announced rather than silently reading as unset --# on a localhost/bare-IP install it yields no host gate at all (see below).# Explicit entries match literally. "*"# ANYWHERE in the list disables the host gate, logged at WARN, and is the# one-variable rollback. Punycode (xn--) form required for internationalized# domains.# A value that can never match (an IP literal, localhost, *.example.com, a# non-ASCII name) leaves the gate ACTIVE and EMPTY: /colonel and /api/colonel# 404 on every hostname, with a boot WARN naming each rejected entry. The boot# itself is NOT aborted -- the rest of the app is unaffected. Only the# unset/fallback case self-disables, with a boot WARN, on installs with no# routable hostname (the stock HOST=localhost:3000, or a bare-IP install) --# there the fallback has nothing to anchor on, so unsetting turns the gate OFF# rather than restricting to a canonical host; it is not a hardening step on# such installs.# Behind a proxy that forwards the public hostname in a header instead of# rewriting Host, TRUSTED_PROXY_* must be configured with the proxy's own# ranges in TRUSTED_PROXY_CIDRS or the admin gate refuses the forwarded host# and both surfaces 404. Filter mode with no explicit CIDRs trusts every# private-network peer as a proxy, which restores exactly the forwarded-host# spoofing the provenance rule exists to block.# THIS AND ADMIN_ALLOWED_CIDRS ARE ALSO WHAT PROMOTE the 15 destructive colonel# routes (purge, role change, revoke, delete, DLQ purge) from an ADVISORY# network requirement to an ENFORCED one (#4332). Set BOTH or neither: setting# only one gives you neither promotion and logs a boot WARN saying so. A# wildcard here does not count as set for this purpose.# Example: admin.example.com#ADMIN_ALLOWED_HOSTS= # Since v0.26.5
# Admin (Colonel) network isolation (config site.admin.allowed_cidrs).# Optional CIDR allowlist for the Colonel admin surfaces (/colonel shell and# /api/colonel API). Comma-separated. When set, a request whose# trusted-proxy-resolved client IP is OUTSIDE the allowlist gets a 404# (indistinguishable-from-absent, not a 403) on both surfaces — defense-in-depth# on top of the two app-layer auth layers, which still enforce beneath it.## Unset/empty (default): NO-OP — both surfaces reachable, gated only by the auth# layers. Correct for self-hosted single-container installs (which cannot# require a VPN); those can instead front the surfaces with a reverse proxy.# Set to PRIVATE ranges only on cloud (e.g. a Tailscale/VPN CGNAT range).# Behind a proxy you must also configure TRUSTED_PROXY_* above (with the# proxy's own ranges in TRUSTED_PROXY_CIDRS) or every request resolves to the# proxy hop; a raw X-Forwarded-For header cannot bypass this.# Client IPs arrive privacy-masked (last IPv4 octet zeroed = /24, IPv6 to# /48), but matching runs at full precision through the verdict-only true-IP# matcher the privacy layer installs, so /32 and /128 entries work — see# docs/operations/admin-network-isolation.md, "CIDR precision and privacy# masking".# Paired with ADMIN_ALLOWED_HOSTS above, this is what promotes the 15# destructive colonel routes from an advisory network requirement to an# enforced one (#4332). Set BOTH or neither.# Example: 100.64.0.0/10,10.0.0.0/8#ADMIN_ALLOWED_CIDRS= # Since v0.26.0
# Base URL of the standalone Rodauth Admin instance (config# site.admin.rodauth_admin_url). Optional and credential-free: the colonel# console only renders outbound links to it (customer detail -> the matching# Rodauth account by external id; the sessions console -> the full-mode# session authority). Nothing is ever requested from it. Unset or blank# renders those links as plain text. Only meaningful in full auth mode.# Example: http://127.0.0.1:9292#RODAUTH_ADMIN_URL= # Since v0.26.12
# Step-up (sudo) re-authentication for destructive colonel actions# (config site.admin.elevation, issue #4327). ON BY DEFAULT.# A colonel session alone is no longer sufficient for a tier-1 verb (purge# account, change role, revoke sessions, delete org/domain/secret, DLQ purge):# the operator must have re-proven a credential through# POST /api/colonel/elevation within the window. Confirmation (#4326) still# applies on top. Set to false to restore pre-#4327 behaviour.#COLONEL_ELEVATION_ENABLED=true # Since v0.26.11# Window length in seconds (default 600 = 10 minutes).#COLONEL_ELEVATION_WINDOW=600 # Since v0.26.11# Seconds after sign-in during which a PASSWORD-LESS (SSO-only) colonel may# elevate with factor=recent_auth and no credential. DEFAULT 0 = OFF.# Available ONLY to accounts that cannot satisfy the password factor: giving it# to password holders would make step-up a no-op for the first N seconds after# every sign-in. In FULL auth mode every account counts as password-holding# (the Rodauth hash table is not reachable from the logic layer), so recent_auth# is unavailable there and an SSO-only full-mode fleet sets# COLONEL_ELEVATION_ENABLED=false instead. MFA as a step-up factor is not# implemented. AN SSO-ONLY FLEET MUST CONFIGURE THIS OR COLONEL_ELEVATION_ENABLED# OR ITS COLONELS CANNOT PERFORM TIER-1 ACTIONS AT ALL — and which one depends on# the auth mode: in SIMPLE mode set this grace > 0 (or ENABLED=false); in FULL# mode recent_auth is never offered, so only ENABLED=false works.#COLONEL_ELEVATION_REAUTH_GRACE=0 # Since v0.26.11
# Rate limits for the colonel API surface (config site.admin.rate_limit).# Keyed on the acting colonel's public external id (extid) — the same value the# audit trail records as `actor` — never on a session id. The parent flag# short-circuits every bucket.#COLONEL_RATE_LIMIT_ENABLED=true # Since v0.26.11# Step-up attempts. The Rodauth password check behind POST /api/colonel/elevation# is an internal request and does NOT increment Rodauth's own lockout counter,# so this is the only backstop against guessing there. A locked-out operator# clears it with POST /api/colonel/ratelimit/reset (kind=colonel_elevation) or# the commands `bin/ots ratelimit keys colonel_elevation <extid>` prints.#COLONEL_ELEVATION_RATE_LIMIT_ENABLED=true # Since v0.26.11#COLONEL_ELEVATION_MAX_ATTEMPTS=5 # Since v0.26.11#COLONEL_ELEVATION_RATE_WINDOW=900 # Since v0.26.11#COLONEL_ELEVATION_LOCKOUT=900 # Since v0.26.11# Every MUTATING colonel verb (issue #4329), charged once per request from the# colonel logic base constructor. 120 per 5 minutes is far above any human# operator and above a bulk console session, so it only trips on scripted abuse.# A lockout here also blocks POST /ratelimit/reset (itself a mutation), so clear# this one with the commands `bin/ots ratelimit keys colonel_mutation <extid>`# prints.#COLONEL_MUTATION_RATE_LIMIT_ENABLED=true # Since v0.26.11#COLONEL_MUTATION_MAX_ATTEMPTS=120 # Since v0.26.11#COLONEL_MUTATION_RATE_WINDOW=300 # Since v0.26.11#COLONEL_MUTATION_LOCKOUT=300 # Since v0.26.11# TIER 1 (destructive) verbs only: purge account, change role, revoke sessions,# delete org/domain/secret, DLQ purge. Charged LAST — after step-up,# confirmation and the per-verb interlocks all pass — so a rejected attempt# costs nothing and an attacker holding the cookie cannot burn the real# operator's budget with cheap 403s. 10 real actions per 5 minutes with a# 15-minute lockout: enough for an incident-response burst, and low enough that# a scripted purge stalls long before it can flush the count-capped audit trail.# Bulk work belongs on the CLI. Clear a lockout with POST /ratelimit/reset# (kind=colonel_destructive), which needs no elevation.#COLONEL_DESTRUCTIVE_RATE_LIMIT_ENABLED=true # Since v0.26.11#COLONEL_DESTRUCTIVE_MAX_ATTEMPTS=10 # Since v0.26.11#COLONEL_DESTRUCTIVE_RATE_WINDOW=300 # Since v0.26.11#COLONEL_DESTRUCTIVE_LOCKOUT=900 # Since v0.26.11# The two session reads that resolve an opaque session handle (#4330) and may# fall back to a bounded 10 000-key scan plus as many HMACs. Every OTHER colonel# read is deliberately unlimited — the console fetches them on every screen.#COLONEL_HANDLE_RESOLVE_RATE_LIMIT_ENABLED=true # Since v0.26.11#COLONEL_HANDLE_RESOLVE_MAX_ATTEMPTS=60 # Since v0.26.11#COLONEL_HANDLE_RESOLVE_RATE_WINDOW=300 # Since v0.26.11#COLONEL_HANDLE_RESOLVE_LOCKOUT=300 # Since v0.26.11# Session bounds for the ADMIN API SURFACE (/api/colonel) only (issue #4331).# These do NOT shorten the onetime.session cookie and do NOT gate the /colonel# SPA shell: one session serves the admin console, the tenant app and the auth# app, so expiring the object would log a colonel out of the customer app too —# and on a self-hosted install the colonel is often the only customer. A stale# admin tab loads the shell, its first API call 401s, and the console renders an# expired banner with a sign-in link.# ADMIN_SESSION_IDLE_TIMEOUT reads the best-effort SessionMetadata sidecar and# is SKIPPED (never failed) when no record exists. It only bites because the# admin console makes no periodic requests, and the sidecar is a site-wide# activity clock, so tenant traffic keeps the admin window open too.# ADMIN_SESSION_ABSOLUTE_TIMEOUT reads session['authenticated_at'] — nothing a# stolen cookie can do advances it, so it is the bound that always binds.# Set either to 0 to disable that bound; ADMIN_SESSION_LIFETIME_ENABLED=false# disables both and restores the pre-#4331 24h rolling posture.#ADMIN_SESSION_LIFETIME_ENABLED=true # Since v0.26.11#ADMIN_SESSION_IDLE_TIMEOUT=3600 # Since v0.26.11#ADMIN_SESSION_ABSOLUTE_TIMEOUT=43200 # Since v0.26.11Middleware
Section titled “Middleware”# CSRF token protection (site.middleware.authenticity_token).# Validates the 'shrimp' authenticity token on state-changing# requests via Rack::Protection::AuthenticityToken. Bypassed for# /api/*, /auth/sso/*, /auth/email-login, and /billing/webhook.# Default: true. Set to 'false' to disable.#MIDDLEWARE_AUTHENTICITY_TOKEN=true # Since v0.24.0
# Cookie-tossing protection (site.middleware.cookie_tossing).# Enables Rack::Protection::CookieTossing to block session fixation# via cookies set on subdomains. Default: false. Set to 'true' to# enable.#MIDDLEWARE_COOKIE_TOSSING=false # Since v0.24.0
# Clickjacking protection (site.middleware.frame_options).# Enables Rack::Protection::FrameOptions, which sets the# X-Frame-Options header to restrict iframe embedding.# Default: true. Set to 'false' to disable.#MIDDLEWARE_FRAME_OPTIONS=true # Since v0.24.0
# Origin-based CSRF protection (site.middleware.http_origin).# Enables Rack::Protection::HttpOrigin, which denies state-changing# requests whose Origin header does not match the site's own origin.# Requests without an Origin header are allowed through. An Origin# matching the request's resolved display domain (custom domains# behind a Host-rewriting proxy) is always accepted. Default:# false. Set to 'true' to enable.## WARNING: the "site's own origin" is derived from the Host header# (or X-Forwarded-Host) as seen by the application, not from the# host resolved by Rack::DetectHost. Enabling this requires the# proxy tier to pass the public hostname through in Host or# X-Forwarded-Host. If your proxy rewrites Host to the canonical# origin and forwards the real host only in Apx-Incoming-Host or# X-Original-Host, every state-changing request from a custom# domain is rejected with a 403 — most visibly SSO sign-in, which# has no authenticity-token fallback. Symptom in logs:# "attack prevented by Rack::Protection::HttpOrigin".#MIDDLEWARE_HTTP_ORIGIN=false # Since v0.24.0
# IP spoofing detection (site.middleware.ip_spoofing).# Enables Rack::Protection::IPSpoofing, which rejects requests whose# forwarded-IP headers disagree with each other. Default: false.# Set to 'true' to enable.#MIDDLEWARE_IP_SPOOFING=false # Since v0.24.0
# Path traversal protection (site.middleware.path_traversal).# Enables Rack::Protection::PathTraversal, which unescapes and# normalizes request paths, resolving "../" segments before routing# (paths are rewritten, not rejected). Default: true. Set to 'false'# to disable.#MIDDLEWARE_PATH_TRAVERSAL=true # Since v0.24.0
# Static asset serving (site.middleware.static_files).# Serves /dist, /img, /v3 and root-level icon files from public/web# via Rack::Static so the Vue frontend works without a reverse# proxy. Default: true. Set to 'false' when nginx or similar serves# static files instead.#MIDDLEWARE_STATIC_FILES=true # Since v0.24.0
# HSTS header (site.middleware.strict_transport).# Enables Rack::Protection::StrictTransport, which sets the# Strict-Transport-Security header to force HTTPS. Default: true.# Set to 'false' to disable (e.g. plain-HTTP deployments).#MIDDLEWARE_STRICT_TRANSPORT=true # Since v0.24.0
# UTF-8 request sanitization (site.middleware.utf8_sanitizer).# Enables Rack::UTF8Sanitizer, which replaces malformed UTF-8 in# request URIs, headers, and form/JSON bodies with U+FFFD and strips# null bytes (requests are sanitized in place, not rejected).# Default: true. Set to 'false' to disable.#MIDDLEWARE_UTF8_SANITIZER=true # Since v0.24.0
# Legacy XSS filter header (site.middleware.xss_header).# Enables Rack::Protection::XSSHeader, which sets X-XSS-Protection# on HTML responses (modern browsers rely on CSP instead) and also# X-Content-Type-Options: nosniff on all responses. Default: false.# Set to 'true' to enable.#MIDDLEWARE_XSS_HEADER=false # Since v0.24.0
# --- Auth app middleware profile ---# The MIDDLEWARE_AUTH_* toggles below govern the auth app's /auth# middleware stack via the authenticated_web middleware profile# (site.middleware.profiles.authenticated_web.*). They are# independent of the same-named toggles above, which govern the# main app's Security mount. All ship enabled in every environment# (dev/test/prod identical stack).
# Gzip response compression for /auth via Rack::Deflater.# Default: true. Set to 'false' to disable.#MIDDLEWARE_AUTH_DEFLATER=true # Since v0.26.5
# Content-Security-Policy header on /auth responses.# Default: true. Set to 'false' to disable.#MIDDLEWARE_AUTH_CONTENT_SECURITY_POLICY=true # Since v0.26.5
# X-Frame-Options clickjacking protection for /auth.# Default: true. Set to 'false' to disable.#MIDDLEWARE_AUTH_FRAME_OPTIONS=true # Since v0.26.5
# Origin-based CSRF protection for /auth. Uses the shared# display-domain allow_if, so custom-domain /auth requests behind a# Host-rewriting proxy are accepted.# Default: true. Set to 'false' to disable.#MIDDLEWARE_AUTH_HTTP_ORIGIN=true # Since v0.26.5
# IP spoofing detection for /auth (disagreeing forwarded-IP headers# are rejected). Default: true. Set to 'false' to disable.#MIDDLEWARE_AUTH_IP_SPOOFING=true # Since v0.26.5
# Path traversal normalization for /auth request paths.# Default: true. Set to 'false' to disable.#MIDDLEWARE_AUTH_PATH_TRAVERSAL=true # Since v0.26.5
# Session hijacking protection for /auth (ties the session to# stable client attributes). Default: true. Set to 'false' to# disable.#MIDDLEWARE_AUTH_SESSION_HIJACKING=true # Since v0.26.5
# Content-Security-Policy emission (site.security.csp.enabled).# When enabled, HTML responses get a nonce-based CSP header (strict# in production, relaxed in development for hot reloading) unless a# route already set one; the nonce is exposed via# env['onetime.nonce']. Default: true. Set to 'false' to disable.#CSP_ENABLED=true
# Extra CIDR ranges allowed to reach the health endpoints (/health,# /health/*, /auth/health). Comma-separated, e.g.# "100.64.0.0/10,10.96.0.0/12". Localhost and private ranges (RFC# 1918, IPv6 unique-local) are always trusted; other clients get a# 403. Invalid entries are skipped with a warning. No default.#HEALTH_TRUSTED_CIDR= # Since v0.25.5UI / Homepage
Section titled “UI / Homepage”# Controls homepage experience based on IP/headers.# Mode: 'internal', 'external', or blank (default)UI_HOMEPAGE_MODE=UI_HOMEPAGE_MATCHING_CIDRS=UI_HOMEPAGE_MODE_HEADER=
# URL the "What is this link?" affordance on the disabled-homepage view# points at. Surfaced to recipients who arrive via a shared link when the# secret form itself is gated by auth. Leave blank to hide the link.HOMEPAGE_PUBLIC_LINKS_RECIPIENT_INTRO= # Since v0.25.6
# Which landing page the homepage shows when secret creation is gated by auth# (auth.required, or UI_HOMEPAGE_MODE=external). Deployment-wide default for# the CANONICAL site; per-domain config and the ?variant= URL override still# take precedence.# One of:# closed - quiet "members only" two-tagline page, no sign-in button (default)# minimal - small mark + headline + sign-in CTA (one-click SSO when a single# provider is the only login method, otherwise links to /signin)# v1 - full hero: mark, eyebrow, headline, CTA, trust strip, optional promo# Leave blank to use the default (closed).DEFAULT_DISABLED_HOMEPAGE_VARIANT= # Since v0.25.10
# Same as DEFAULT_DISABLED_HOMEPAGE_VARIANT, but the deployment-wide default# for CUSTOM DOMAINS whose homepage is private. Kept separate so the canonical# and custom-domain defaults stay decoupled: a custom domain does NOT inherit# DEFAULT_DISABLED_HOMEPAGE_VARIANT. Per-domain config and the ?variant= URL# override still take precedence. Same values (closed / minimal / v1); leave# blank to use the default (closed).DEFAULT_CUSTOM_DOMAIN_DISABLED_HOMEPAGE_VARIANT= # Since v0.26.2
# Show help modals on secret reveal/receipt pages. Set to 'false' to hide.HELP_ENABLED=true # Since v0.25.5
# Deprecated (#3612): use BRAND_LOGO_URL (brand.logo_url) instead. A value# set here is still honored as a fallback, with a deprecation warning at# boot. Vue component sentinels (e.g. the old DefaultLogo.vue default) are# ignored.#LOGO_URL=
# Deprecated (#3612): use BRAND_LOGO_ALT (brand.logo_alt) instead. A value# set here is still honored as a fallback, with a deprecation warning at# boot.#LOGO_ALT=
# Click target for the masthead logo (site.interface.ui.header.logo.href).# Accepts a relative path or absolute URL. Default when unset: / (home).#LOGO_LINK=/
# Show the product name (BRAND_PRODUCT_NAME) as a text mark next to the logo# icon in the masthead (site.interface.ui.header.logo.show_name). Unset means# auto: shown, unless a custom brand logo is configured (custom logos usually# embed their own wordmark). Set 'true'/'false' to force either way; the# product name stays active in page titles and MFA labels regardless.## On requests classified as custom, this setting cannot show the install# product name. This prevents the install identity from appearing on a# tenant's white-labeled domain (#4241).## Migration note (#3612): unset previously meant always-on. If you relied on# the site name showing next to a custom LOGO_URL/BRAND_LOGO_URL, set# LOGO_SHOW_NAME=true to keep the wordmark.#LOGO_SHOW_NAME= # Since v0.25.0
# Render custom logos at a larger size in authenticated views# (site.interface.ui.header.logo.prominent). When true, authenticated users# see an 80px logo instead of the compact 40px. Useful for rasterized brand# assets that need more visual presence alongside the org/domain context# switchers. Unauthenticated views always use the prominent treatment (160px)# for custom logos regardless of this setting.#LOGO_PROMINENT=false # Since v0.25.3
# Homepage UI toggle. Set to 'false' to show a disabled/API-only landing# page. Does not gate non-homepage routes or API endpoints. Default: true.#UI_ENABLED=true
# Masthead header customization (logo lockup + nav). Set to 'false' to# disable. Default: true.#HEADER_ENABLED=true
# Header navigation links. Set to 'false' to hide the masthead nav while# keeping the logo. Default: true.#HEADER_NAV_ENABLED=true
# Per-surface UI capability flags (surface composition, UI-only — NOT# endpoint or security gating). Set any to 'false' to hide that affordance.# Default: true each.#UI_CAPABILITIES_SHOW=true # Since v0.25.0#UI_CAPABILITIES_RECIPIENT=true # Since v0.25.0#UI_CAPABILITIES_RECEIPT=true # Since v0.25.0#UI_CAPABILITIES_BURN=true # Since v0.25.0Branding / Private-Label
Section titled “Branding / Private-Label”# Installation-level brand identity (config `brand:` block). All default# to unset so shipped/self-hosted instances stay brand-agnostic — no OTS# branding leaks into emails, MFA labels, or the masthead. Per-domain# Redis branding overrides these at runtime.
# Primary brand color (hex). Used in emails and UI accents.# Default when unset: #3B82F6 (neutral blue).#BRAND_PRIMARY_COLOR='#3B82F6' # Since v0.26.0
# Product name in emails, page titles, logo alt text, and (unless# BRAND_TOTP_ISSUER is set) MFA authenticator labels.# Default when unset: "Secure Links" (neutral).#BRAND_PRODUCT_NAME= # Since v0.26.0
# Canonical product domain stored in brand settings for per-domain# overrides. Not currently consumed by any template or component.# Default: unset.#BRAND_PRODUCT_DOMAIN= # Since v0.26.0
# Support address shown in the email footer and available to the frontend# via bootstrap. Default: unset (no contact line rendered).#BRAND_SUPPORT_EMAIL= # Since v0.26.0
# Corner style for buttons/cards. One of: rounded (default), square, pill.#BRAND_CORNER_STYLE=rounded # Since v0.26.0
# Font family. One of: sans (default), serif, mono.#BRAND_FONT_FAMILY=sans # Since v0.26.0
# Use light text on brand-colored buttons. Set to 'false' for dark text# (e.g. light brand colors). Default: true.#BRAND_BUTTON_TEXT_LIGHT=true # Since v0.26.0
# Brand logo URL for the masthead and outbound emails. Never shown on# tenant custom domains — those render their own uploaded logo or the# neutral mark. No default — unset shows the neutral mark in the masthead# and omits the logo from email templates. Emails only emit absolute# http(s) URLs; a relative path still works for the masthead but degrades# emails to a text-only header. Values ending in .vue (the frontend's# component-sentinel convention) are ignored. Set to a full absolute URL# (https:// recommended).#BRAND_LOGO_URL= # Since v0.26.0
# Dark-theme variant of BRAND_LOGO_URL, swapped in by the web UI's masthead# when the site theme toggle is dark. Inert unless BRAND_LOGO_URL is also# set (it pairs with the light logo, never replaces it). Web UI only —# outbound emails always use BRAND_LOGO_URL. Same rules as BRAND_LOGO_URL:# never shown on tenant custom domains, .vue values ignored.#BRAND_LOGO_DARK_URL= # Since v0.26.3
# Alt text for the brand logo (accessibility / hover title). Default when# unset: an i18n string derived from the product name.#BRAND_LOGO_ALT= # Since v0.26.0
# Favicon URL for the installation. Overrides the default /favicon.ico for# all domains that don't have their own per-domain icon. Set to a full# https:// URL pointing to a .ico, .png, or .svg image.#BRAND_FAVICON_URL= # Since v0.26.0
# Apple touch icon URL (iOS home-screen / bookmark icon, 180x180 PNG).# Overrides the neutral /apple-touch-icon.png default. Full https:// URL.#BRAND_APPLE_TOUCH_ICON_URL= # Since v0.26.0
# Open Graph / Twitter social-card image URL (recommended 1200x630). Must be an# absolute URL so social scrapers can fetch it.## Resolution:# 1. this variable, if set to a URL;# 2. else /social-preview.png, but ONLY if the resolved brand pack carries the# file — the tracked `default` and example packs do, and a partial pack# falls through to `default` for files it omits;# 3. else nothing — no og:image/twitter:image tags are emitted at all (never an# empty tag, never one pointing at a 404), and twitter:card degrades from# `summary_large_image` to `summary`, which renders correctly imageless.## Set to `none` (or `off` / `false`) to suppress the card entirely. That is the# opt-out to use when you want NO social preview: deleting social-preview.png# from your own pack is not sufficient on its own, because a partial pack falls# through to the default pack's card. Blank means "unset", not "none".## Custom domains NEVER inherit the install's card, regardless of this setting —# there is no per-domain social image, so emitting one would put your brand on a# customer's shared link.#BRAND_OG_IMAGE_URL= # Since v0.26.0
# Note: the remaining variety-pack assets (favicon.svg, safari-pinned-tab.svg,# icon-192.png, icon-512.png, site.webmanifest) ship in the neutral default pack# and are overridden by selecting a generated pack (BRAND_PACK / BRAND_ASSETS_DIR# below, or --build-arg BRAND_PACK to bake at build time) or by mounting a pack# dir at runtime. See docs/product/branding-favicon.md.
# Brand pack selection (#3739, v2 #3774). A brand pack is the single unit of# branding: a directory of root-served assets (favicon.ico, icon-192.png,# icon-512.png, favicon.svg, safari-pinned-tab.svg, site.webmanifest, plus the# OPTIONAL social-preview.png card) PLUS an optional brand.yaml identity manifest (colours,# product name — absorbed into OT.conf['brand'] at boot, below your BRAND_*# vars). Resolution always lands on a pack; unset = the tracked neutral `default`# pack. A partial selected pack falls through to `default` for files it omits.# The stack is built once at boot, so changing packs needs a restart. Two knobs# for the same mechanism; if both are set, BRAND_ASSETS_DIR wins.# A pack may also carry an OPTIONAL masthead logo (brand-logo.svg / brand-logo.png),# served at /brand-logo.svg|png. It is inert until you point BRAND_LOGO_URL (or# the pack's brand.yaml logo_url) at it, e.g. BRAND_LOGO_URL=/brand-logo.svg.# A root-relative logo renders in the web UI but is omitted from emails (which# need an absolute URL). See docs/product/branding-favicon.md.
# Pack NAME, resolved across two search roots (first existing wins):# 1. etc/branding/<name> — operator space (runtime mounts / confext)# 2. public/branding/<name> — vendor space (tracked default + generated packs)# e.g. BRAND_PACK=maruhi serves etc/branding/maruhi or public/branding/maruhi.# Must be a bare name — path separators are rejected. Named packs are generator# output (pnpm run gen:favicons:maruhi), never committed. Unset resolves to the# tracked neutral `default` pack; an unknown name falls back to it (and warns).#BRAND_PACK= # Since v0.26.0
# Explicit filesystem PATH to a brand pack directory, for runtime mounts (e.g.# /mnt/brand). Wins over BRAND_PACK when both are set. A missing path falls back# to the default pack (and warns). No default.#BRAND_ASSETS_DIR= # Since v0.26.0
# Issuer label shown in TOTP/MFA authenticator apps. Used by both the# frontend QR code and the server-side Rodauth OTP configuration.# Default when unset: BRAND_PRODUCT_NAME, then OTS.#BRAND_TOTP_ISSUER= # Since v0.26.0
# Sign-off name on the closing line of transactional emails (welcome,# password reset, magic link, email-change, org invite, etc.). Kept separate# from BRAND_PRODUCT_NAME so you can sign mail with a person or team without# renaming the product. Default when unset: "Support Team" (neutral i18n# default).#BRAND_SIGNATURE_NAME= # Since v0.26.0Logging & Diagnostics
Section titled “Logging & Diagnostics”# Global default log level (applies to all logger categories).# Options: trace, debug, info, warn, error, fatalDEFAULT_LOG_LEVEL=info # Since v0.24.0ONETIME_DEBUG=false
# Log every Valkey/Redis command issued through Familia (DatabaseLogger# middleware). Verbose; intended for local debugging only and hard-blocked# in production regardless of this value. This is the canonical name; the# former DATABASE_DEBUG / DEBUG_VALKEY / DEBUG_REDIS aliases have been removed.#DEBUG_DATABASE=false # Since v0.25.6
# HTTP request logging (RequestLogger middleware).# Disable when a reverse proxy already logs requests.LOG_HTTP_REQUESTS=true
# Log level for HTTP request lines specifically.# Options: trace, debug, info, warn, error, fatalLOG_HTTP_REQUESTS_LEVEL=info # Since v0.24.0
# What data to include in each request log entry.# Options: minimal (method/path/status/duration),# standard (+ request_id, ip),# debug (+ params, headers, session_id)LOG_HTTP_CAPTURE=minimal # Since v0.24.0
# Opt-in allowlist of request param/header names allowed to appear in logs# or error reports (Sentry) beyond method/path/status/duration/request_id/ip.# Comma-separated, e.g. "ttl,recipient_locale,User-Agent". Empty by default:# with nothing listed, LOG_HTTP_CAPTURE=debug shows no param/header values# and an unhandled exception logs identically to any other request — the# query string and request body are never logged. Never list names like# password, secret, token, passphrase, api_key, Authorization, or Cookie.LOG_HTTP_ALLOWED_ERROR_FIELDS= # Since v0.26.0
DIAGNOSTICS_ENABLED=falseSENTRY_DSN_BACKEND=SENTRY_DSN_FRONTEND=SENTRY_DSN_WORKERS= # Since v0.25.0SENTRY_VUE_TRACK_COMPONENTS=trueSENTRY_SAMPLE_RATE=SENTRY_MAX_BREADCRUMBS=SENTRY_LOG_ERRORS=true# Source map upload (CI only — used by @sentry/vite-plugin)SENTRY_AUTH_TOKEN= # Since v0.24.5SENTRY_ORG= # Since v0.24.5SENTRY_PROJECT= # Since v0.24.5SENTRY_URL= # Since v0.24.5SENTRY_RELEASE= # Since v0.24.5# No SENTRY_DIST here, deliberately. The dist tag is a build-time literal,# not an operator setting: it is applied by the CI upload steps in# .github/workflows/build-and-publish-oci-images.yml, and it must match the# `dist` the frontend puts on its own events in Sentry.init. Sentry resolves# an artifact bundle only when RELEASE AND DIST both match, so those two are# halves of a single join key. Nothing in lib/, apps/ or src/ ever read this# variable; an entry here read like a knob an operator could turn, when all# it actually offered was a fourth way for the two real halves to drift.
# Sampling rate for Valkey/Redis command logging by Familia's# DatabaseLogger (0.0-1.0). Currently has no effect: the reader gates# on a config key ('environment') that is never set, so this var is# never consulted and every command is logged whenever command logging# is on (DEBUG_DATABASE=1; always blocked in production).#FAMILIA_SAMPLE_RATE= # Since v0.24.0
# Ship the colonel audit sink to syslog as well as stdout (logging.yaml# audit.syslog.enabled). Every ColonelAuditEvent is emitted on its own# `ColonelAudit` log category before it is written to Valkey; by default that# rides the console appender. Set to true to ALSO route that one category —# and nothing else — to a syslog appender, for operators who want the audit# stream shipped separately (SIEM, write-once host, its own retention).# Only the exact string "true" enables it. Default: false (off).#LOG_AUDIT_SYSLOG=false # Since v0.26.11
# Syslog facility NAME for the audit appender (logging.yaml# audit.syslog.facility). local0..local7, user, daemon, authpriv, … — resolved# to the matching ::Syslog::LOG_* constant; an unrecognised name falls back to# `user`. Operators usually give the audit stream its own facility so# rsyslog/journald can route it to a dedicated file or forwarder.# Only read when LOG_AUDIT_SYSLOG=true. Default: local0.#LOG_AUDIT_SYSLOG_FACILITY=local0 # Since v0.26.11
# Appender-level floor for the audit syslog appender (logging.yaml# audit.syslog.level). Options: trace, debug, info, warn, error, fatal.# Leave at info: the sink emits at info, so anything higher silently drops the# whole stream. Does NOT change the sink itself, whose level is pinned in code# (ColonelAuditEvent::SINK_LEVEL). Only read when LOG_AUDIT_SYSLOG=true.# Default: info.#LOG_AUDIT_SYSLOG_LEVEL=info # Since v0.26.11
# Destination for the audit syslog appender, protocol://host[:port]# (logging.yaml audit.syslog.url). 'syslog://localhost' writes to the LOCAL# syslog daemon through Ruby's bundled `syslog` library and needs nothing# extra. 'tcp://loghost:514' / 'udp://loghost:514' ship to a REMOTE syslog# server, which needs the third-party `syslog_protocol` gem (plus# `net_tcp_client` for tcp://) added to the Gemfile — neither is bundled, and# without them the appender is skipped with one boot warning while the audit# stream still reaches stdout. Only read when LOG_AUDIT_SYSLOG=true.# Default: syslog://localhost.#LOG_AUDIT_SYSLOG_URL=syslog://localhost # Since v0.26.11
# Log output formatter (logging.yaml formatter). color = human-readable# with ANSI colors, timestamps, PID/thread (default); json = structured# JSON for production log shipping; default = plain text without# colors. CLI commands always use color regardless of this setting.#LOG_FORMATTER=color # Since v0.24.0
# Slow-request threshold in milliseconds (logging.yaml# http.slow_request_ms). Requests taking longer than this are logged# at warn instead of info by the RequestLogger middleware. Only applies# while HTTP request logging is on (LOG_HTTP_REQUESTS=true).# Default: 1000.#LOG_HTTP_SLOW_REQUEST_MS=1000 # Since v0.24.0
# Runtime override for the global default log level. Takes precedence# over the logging config's default_level (DEFAULT_LOG_LEVEL; config# default: warn). Applies to loggers without an explicit per-category# level in logging.yaml; ONETIME_DEBUG=true still forces debug.# Options: trace, debug, info, warn, error, fatal. No default.#LOG_LEVEL= # Since v0.24.0
# Shared Sentry DSN (diagnostics.sentry.defaults.dsn). Fallback for# every peer when the specific SENTRY_DSN_BACKEND / SENTRY_DSN_FRONTEND# / SENTRY_DSN_WORKERS is not set. Requires DIAGNOSTICS_ENABLED=true;# diagnostics stay off when no DSN is present at all. No default.#SENTRY_DSN=
# Sentry organization ID (diagnostics.sentry.defaults.org_id). When# set, sentry-ruby enables strict trace continuation: inbound traces# are continued only if their sentry-org_id baggage matches, keeping# other Sentry orgs' traces out. Self-hosted Sentry must set this# explicitly; leave empty to continue all inbound traces. No default.#SENTRY_ORG_ID= # Since v0.25.0
# Pseudonymous Sentry references (actor and organization) are keyed by# ACCOUNT_ID_SECRET — see that entry for what is derived, what Sentry# receives, and what rotating it costs. There is no diagnostics-specific# secret and no residency knob: both pre-images are minted per installation,# so separately provisioned installations do not correlate by default.Job System
Section titled “Job System”JOBS_ENABLED=true # Since v0.24.0SNEAKERS_PID_PATH=tmp/pids/sneakers.pid # Since v0.24.0SCHEDULER_PID_PATH=tmp/pids/scheduler.pid # Since v0.24.0WORKER_HEARTBEAT_INTERVAL=600 # Since v0.24.0
# AMQP prefetch for the billing worker: max unacknowledged messages# fetched per consumer from the billing.event.process queue# (jobs.workers.billing.prefetch). Count; default 5. Read by# `bin/ots worker`, which loads billing workers only when billing# is enabled.#BILLING_WORKER_PREFETCH=5 # Since v0.24.0
# Consumer thread count for the billing worker processing Stripe# webhook events from the billing.event.process queue# (jobs.workers.billing.threads). Count; default 2. Read by# `bin/ots worker`, which loads billing workers only when billing# is enabled.#BILLING_WORKER_THREADS=2 # Since v0.24.0
# AMQP prefetch for the DNS record check worker (domain.dns.check# queue): how many unacked messages RabbitMQ delivers ahead to each# consumer. Read from the environment when the `ots worker` process# loads the worker class (job system; see JOBS_ENABLED).# Count; default 5.#DNS_CHECK_WORKER_PREFETCH=5 # Since v0.25.0
# Concurrent consumer threads for the DNS record check worker# (domain.dns.check queue), which fact-finds custom-domain mail DNS# records. Read by the `ots worker` process at class load (job# system; see JOBS_ENABLED). Count; default 2.#DNS_CHECK_WORKER_THREADS=2 # Since v0.25.0
# AMQP prefetch for the domain validation worker# (domain.validation.check queue): unacked messages RabbitMQ# delivers ahead per consumer. Read by the `ots worker` process at# class load (job system; see JOBS_ENABLED). Count; default 5.#DOMAIN_VALIDATION_WORKER_PREFETCH=5 # Since v0.25.0
# Concurrent consumer threads for the domain validation worker# (domain.validation.check queue), which sets verification status.# Conservative rollout default; the work is DNS/IO-bound, so the# code notes 8-16 threads would be safe. Read by the `ots worker`# process (job system; see JOBS_ENABLED). Count; default 2.#DOMAIN_VALIDATION_WORKER_THREADS=2 # Since v0.25.0
# AMQP prefetch for the favicon fetch worker (domain.favicon.fetch# queue): unacked messages RabbitMQ delivers ahead per consumer.# Read by the `ots worker` process at class load (job system; see# JOBS_ENABLED). Count; default 5.#FAVICON_FETCH_WORKER_PREFETCH=5 # Since v0.26.0
# Concurrent consumer threads for the favicon fetch worker# (domain.favicon.fetch queue), which pulls a custom domain's favicon# from the live site (#3780). Read by the `ots worker` process at# class load (job system; see JOBS_ENABLED). Count; default 2.#FAVICON_FETCH_WORKER_THREADS=2 # Since v0.26.0
# AMQP prefetch for the session revocation sweep worker# (session.revoke.sweep queue): unacked messages RabbitMQ delivers# ahead per consumer. Read by the `ots worker` process at class# load (job system; see JOBS_ENABLED). Count; default 5.#SESSION_SWEEP_WORKER_PREFETCH=5 # Since v0.26.1
# Concurrent consumer threads for the session revocation sweep# worker (session.revoke.sweep queue), which revokes a customer's# other sessions after a password change (#3810). Read by the# `ots worker` process at class load (job system; see# JOBS_ENABLED). Count; default 2.#SESSION_SWEEP_WORKER_THREADS=2 # Since v0.26.1
# AMQP prefetch for the email delivery worker (email.message.send# queue): unacked messages RabbitMQ delivers ahead per consumer.# The worker reads this env var directly at class load; the# mirrored jobs.workers.email.prefetch YAML default is not consumed# by code. Count; default 10.#EMAIL_WORKER_PREFETCH=10 # Since v0.24.0
# Concurrent consumer threads for the email delivery worker# (email.message.send queue). The worker reads this env var# directly at class load; the mirrored jobs.workers.email.threads# YAML default is not consumed by code. Count; default 4.#EMAIL_WORKER_THREADS=4 # Since v0.24.0
# AMQP prefetch for the notification worker# (notifications.alert.push queue): unacked messages RabbitMQ# delivers ahead per consumer. The worker reads this env var# directly (effective default 5); the jobs.workers.notifications# YAML default of 10 is not consumed by code. Count; default 5.#NOTIFICATION_WORKER_PREFETCH=5 # Since v0.24.0
# Concurrent consumer threads for the notification worker# (notifications.alert.push queue), which dispatches secret# viewed/burned alerts. Read directly by the `ots worker` process;# the mirrored jobs.workers.notifications.threads YAML default is# not consumed by code. Count; default 2.#NOTIFICATION_WORKER_THREADS=2 # Since v0.24.0
# AMQP prefetch for the transient worker (system.transient queue):# unacked messages RabbitMQ delivers ahead per consumer. Read by# the `ots worker` process at class load (job system; see# JOBS_ENABLED). Count; default 5.#TRANSIENT_WORKER_PREFETCH=5 # Since v0.24.0
# Concurrent consumer threads for the transient worker# (system.transient queue), handling ephemeral fire-and-forget# tasks like queue ping tests (`ots queue ping`). Read by the# `ots worker` process (job system; see JOBS_ENABLED).# Count; default 2.#TRANSIENT_WORKER_THREADS=2 # Since v0.24.0
# Fall back to synchronous email delivery when RabbitMQ is# unavailable (jobs.fallback_to_sync). Default: true; set to 'false'# to disable. Note: the key is currently only recorded in config —# delivery code chooses its fallback per call (default# :async_thread), so this setting is not read at runtime.#JOBS_FALLBACK_SYNC=true # Since v0.24.0
# Scheduler daemon flag (jobs.scheduler.enabled). The docker compose# full stack documents setting this to 'true' for its `ots scheduler`# service, but no Ruby code currently reads this config path — the# scheduler daemon runs whichever jobs enable their own# JOBS_*_ENABLED flags. Default: false.# Note: the per-job flags are config-file-only (no ENV interpolation,# deliberate per #3775) — e.g. jobs.plan_cache_refresh.enabled and# jobs.maintenance.entitlement_materialize.enabled in etc/config.yaml.# See docs/runbooks/entitlement-rematerialization.md.#JOBS_SCHEDULER_ENABLED=false # Since v0.24.0
# Path to a custom CA certificate file for verifying the RabbitMQ# server's TLS certificate. Only read when the rabbitmq_url scheme is# amqps://; when unset, the system CA bundle is used (managed services# like CloudAMQP need no custom cert). Passed to Bunny/Sneakers as# tls_ca_certificates.#RABBITMQ_CA_CERTIFICATES= # Since v0.24.0
# Publisher-side Bunny channel pool size per web (Puma) process# (jobs.channel_pool_size). Channels are checked out from this pool# for thread-safe publishing. Count; default 5. Only used when# JOBS_ENABLED=true; Sneakers workers manage their own consumer# connections and skip this pool.#RABBITMQ_CHANNEL_POOL_SIZE=5 # Since v0.24.0
# Base URL of the RabbitMQ Management HTTP API used by the `ots queue`# CLI (init, status) to create the vhost, set permissions, and apply# DLQ policies. A jobs.rabbitmq_management_url config key wins over# this env var when present; credentials come from the user:password# in RABBITMQ_URL. Default: http://localhost:15672.#RABBITMQ_MANAGEMENT_URL=http://localhost:15672 # Since v0.24.0
# Whether to verify the RabbitMQ server's TLS certificate on amqps://# connections. Default: true. Set to 'false' to skip verification for# local dev with self-signed certs; only the exact string 'true'# enables verification. Ignored for plain amqp:// URLs.#RABBITMQ_VERIFY_PEER=true # Since v0.24.0
# Overrides the vhost for Sneakers worker processes (`ots worker run`)# without editing the AMQP URL — useful for debugging or temporarily# running workers against a different vhost. Applied only when set;# otherwise the vhost comes from the path component of RABBITMQ_URL.# Does not affect the web publisher connection.#RABBITMQ_VHOST= # Since v0.24.0Server & Worker Tuning
Section titled “Server & Worker Tuning”# [deprecated] Compatibility alias for PUMA_MAX_THREADS, which takes# precedence when both are set. Maximum Puma threads per worker# process (count). Read by etc/puma.rb only when PUMA_MAX_THREADS is# unset. Default: 16.#MAX_THREADS=16 # Since v0.24.0
# [deprecated] Compatibility alias for PUMA_MIN_THREADS, which takes# precedence when both are set. Minimum Puma threads per worker# process (count). Default when neither is set: 1 when# RACK_ENV=production, 0 in development.#MIN_THREADS= # Since v0.24.0
# Restart each Puma worker after N requests to curb memory growth# (count, production cluster mode). Currently inert: the# worker_max_requests line ships commented out in# etc/examples/puma.example.rb — uncomment it in your etc/puma.rb# copy to enable. Default when enabled: 1000.#MAX_WORKER_REQUESTS=1000 # Since v0.24.0
# Maximum Puma threads per worker process (count). Read by# etc/puma.rb (copied from etc/examples/puma.example.rb in the Docker# image); takes precedence over the legacy MAX_THREADS alias.# Default: 16.#PUMA_MAX_THREADS=16 # Since v0.24.0
# Minimum Puma threads per worker process (count). Read by# etc/puma.rb; takes precedence over the legacy MIN_THREADS alias.# Default: 1 when RACK_ENV=production (the Docker default), 0 in# development.#PUMA_MIN_THREADS=1 # Since v0.24.0
# Number of Puma worker processes (count). 0 = single-process mode# (no cluster, no fork hooks); >0 enables cluster mode with# preload_app!. Takes precedence over the legacy WEB_CONCURRENCY# alias. Default: 2 when RACK_ENV=production (the Docker default),# 0 in development.#PUMA_WORKERS=2 # Since v0.24.0
# [deprecated] Compatibility alias for PUMA_WORKERS, which takes# precedence when both are set. Number of Puma worker processes# (count); >0 enables cluster mode. Default when neither is set:# 2 in production, 0 in development.#WEB_CONCURRENCY= # Since v0.24.0
# TCP port the Puma web server binds (tcp://0.0.0.0:PORT in# etc/puma.rb). Also probed by the Docker healthcheck and# `ots status`. Default: 3000; bin/dev defaults it to 7143 for local# development (an explicit PORT or its --port flag still wins).#PORT=3000
# App image tag used by docker/compose/*.yml (not read by app code).# Selects the onetimesecret/onetimesecret image version for the app,# worker-email, and scheduler services. Defaults to a pinned release so# a fresh `docker compose up` is reproducible; set to 'latest' or any# published tag to override.#OTS_IMAGE_TAG=v0.26.12 # Since v0.26.0Compatibility
Section titled “Compatibility”# How the boot process responds when a deprecated config key or env var# is detected (e.g. UI_HOMEPAGE_TRUSTED_PROXY_DEPTH, site.domains).# strict (default): raise an error and refuse to start# warn: log a migration message and continue# silent: ignore# Soft deprecations whose legacy values still work via a fallback (e.g.# SITE_NAME/LOGO_URL/LOGO_ALT, #3612) only ever log — even under strict —# so a working install keeps booting; silent suppresses them too.DEPRECATED_CONFIG_MODE=strict # Since v0.25.5Experimental Features
Section titled “Experimental Features”# Opt-in, not-yet-stable feature flags (config: experimental.*). Each is safe# to disable at any time — rollback is a config flip.## No experimental flags are currently defined: the Colonel admin-console cutover# flag was retired once the rebuilt console became the sole admin frontend# (docs/specs/colonel-ui/50-cutover-hardening.md). This section is kept as an# extension point for future flags.Development Only
Section titled “Development Only”# Uncomment for local development#RACK_ENV=development#NODE_ENV=development#DEFAULT_LOG_LEVEL=debug#LOG_HTTP_REQUESTS_LEVEL=debug# Options: minimal, standard, debug#LOG_HTTP_CAPTURE=debug
# Shared dev config directory used by bin/setup to source the# symlinked config/auth/billing/logging YAML, data dir, Procfiles, and# .env.test for a checkout or worktree.# Default: $HOME/.config/onetimesecret-dev#OTS_DEV_CONFIG= # Since v0.25.6
#FRONTEND_HOST=https://dev.onetime.dev#VITE_API_BASE_URL=dev.onetime.dev # Since v0.24.1#VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=.onetime.dev,.afb.pet,.metalbaum.dev # Since v0.24.1
# Bypasses the boot-time guard that refuses development.enabled=true on a# deployment artifact (e.g. the production container image, which has no# Vite toolchain and cannot proxy /dist/* — ADR-024). Set only when# deliberately proxying to an external Vite dev server from such a build.#ONETIME_ALLOW_DEV_FRONTEND=true # Since v0.26.0
# HTTP Request header debugging#HEADER_PREFIX=ONETIME_
# IRB interface for Ruby debugger breakpoints#RUBY_DEBUG_IRB_CONSOLE=true # Since v0.24.1
# On-demand heap dumps for diagnosing memory growth. When enabled, every OTS# process installs a SIGUSR2 handler at boot; `kill -USR2 <pid>` writes# ObjectSpace.dump_all output to HEAP_DUMP_DIR/heap-<pid>-<epoch>.json# (owner-only, 0600). Analyze with scripts/analyze-heapdump.# SECURITY: a dump contains plaintext secrets and key material that were live# in memory — treat the file as a credential and delete it after analysis.# Default off; enable explicitly (requires a restart, and on a read-only# container a writable HEAP_DUMP_DIR mount).#HEAP_DUMP_ENABLED=true # Since v0.25.9# Default /var/tmp (disk-backed and persistent on Debian 13, unlike the tmpfs# /tmp which consumes RAM against the container's MemoryMax).#HEAP_DUMP_DIR=/var/tmp # Since v0.25.9
#RUBY_YJIT_ENABLE=1 # Since v0.24.1#RUBYOPT="--enable-frozen-string-literal" # Since v0.24.1
# Development tool: enables domain-context overrides in DomainStrategy# middleware (development.domain_context_enabled) to simulate a custom# domain without DNS. When true, the DOMAIN_CONTEXT env var, the# O-Domain-Context header, or the colonel UI can override the request# domain. Default: false. Must remain false in production.#DOMAIN_CONTEXT_ENABLED=false # Since v0.24.0
# Arming switch for the destructive visual-fixture seed task# (rake qa:visual:seed, lib/tasks/qa_visual.rake). The task creates brand# and secret fixtures in the configured datastore and refuses to run# unless this is set to 1; bin/visual sets it for you. Never arm it# against a production datastore.#QA_VISUAL_SEED=1 # Since v0.26.0