Environment Variables Reference
Ce contenu n'est pas encore disponible dans votre langue.
This guide documents every environment variable Onetime Secret supports. The reference below tracks .env.reference as shipped in the current v0.26 release. A legacy section at the end keeps the older v0.24 variable set for instances still on that line.
Environment Variables
Section titled “Environment Variables”Set these in your .env file or environment, or add them to your docker run command
or docker-compose.yml. All variables are optional unless a comment marks them required.
For a smaller starting point than the full set below, the repository also ships
.env.example.
Variables marked:
[derived]are generated byrake ots:initvia HKDF fromSECRET.[independent]are randomly generated byrake ots:initand 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
# 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=
# [derived] Rack session signing key#SESSION_SECRET=
# [derived] Secret link verification key (Familia::VerifiableIdentifier).# Used to generate secret/receipt keys. Derived from SECRET via HKDF.#IDENTIFIER_SECRET=
# [independent] HMAC key for Rodauth auth operations (TOTP, login tokens).# Cannot be recovered from SECRET. Back this up.#AUTH_SECRET=
# [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=
# [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=
# [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=
# 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=
# [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=
# 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=Connections
Section titled “Connections”REDIS_URL='redis://maindb:6379/0?timeout=5'VALKEY_URL=AUTH_DATABASE_URL=postgresql://onetime_user:CHANGEME@authdb/onetime_authAUTH_DATABASE_URL_MIGRATIONS=postgresql://onetime_migrator:CHANGEME@authdb/onetime_auth
# 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=
# 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=RABBITMQ_PASS=
# 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
# Size of the shared Valkey/Redis connection pool used for all# Familia model operations (count of connections). Default: 25.#FAMILIA_POOL_SIZE=25
# 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
# 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
# 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
# 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
# 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
# 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=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=
# 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=604800Secret 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# Anonymous creations permitted per window from one masked IP. Default: 500.#SECRET_CREATE_RATE_LIMIT_MAX_PER_IP=500# Counting window in seconds. Default: 3600 (1 hour).#SECRET_CREATE_RATE_LIMIT_WINDOW=3600# Lockout duration in seconds once the cap is hit. Default: 3600 (1 hour).#SECRET_CREATE_RATE_LIMIT_LOCKOUT=3600
# 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
# 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
# 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=60EMAILER_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=
VERIFIER_EMAIL=secure@onetimesecret.comVERIFIER_DOMAIN=onetimesecret.com
EMAILER_SHOW_LOGO=false
# 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=
# 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
# 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=
# [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=
# [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=
# [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=
# 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=
# 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=Authentication
Section titled “Authentication”# 'full' enables Rodauth-based auth with PostgreSQL.# Other modes may use simpler auth flows.AUTHENTICATION_MODE=full
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=trueAUTH_LOCKOUT_ENABLED=trueAUTH_MFA_ENABLED=falseAUTH_WEBAUTHN_ENABLED=falseAUTH_PASSWORD_REQUIREMENTS_ENABLED=trueAUTH_ACTIVE_SESSIONS_ENABLED=trueAUTH_VERIFY_ACCOUNT_ENABLED=true
# 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#AUTH_EMAIL_AUTH_ONLY=false#AUTH_WEBAUTHN_ONLY=false#AUTH_SSO_ONLY=false
# 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
# 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# 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
# 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
# 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
# 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
# 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# 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
# 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
# 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
# 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=
# 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
# 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=falseOrganizations
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
# When enabled, allows organizations with the manage_sso entitlement to# configure SSO for each of their custom domains.ORGS_SSO_ENABLED=false
# 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
# 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
# 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
# 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
# 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=10000Custom 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 (below); 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=
# --- 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## 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...#CUSTOM_MAIL_SES_SECRET_ACCESS_KEY=...
# --- 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=
# 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=
# API base URL (for enterprise/on-premise deployments).# Default: https://api.lettermint.co/v1#LETTERMINT_BASE_URL=https://api.lettermint.co/v1
# --- 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=
# API base URL override; rarely needed.# Default: https://api.smtp2go.com/v3#SMTP2GO_BASE_URL=https://api.smtp2go.com/v3
# 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
# 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
# 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 that trigger an email send are taking too long to load.# Default: false#CUSTOM_MAIL_SMTP2GO_FASTACCEPT=false
# --- 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
# 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
# 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=emSSO / OmniAuth
Section titled “SSO / OmniAuth”# Master toggle for SSO. Must be true for any provider below.AUTH_SSO_ENABLED=false
# [deprecated] Use per-provider DISPLAY_NAME vars instead.#SSO_DISPLAY_NAME=
# --- Generic OIDC provider ---# Required: OIDC_ISSUER, OIDC_CLIENT_ID# Optional: OIDC_CLIENT_SECRET (omit for PKCE-only flows)OIDC_ISSUER=OIDC_CLIENT_ID=OIDC_CLIENT_SECRET=#OIDC_ROUTE_NAME=oidc# Default false. Opt-in email-based SSO linking; single-tenant trusted-IdP only.#OIDC_TRUST_EMAIL_FOR_LINKING=false
# --- Microsoft Entra ID ---# Required: ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRETENTRA_TENANT_ID=ENTRA_CLIENT_ID=ENTRA_CLIENT_SECRET=#ENTRA_ROUTE_NAME=entra#ENTRA_DISPLAY_NAME=Microsoft# Default false. Opt-in email-based SSO linking; single-tenant trusted-IdP only.#ENTRA_TRUST_EMAIL_FOR_LINKING=false
# --- GitHub ---# Required: GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRETGITHUB_CLIENT_ID=GITHUB_CLIENT_SECRET=#GITHUB_ROUTE_NAME=github#GITHUB_DISPLAY_NAME=GitHub# Default false. Opt-in email-based SSO linking; single-tenant trusted-IdP only.#GITHUB_TRUST_EMAIL_FOR_LINKING=false
# --- Google ---# Required: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRETGOOGLE_CLIENT_ID=GOOGLE_CLIENT_SECRET=#GOOGLE_ROUTE_NAME=google#GOOGLE_DISPLAY_NAME=Google# Default false. Opt-in email-based SSO linking; single-tenant trusted-IdP only.#GOOGLE_TRUST_EMAIL_FOR_LINKING=false
# 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=
# 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=
# 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
# 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
# [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=
# [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=
# [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=
# [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=Stripe Billing
Section titled “Stripe Billing”STRIPE_API_KEY=PUBLIC_STRIPE_API_KEY=STRIPE_WEBHOOK_SIGNING_SECRET=
# 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=
# 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=
# 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=
# 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
# 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=
# 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=cadCustom 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
# Validation strategy: passthrough (default), approximated, caddy_on_demandDOMAINS_VALIDATION_STRATEGY=passthrough
# Approximated provider settings (when strategy=approximated)APPROXIMATED_API_KEY=APPROXIMATED_PROXY_IP=APPROXIMATED_PROXY_HOST=APPROXIMATED_PROXY_NAME=APPROXIMATED_VHOST_TARGET=
# 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
# 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
# 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
# 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=falseFeature 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=
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# 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=COOKIE_URL=AUP_URL=SECURITY_URL=STATUS_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/
# 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# 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=WORKSPACE_BRANDING_GUIDE_URL=WORKSPACE_FEEDBACK_URL=/feedback# Show the workspace feedback link. Set to 'false' to hide. Default: true.#WORKSPACE_FEEDBACK_ENABLED=true
# Show version number in all footers (default: true)# Set to 'false' to hide version across all footer componentsFOOTER_VERSION_ENABLED=true
# 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
# 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=localeAPI 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
# 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
# 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
# Counting window in seconds for both rate-limit tiers# (features.incoming.rate_limit.window). Default: 3600 (1 hour).#INCOMING_RATE_LIMIT_WINDOW=3600
# Lockout duration in seconds once a tier's cap is hit# (features.incoming.rate_limit.lockout). Default: 3600 (1 hour).#INCOMING_RATE_LIMIT_LOCKOUT=3600Reverse 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
# 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
# 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
# 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=
# 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
# ---- 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=
# 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=
# 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
# 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
# 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=
# 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=
# 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=
# 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# Window length in seconds (default 600 = 10 minutes).#COLONEL_ELEVATION_WINDOW=600# 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
# 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# 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#COLONEL_ELEVATION_MAX_ATTEMPTS=5#COLONEL_ELEVATION_RATE_WINDOW=900#COLONEL_ELEVATION_LOCKOUT=900# 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#COLONEL_MUTATION_MAX_ATTEMPTS=120#COLONEL_MUTATION_RATE_WINDOW=300#COLONEL_MUTATION_LOCKOUT=300# 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#COLONEL_DESTRUCTIVE_MAX_ATTEMPTS=10#COLONEL_DESTRUCTIVE_RATE_WINDOW=300#COLONEL_DESTRUCTIVE_LOCKOUT=900# 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#COLONEL_HANDLE_RESOLVE_MAX_ATTEMPTS=60#COLONEL_HANDLE_RESOLVE_RATE_WINDOW=300#COLONEL_HANDLE_RESOLVE_LOCKOUT=300# 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#ADMIN_SESSION_IDLE_TIMEOUT=3600#ADMIN_SESSION_ABSOLUTE_TIMEOUT=43200Middleware
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
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# --- 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
# Content-Security-Policy header on /auth responses.# Default: true. Set to 'false' to disable.#MIDDLEWARE_AUTH_CONTENT_SECURITY_POLICY=true
# X-Frame-Options clickjacking protection for /auth.# Default: true. Set to 'false' to disable.#MIDDLEWARE_AUTH_FRAME_OPTIONS=true
# 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
# IP spoofing detection for /auth (disagreeing forwarded-IP headers# are rejected). Default: true. Set to 'false' to disable.#MIDDLEWARE_AUTH_IP_SPOOFING=true
# Path traversal normalization for /auth request paths.# Default: true. Set to 'false' to disable.#MIDDLEWARE_AUTH_PATH_TRAVERSAL=true
# Session hijacking protection for /auth (ties the session to# stable client attributes). Default: true. Set to 'false' to# disable.#MIDDLEWARE_AUTH_SESSION_HIJACKING=true
# 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=UI / 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=
# 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=
# 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=
# Show help modals on secret reveal/receipt pages. Set to 'false' to hide.HELP_ENABLED=true
# 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=
# 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
# 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#UI_CAPABILITIES_RECIPIENT=true#UI_CAPABILITIES_RECEIPT=true#UI_CAPABILITIES_BURN=trueBranding / 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'
# 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=
# Canonical product domain stored in brand settings for per-domain# overrides. Not currently consumed by any template or component.# Default: unset.#BRAND_PRODUCT_DOMAIN=
# Support address shown in the email footer and available to the frontend# via bootstrap. Default: unset (no contact line rendered).#BRAND_SUPPORT_EMAIL=
# Corner style for buttons/cards. One of: rounded (default), square, pill.#BRAND_CORNER_STYLE=rounded
# Font family. One of: sans (default), serif, mono.#BRAND_FONT_FAMILY=sans
# 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
# 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=
# 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=
# Alt text for the brand logo (accessibility / hover title). Default when# unset: an i18n string derived from the product name.#BRAND_LOGO_ALT=
# 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=
# 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=
# 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=
# 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=
# 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=
# 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=
# 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=Logging & Diagnostics
Section titled “Logging & Diagnostics”# Global default log level (applies to all logger categories).# Options: trace, debug, info, warn, error, fatalDEFAULT_LOG_LEVEL=infoONETIME_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
# 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
# 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
# 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=
DIAGNOSTICS_ENABLED=falseSENTRY_DSN_BACKEND=SENTRY_DSN_FRONTEND=SENTRY_DSN_WORKERS=SENTRY_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=SENTRY_ORG=SENTRY_PROJECT=SENTRY_URL=SENTRY_RELEASE=# 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=
# 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
# 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
# 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
# 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
# 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
# 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
# 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=
# 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=
# 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=trueSNEAKERS_PID_PATH=tmp/pids/sneakers.pidSCHEDULER_PID_PATH=tmp/pids/scheduler.pidWORKER_HEARTBEAT_INTERVAL=600
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# 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=
# 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
# 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
# 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
# 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=Server & 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
# [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=
# 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
# 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
# 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
# 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
# [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=
# 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.12Compatibility
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=strictExperimental 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=
#FRONTEND_HOST=https://dev.onetime.dev#VITE_API_BASE_URL=dev.onetime.dev#VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=.onetime.dev,.afb.pet,.metalbaum.dev
# 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
# Arming switch for the destructive `rake qa:visual:seed` task, which flushes# and reseeds the test datastore with visual-regression fixtures. Exported by# bin/visual; never set against a real datastore.#QA_VISUAL_SEED=1
# HTTP Request header debugging#HEADER_PREFIX=ONETIME_
# IRB interface for Ruby debugger breakpoints#RUBY_DEBUG_IRB_CONSOLE=true
# 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# 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
#RUBY_YJIT_ENABLE=1#RUBYOPT="--enable-frozen-string-literal"
# 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
# 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=1Legacy Reference (v0.24)
Section titled “Legacy Reference (v0.24)”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.
Core Application Settings
Section titled “Core Application Settings”SECRET=your-32-char-hex-key # Secret key for sessions and encryption (REQUIRED) - DO NOT change after settingPORT=3000 # Port for the web server to listen on (default: 3000)HOST=localhost:3000 # Host and port combination used for generating linksSSL=false # Controls https/http when generating links (set to true when behind a reverse proxy)SERVER_TYPE=puma # Web server type: pumaRACK_ENV=production # Application environment: development, production, testDatabase & Storage
Section titled “Database & Storage”REDIS_URL=redis://localhost:6379/0 # Redis/Valkey connection string for sessions, secrets, and all application dataVariables beginning with REDIS_ can alternately be set with the VALKEY_ prefix (e.g., VALKEY_URL). The app accepts either.
Authentication & Security
Section titled “Authentication & Security”AUTH_ENABLED=true # Enable authentication system (disables API auth when false)AUTH_SIGNUP=true # Allow new user registrationAUTH_SIGNIN=true # Allow existing users to sign inAUTH_AUTOVERIFY=false # Skip email verification for new accountsAUTHENTICATION_MODE=simple # Authentication mode: none, simple, full (full requires PostgreSQL + RabbitMQ)AUTH_DATABASE_URL= # Database URL for auth (only used in full mode, configured in etc/config.yaml)FEDERATION_SECRET= # Secret for federation between instances (auto-generated by `install.sh init` as a multi-word passphrase)Note: “Colonel” is our term for “admin” users. Colonel accounts are created using bin/ots customer create email@example.com && bin/ots customer promote email@example.com. Colonels can access the admin area at /colonel which shows basic system stats. The admin interface currently has limited functionality - no user management and only readonly configuration viewing.
User Interface & Features
Section titled “User Interface & Features”UI_ENABLED=true # Enable web user interface (shows minimal page when disabled)API_ENABLED=true # Enable REST API endpoints (returns 404 when disabled)CSP_ENABLED=true # Enable Content Security Policy headersHEADER_ENABLED=true # Show site header with brandingHEADER_NAV_ENABLED=true # Show navigation links in headerHEADER_PREFIX=DOMAINS_ENABLED=false # Enable custom domain supportREGIONS_ENABLED=false # Enable multi-region deployment support. This doesn't affect # the functionality of the application. But it does enable UI # components for linking to other regions.Branding & Content
Section titled “Branding & Content”LOGO_URL= # URL to custom logo image (defaults to built-in logo)LOGO_ALT=LOGO_LINK=FOOTER_LINKS=ABOUT_URL=ABOUT_EXTERNAL=falseCONTACT_URL=PRIVACY_URL=PRIVACY_EXTERNAL=falseTERMS_URL=TERMS_EXTERNAL=falseSTATUS_URL=STATUS_EXTERNAL=falseSending Emails
Section titled “Sending Emails”EMAILER_MODE=smtp # Email service mode (smtp, sendgrid, etc.)EMAILER_REGION= # Email service region (for cloud providers)FROM_EMAIL=noreply@localhost # Default sender email addressFROM= # Sender name (alternative to FROMNAME)FROMNAME= # Display name for senderSMTP_HOST= # SMTP server hostnameSMTP_PORT=587 # SMTP server port (usually 587 for TLS, 25 for plain)SMTP_USERNAME= # SMTP authentication usernameSMTP_PASSWORD= # SMTP authentication passwordSMTP_TLS=true # Enable TLS encryption for SMTPSMTP_AUTH=login # SMTP authentication method (login, plain, etc.)Secrets & TTL
Section titled “Secrets & TTL”DEFAULT_TTL=604800 # Default secret expiration in seconds (604800 = 7 days)TTL_OPTIONS=300,1800,3600,86400 # Available TTL options presented to users, comma separated (seconds)DEFAULT_DOMAIN= # Default domain for secret links (uses HOST if empty)ALLOW_NIL_GLOBAL_SECRET=false # Allow operation with missing SECRET key (emergency recovery)Validating Email Addresses
Section titled “Validating Email Addresses”Email address validation is handled by the Truemail library, which supports multiple validation types including regex, MX record lookup, and SMTP verification.
VERIFIER_DOMAIN= # Domain for SMTP verification (required for SMTP validation)VERIFIER_EMAIL= # Email address for SMTP verification (required for SMTP validation)Note: Many additional Truemail configuration options are available in the YAML config under the truemail: section, including validation types, timeout settings, allowed/blocked domains, DNS servers, and more. See etc/config.yaml for the full configuration.
Internationalization
Section titled “Internationalization”I18N_ENABLED=true # Enable internationalizationI18N_DEFAULT_LOCALE=en # Default language localeDevelopment & Debugging
Section titled “Development & Debugging”ONETIME_DEBUG=false # Enable debug modeLOG_HTTP_REQUESTS=false # Log HTTP requestsSTDOUT_SYNC=true # Sync stdout outputDIAGNOSTICS_ENABLED=false # Enable diagnosticsFRONTEND_HOST=http://localhost:5173 # Frontend dev server URL (development only)VITE_API_BASE_URL= # Vite API base URL overrideMonitoring & Error Tracking
Section titled “Monitoring & Error Tracking”See the sentry documentation for more information on configuring Sentry.
SENTRY_DSN=SENTRY_DSN_BACKEND=SENTRY_DSN_FRONTEND=SENTRY_LOG_ERRORS=trueSENTRY_MAX_BREADCRUMBS=50SENTRY_SAMPLE_RATE=1.0SENTRY_VUE_TRACK_COMPONENTS=true