# ==============================================================================
# Warmbly environment
# ==============================================================================
# Copy this file to .env next to docker-compose.yml:
#
#     cp .env.example .env
#
# You do not need it to start. `make up` boots the whole platform with no .env
# at all and prints a link that claims the instance. Copy this file when you are
# ready to run something other people can reach.
#
# Everything here is read from the process environment. The environment is
# authoritative: no screen in the product ever overwrites a value you set here.
# The admin panel on :5174 shows the backend process's resolved value for most
# of these under Instance > Configuration, with its source and whether a change
# needs a restart. It does not read the realtime or tracking containers, so the
# variables in those two blocks below are not on that page.
#
# Full reference, with defaults and restart requirements per variable:
#   https://docs.warmbly.com/development/configuration/
#
# ------------------------------------------------------------------------------
# HOW EMPTY VALUES BEHAVE, BECAUSE IT IS NOT WHAT YOU EXPECT
#
# docker-compose.yml reads this file as ${VAR:-default}, and Compose treats an
# EMPTY assignment exactly like a missing one. So `KMS_LOCAL_MASTER_KEY=` does
# not blank the key, it substitutes the published default from
# docker-compose.yml. To leave a value unset, comment the line out; to change
# it, give it a real value. There is no way to force "empty" here.
# ------------------------------------------------------------------------------
# CHANGE THESE FIVE BEFORE ANYONE ELSE CAN REACH THIS INSTANCE
#
#   AUTH_SECRET  INTERNAL_API_TOKEN  SECRET_KEY_BASE
#   KMS_LOCAL_MASTER_KEY  CREDENTIALS_ENCRYPTION_KEY
#
# docker-compose.yml ships a working default for each so a fresh clone boots.
# All five defaults are published in this repository, so they protect nothing:
# anyone can forge a session token or unwrap every organization key. They ship
# commented out below, so this file boots as-is on those published defaults and
# the backend logs a warning. Generate real values (the Secrets block has the
# commands), uncomment the five lines, THEN uncomment APP_ENV=prod: with prod
# set, the backend refuses to start while any of the five is still a default.
# ------------------------------------------------------------------------------


# === Deployment ===============================================================

# dev | prod. dev tolerates the published default secrets and turns on debug
# logging. prod needs no cloud account. Uncomment this only after the five
# secrets below hold real values, or the backend refuses to start.
# APP_ENV=prod

# cloud | self_hosted. Picks the auth defaults in the Auth policy block below;
# every one stays individually overridable.
DEPLOYMENT_MODE=self_hosted

# true lets the backend boot on a published default secret even under
# APP_ENV=prod. For a throwaway instance only. The shipped docker-compose.yml
# does not forward it, so under compose it only works if you add it there.
# ALLOW_INSECURE_DEFAULTS=false

# debug | release. The shipped docker-compose.yml pins the backend to release,
# so this line only takes effect outside compose.
# GIN_MODE=release

# Label shown next to the instance name in the admin panel.
# ENV_LABEL=production

# true lets customer webhooks point at http:// and private addresses, which
# lets any workspace member make the backend reach into your network.
# Development only. Not forwarded by the shipped docker-compose.yml.
# WARMBLY_ALLOW_UNSAFE_WEBHOOK_URLS=false


# === Secrets (CHANGE ALL FIVE) ================================================
#
# Generate all five at once and append them to this file:
#
#   cat >> .env <<EOF
#   AUTH_SECRET=$(openssl rand -base64 32)
#   INTERNAL_API_TOKEN=$(openssl rand -hex 24)
#   SECRET_KEY_BASE=$(openssl rand -base64 64 | tr -d '\n')
#   KMS_LOCAL_MASTER_KEY=$(openssl rand -base64 32)
#   CREDENTIALS_ENCRYPTION_KEY=$(openssl rand -hex 32)
#   EOF
#
# `make gen-key` prints a single fresh KMS_LOCAL_MASTER_KEY if that is all you
# need. Every line below is commented out on purpose: an uncommented placeholder
# would be just as public as the compose default while hiding from the check
# that refuses to boot on one.

# JWT and session signing. 32 characters minimum. MUST equal the realtime
# service's JWT_SECRET (compose derives that from this value for you).
# AUTH_SECRET=

