Przejdź do treści

Przeglądasz przedpremierową wersję dokumentacjiTreści tutaj służą do testów i mogą różnić się od dokumentacji produkcyjnej.

Przejdź do dokumentacji produkcyjnej

Environment Variables Reference

Ta treść nie jest jeszcze dostępna w twoim języku.

This guide covers all environment variables available in Onetime Secret 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.

A trailing # Since vX.Y.Z records the release a variable first shipped in; # Since unreleased means it ships in the next one. A variable with no marker predates v0.24.0. The marker is a comment about the variable, not part of its value — drop it when copying a line, since docker run --env-file and docker compose --env-file take the whole rest of the line as the value rather than stripping a trailing comment.

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