# Shared token for the backend's /api/v1/internal/ routes. Workers read it as
# ENCRYPTED_KEYS_WORKER_TOKEN and the tracking service reads it directly.
# Unset fails closed: workers cannot fetch keys, tracking cannot resolve links.
# INTERNAL_API_TOKEN=

# Phoenix session signing in the realtime service. 64 characters minimum.
# SECRET_KEY_BASE=

# Root key that seals every per-organization data key. base64, exactly 32 bytes.
# BACK IT UP. Losing it is unrecoverable.
# KMS_LOCAL_MASTER_KEY=

# Seals mailbox SMTP and IMAP passwords at rest. Exactly 64 hex characters.
# BACK IT UP. Set it before you connect a single mailbox. Outside compose an
# unset value does not fail at boot, it stores credentials unsealed; under
# compose an unset or empty value takes the published default instead.
# CREDENTIALS_ENCRYPTION_KEY=


# === Addresses ================================================================
#
# APP_URL is the source of every emailed link: password resets, invitations and
# the first-run claim link. Leave it unset and those links are built against the
# hosted service, which sends a live reset token off your deployment.

# With the shipped docker-compose.yml, set PUBLIC_HOST to your LAN IP or domain
# and APP_URL / API_PUBLIC_URL / CORS_ALLOW_ORIGINS / WEBSOCKET_URL /
# TRACKING_DOMAIN / PHX_HOST and the frontend URLs all derive from it. Note that
# setting it turns localhost off, because a localhost origin is then no longer
# allowed by CORS. Setting any of the six below by hand opts that one out of the
# derivation, which is why they ship commented out.
# PUBLIC_HOST=192.168.1.50

# The compose defaults, shown so you can see what PUBLIC_HOST would replace.
# APP_URL=http://localhost:5173
# API_PUBLIC_URL=http://localhost:8080
# CORS_ALLOW_ORIGINS=http://localhost:5173,http://localhost:5174
# WEBSOCKET_URL=ws://localhost:4000/socket/websocket

# The listen address. The shipped docker-compose.yml pins it, so this line only
# takes effect outside compose.
# API_HOST=0.0.0.0:8080

# Exact origin the mailbox OAuth callback page posts the authorization code back
# to. Unset means a wildcard target origin. Not forwarded by the shipped
# docker-compose.yml.
# APP_ORIGIN=http://localhost:5173

# Host serving open pixels and click links, and the CNAME value customers point
# their own tracking subdomain at. Use a separate, neutral domain in production
# and proxy it to the tracking service on :3000. Unset means mail goes out with
# no pixel and unwrapped links, and no custom tracking domain can verify.
# TRACKING_DOMAIN=localhost:3000

# Host port the tracking service is published on. 3000 is a very common default
# for other self-hosted tools, so remap it here when it is already taken and
# point your reverse proxy at the new port. The container port stays 3000.
# TRACKING_PORT=3001

# Behind a reverse proxy with HTTPS, set each one explicitly instead:
# APP_URL=https://app.example.com
# API_PUBLIC_URL=https://api.example.com
# CORS_ALLOW_ORIGINS=https://app.example.com,https://admin.example.com
# WEBSOCKET_URL=wss://ws.example.com/socket/websocket
# PHX_HOST=ws.example.com
# TRACKING_DOMAIN=t.example.com


# === Network and proxy ========================================================

# CIDRs allowed to set X-Forwarded-For. Empty trusts nothing, which is correct
# for a directly exposed backend. Behind a proxy it is not: with nothing
# trusted, the login rate limiter, session records, audit rows and API key IP
# allowlists all record the proxy's address instead of the client's.
# TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12


# === Database, cache and GeoIP ================================================

PRIMARY_DB=postgres://warmbly:warmbly@postgres:5432/warmbly_dev?sslmode=disable
REDIS=redis://redis:6379
# Pool size for the realtime service only. The Go services use the driver
# default.
# DATABASE_POOL_SIZE=10

# Must be SET on the backend in every environment (an unset value is fatal at
# boot). The file it points at is optional: without it, sessions and audit rows
# are recorded with no city and nothing else changes.
GEODB_PATH=/app/data/GeoLite2-City.mmdb


# === Auth policy ==============================================================
#
# Self-host defaults. Each is independently overridable.
# https://docs.warmbly.com/development/accounts-and-access/

# always | new_device | off. Self-host defaults to off, so a login never depends
# on outbound mail. Registration and reset codes still use email, which is what
# NIST SP 800-63B permits email for.
# AUTH_LOGIN_CODE=off

# REQUIRE_EMAIL_VERIFICATION=false

# true | false | invite_only. Self-host defaults to invite_only.
#
#   false        anyone may create an account from the sign-up form
#   invite_only  the sign-up form is refused, but an invitation link creates the
#                account: the link carries the token that permits the signup.
#                A stranger without one cannot register.
#   true         signups are off, and members cannot mint invitations either
#
# There is one exemption, and it does not apply to `true`: under false or
# invite_only, while the users table is EMPTY any signup is allowed, which is
# what makes a fresh instance claimable. Seeding spends it permanently.
# DISABLE_REGISTRATION=invite_only

# Turns off email and password entirely. Only do this with a working provider
# configured, or there is no way to sign in at all.
# DISABLE_PASSWORD_LOGIN=false

# true lets a verified identity-provider assertion create an account regardless
# of DISABLE_REGISTRATION. Off by default so configuring OIDC never silently
# reopens signup.
# SSO_AUTO_PROVISION=false

# Unauthenticated auth requests per source IP per 15 minutes.
# AUTH_IP_RATE_LIMIT=60

# Encrypts stored TOTP secrets. Falls back to AUTH_SECRET when unset. Not
# forwarded by the shipped docker-compose.yml.
# TWOFA_SECRET=

# Passkeys (WebAuthn). Derived from APP_URL when unset. Changing the RP ID
# invalidates every enrolled passkey, so keep it stable per deployment.
# Passkeys need a secure context: HTTPS, or a localhost address.
# WEBAUTHN_RP_DISPLAY_NAME is not forwarded by the shipped docker-compose.yml.
# WEBAUTHN_RP_ID=app.example.com
# WEBAUTHN_RP_ORIGINS=https://app.example.com
# WEBAUTHN_RP_DISPLAY_NAME=Warmbly


# === First owner ==============================================================
#
# Read ONLY while the users table is empty, so these are a no-op on every later
# restart and cannot be used to fix a lockout. Leave unset and the backend
# prints a single-use setup link instead (`make claim` prints it again).
#
# Produce the hash with `warmblyctl hash-password`, then QUOTE IT here: an
# argon2 PHC string contains $ characters and compose reads those as
# interpolation, which silently mangles an unquoted value.
# WARMBLY_BOOTSTRAP_EMAIL=you@example.com
# WARMBLY_BOOTSTRAP_PASSWORD_HASH='$argon2id$v=19$m=65536,t=3,p=2$...'
# WARMBLY_BOOTSTRAP_ORG=My Organization
#
# Plaintext convenience form. Warns at boot and leaves the password readable
# through `docker inspect`. Prefer the hash, and remove it once the owner exists.
# WARMBLY_BOOTSTRAP_PASSWORD=


# === Single sign-on (generic OpenID Connect) ==================================
#
# The only sign-in path with no dependency on outbound mail, so it is the
# recommended posture for a deployment with no relay. Works with Authentik,
# Keycloak, Zitadel, Pocket ID, Dex and anything else publishing a discovery
# document. RS256 ID tokens only.
#
# The redirect URI is /v1/auth/oidc/callback, NOT /api/v1/auth/oidc/callback.
# Providers match it by exact string.
# OIDC_ISSUER_URL=https://id.example.com/application/o/warmbly/
# OIDC_CLIENT_ID=
# OIDC_CLIENT_SECRET=
# OIDC_REDIRECT_URL=                 # defaults to API_PUBLIC_URL/v1/auth/oidc/callback
# OIDC_SCOPES=openid,profile,email
# OIDC_ALLOWED_DOMAINS=example.com   # without this, any address your IdP asserts
# OIDC_DEFAULT_ORG=                  # org every SSO user joins; without it each
#                                    # new user gets their own single-member org
# OIDC_PROVIDER_NAME=Single sign-on


# === Social sign-in (optional) ================================================
#
# Separate from the BOX_GOOGLE_* mailbox client further down. These are for
# logging INTO Warmbly; those are for sending FROM a mailbox.
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# GOOGLE_REDIRECT_URI=https://app.example.com/auth/google/callback
# GOOGLE_IOS_CLIENT_ID=
# APPLE_APP_ID=
# APPLE_TEAM_ID=
# APPLE_KEY_ID=
# APPLE_KEY_SECRET=


# === Captcha ==================================================================
#
# turnstile with an empty secret fails every verification, which means nobody
# can sign in. Set both, or leave the provider at none.
#
# CAPTCHA_PROVIDER is derived when unset: turnstile if TURNSTILE_SECRET holds a
# value, none otherwise. Set it explicitly only to force turnstile on.
# CAPTCHA_PROVIDER=none
# TURNSTILE_SECRET=
# WARMBLY_TURNSTILE_KEY=             # the site key, read by web + admin
# TURNSTILE_BYPASS_TOKEN=            # only honoured when APP_ENV=dev; not
#                                    # forwarded by the shipped compose file


# === Platform mail (login codes, resets, invitations, digests) ================
#
# MAIL_TRANSPORT is the primary switch: smtp | log | ses.
#
#   log   writes every message to the backend log and delivers nothing. The
#         COMPOSE default, so a fresh install works with no relay. Invitations
#         still work: copy the link from Settings > Members and send it yourself.
#   smtp  a real submission relay. Set SMTP_HOST plus the credentials below.
#   ses   AWS SES; needs AWS credentials and a verified identity.
#
# A bare binary with neither MAIL_TRANSPORT nor SMTP_HOST falls back to ses.
MAIL_TRANSPORT=log

# The backend refuses to start without these two. The consumer only WARNS and
# then silently sends no notification or digest mail, so set them everywhere.
EMAIL_NAME=Warmbly
EMAIL_ADDRESS=noreply@example.com

# SMTP_SECURITY is starttls (587) | tls (465) | none (25). The port follows the
# mode, so setting either alone is enough. Credentials are never sent over an
# unencrypted connection.
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USERNAME=
# SMTP_PASSWORD=
# SMTP_SECURITY=starttls
# SMTP_AUTH=auto                     # auto | plain | login | cram-md5 | none
# SMTP_EHLO_NAME=                    # defaults to the sender domain
# SMTP_TLS_INSECURE_SKIP_VERIFY=false  # only for a relay with a private CA

# Notification volume.
# NOTIFICATION_EMAIL_DAILY_CAP=25    # per user per day; 0 means uncapped
# NOTIFICATION_PUSH_WINDOW=5h


# === Transactional email branding =============================================
# A self-hosted install should not send mail attributed to another company.
# EMAIL_BRAND_NAME=Acme
# EMAIL_BRAND_LEGAL_ENTITY=Acme Ltd
# EMAIL_BRAND_COMPANY_NUMBER=
# EMAIL_BRAND_PLACE_OF_REG=
# EMAIL_BRAND_ADDRESS=
# EMAIL_BRAND_WEBSITE_URL=https://acme.example.com
# EMAIL_BRAND_SUPPORT_EMAIL=support@acme.example.com
# EMAIL_BRAND_TERMS_URL=
# EMAIL_BRAND_PRIVACY_URL=


# === Provider switches ========================================================
#
# Every provider default IN CODE is the cloud one (kafka, avro, s3, aws), so a
# deployment that is not using the shipped docker-compose.yml must set all five
# explicitly or the process exits at boot.

# true reads secrets from AWS SSM / Secrets Manager.
AWS_CONFIG_ENABLED=false

# Event bus. nats: one small JetStream binary. kafka: build the images with
# GO_TAGS=kafka / CARGO_FEATURES=kafka and set KAFKA_* below.
EVENTBUS_PROVIDER=nats
NATS_URL=nats://nats:4222
# NATS_STREAM_NAME=warmbly
# NATS_SUBJECT_PREFIX=warmbly
# KAFKA_BOOTSTRAP_SERVERS=broker:9092
# KAFKA_SASL_USERNAME=
# KAFKA_SASL_PASSWORD=
# SCHEMA_REGISTRY_URL=
# SCHEMA_REGISTRY_KEY=
# SCHEMA_REGISTRY_SECRET=
# EVENTBUS_HANDLER_TIMEOUT=30s

# Serialization. json is REQUIRED wherever workers run: worker command and
# result envelopes carry untyped bodies Avro cannot serialize.
CODEC_PROVIDER=json

# Encryption root key provider. local: the AES master key above. aws: AWS KMS.
KMS_PROVIDER=local
# Instead of the inline key. Not forwarded by the shipped docker-compose.yml.
# KMS_LOCAL_MASTER_KEY_FILE=/run/secrets/kms_master_key
# KMS_AWS_KEY_ID=alias/warmbly

# Blob storage. filesystem: a local dir shared by backend, consumer and every
# worker on the host. s3: any S3-compatible store, and the right answer once
# workers run off-host.
BLOB_PROVIDER=filesystem
BLOB_FS_ROOT=/data/blobs
# Derives from API_PUBLIC_URL, and so from PUBLIC_HOST, when unset. Setting it
# by hand opts it out of that.
# BLOB_PUBLIC_BASE_URL=http://localhost:8080/public
# BLOB_BUCKET=warmbly
# AWS_ENDPOINT_URL_S3=http://minio:9000
# AWS_REGION=us-east-1
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=

# Delayed tasks (campaign ticks, scheduled sends). local is an in-process
# Postgres poller, so the backend must be running for scheduled work to fire.
TASKS_PROVIDER=local
# TASKS_LOCAL_POLL_INTERVAL=1s

# Billing. none unlocks every feature and never expires a trial.
BILLING_PROVIDER=none
# STRIPE_SECRET_KEY=
# STRIPE_WEBHOOK_SECRET=
# STRIPE_PUBLISHABLE_KEY=

# Where the backend and consumer read organization keys from. Workers use http.
# The shipped docker-compose.yml pins it per service, so this line only takes
# effect outside compose.
# ENCRYPTED_KEYS_PROVIDER=postgres


# === Mailbox connections ======================================================
#
# Needed on the backend AND every worker: the backend starts the OAuth flow,
# each worker refreshes the token. A worker missing these connects the mailbox
# fine and then stops silently about an hour later.
#
# Plain SMTP + IMAP mailboxes need none of this.
# Redirect URI: <API_PUBLIC_URL>/addresses/google/callback
# BOX_GOOGLE_CLIENT_ID=
# BOX_GOOGLE_CLIENT_SECRET=
# Redirect URI: <API_PUBLIC_URL>/addresses/outlook/callback
# BOX_OUTLOOK_CLIENT_ID=
# BOX_OUTLOOK_CLIENT_SECRET=


# === Worker (set in each worker's own environment) ============================
#
# Workers hold no database. They reach organization keys over the backend's
# internal API. An unset URL or token lets a worker start, subscribe and NEVER
# register, with no log line. WORKER_BIND_IP, WORKER_PUBLIC_IP and
# WORKER_INSTALLER_PATH are not forwarded by the shipped docker-compose.yml.
# ENCRYPTED_KEYS_PROVIDER=http
# ENCRYPTED_KEYS_BACKEND_URL=http://backend:8080
# ENCRYPTED_KEYS_WORKER_TOKEN=       # same value as INTERNAL_API_TOKEN
#
# Stable identity. Set it only when you run one worker per host; scaled replicas
# share an environment and would collide.
# WORKER_ID=<uuid>
# WORKER_BIND_IP=
# WORKER_PUBLIC_IP=
# WORKER_TIER=free                   # free | premium | dedicated
# WORKER_EGRESS_KIND=
# MAIL_TLS_INSECURE=false            # skips cert verification on mailbox connections
#
# Set on the BACKEND: the image the remote installer pulls. The built-in default
# does not match what CI publishes.
# WORKER_IMAGE=ghcr.io/<owner>/warmbly/worker:prod
# WORKER_INSTALLER_PATH=


# === Tracking service (Rust, open/click) ======================================
#
# The shipped docker-compose.yml pins the listen address, so these two only take
# effect outside compose.
# TRACKING_HOST=0.0.0.0
# TRACKING_PORT=3000
# Where tracking resolves opaque /c/<id> click tickets. Required: the service
# exits at boot without it, and without INTERNAL_API_TOKEN.
# BACKEND_INTERNAL_URL=http://backend:8080
# Counted pixel and click requests per source per minute. Over budget, pixels
# are still served but not counted and click redirects get 429.
# TRACKING_RATE_LIMIT_PER_MIN=300
# Read by the Rust publisher AND the Go subscriber. Override it in one place
# only and opens and clicks stop being consumed, with no error anywhere.
# KAFKA_TRACKING_TOPIC=tracking-events


# === Realtime service (Elixir/Phoenix) ========================================
#
# Read only when the release boots with MIX_ENV=prod, which is how the image
# runs. The shipped docker-compose.yml derives PORT, DATABASE_URL, REDIS_URL and
# JWT_SECRET from the values above, so those four only take effect outside
# compose. None of this block is on the admin panel's configuration page: that
# registry reads the backend process, not this container.

# The realtime service's own hostname. Derives from PUBLIC_HOST under compose.
# PHX_HOST=localhost
# PORT=4000

# MUST equal the backend's AUTH_SECRET or every websocket token is rejected and
# the dashboard loads but never goes live. Compose sets it from AUTH_SECRET.
# JWT_SECRET=

# DATABASE_URL=postgres://warmbly:warmbly@postgres:5432/warmbly_dev?sslmode=disable
# DATABASE_SSL=false
# REDIS_URL=redis://redis:6379

# false accepts a websocket upgrade from ANY origin, which is the shipped
# default. Set true once PHX_HOST is the public websocket hostname, so only that
# origin can open a socket.
# CHECK_ORIGIN=false

# Concurrent websocket connections. Per user is also capped by the caller's
# plan, whichever is lower.
# MAX_CONNECTIONS_PER_USER=10
# MAX_CONNECTIONS_PER_IP=50
# MAX_CONNECTIONS_GLOBAL=100000

# Websocket rate limits, per minute.
# RATE_LIMIT_WS_MESSAGE=120
# RATE_LIMIT_WS_JOIN=30
# RATE_LIMIT_WS_EVENT=60

# Realtime transport. false uses the Redis bridge and needs no cloud. Read
# identically by backend, consumer and realtime; never set true on one side.
PUBSUB_ENABLED=false
# GCP_PROJECT_ID=                    # required when PUBSUB_ENABLED=true


# === AI provider (optional) ===================================================
#
# Omit every AI variable to run with AI off: AI endpoints return a clean 503 and
# everything else works. Set these on the backend AND the consumer.
#
# An empty AI_PROVIDER with a set AI_API_KEY falls back to api.openai.com, which
# sends your key there. Set both or neither.
# AI_PROVIDER=                       # openai | openrouter | groq | ollama | anthropic | custom
# AI_API_KEY=
# AI_MODEL=
# AI_MODEL_TRIAL=
# AI_MODEL_PAID=
# AI_BASE_URL=                       # required for custom
# AI_FREE=
# SEARCH_PROVIDER=                   # serper | searxng
# SEARCH_API_URL=
# SEARCH_API_KEY=


# === Integrations (optional) ==================================================
# Shared redirect URI for the CRM and messaging OAuth flows. Unset, it becomes
# BACKEND_PUBLIC_URL + /integrations/oauth/callback, and http://localhost:8080
# when that is unset too. Not forwarded by the shipped docker-compose.yml.
# INTEGRATIONS_OAUTH_REDIRECT_URL=
# BACKEND_PUBLIC_URL=https://api.example.com


# === Observability and push (optional) ========================================
# Optional in EVERY environment, including APP_ENV=prod.
# SENTRY_DSN=
#
# Mobile push. Partial configuration disables push with a warning, never a
# crash. Set on the backend and the consumer. Not forwarded by the shipped
# docker-compose.yml.
# APNS_KEY=
# APNS_KEY_PATH=
# APNS_KEY_ID=
# APNS_TEAM_ID=
# APNS_TOPIC=
