.. This file is part of PEPSI. Copyright (C) 2026 Pepsi contributors PEPSI is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. ============= Configuration ============= All Pepsi components read **one** INI-style configuration file (the format is shared with the GNU Taler tools). This chapter explains the file structure and how the sections compose into a working pipeline. The exhaustive option reference lives in the single man page :manpage:`pepsi.conf(5)` and in the per-program chapters under **Programs**. File format =========== A file is a sequence of ``[SECTION]`` headers, each followed by ``OPTION = VALUE`` assignments. Section and option names are case-insensitive (conventionally upper case); ``#`` or ``%`` begins a comment; a value may be double-quoted to preserve surrounding whitespace. Three directives compose multiple files — important for keeping secrets out of the world-readable config: ``@inline@ FILE`` Include another file, relative to the current one. ``@inline-matching@ GLOB`` Include every file matching a shell glob. ``@inline-secret@ SECTION FILE`` Merge one section from a mode-restricted file (typically credentials). A directive ends the section it appears in, so write it *after* that section's last option; an option below it would belong to no section and the file would not load. A fragment that cannot be read is only a warning, which makes the options it carries look unset; ``pepsi-setup run`` gives each fragment to the service account meant to read it. Path-valued options undergo ``$``-expansion from the ``[PATHS]`` section or the environment (``$VAR``, ``${VAR}``, ``${VAR:-default}``). Value types are *boolean* (``YES``/``NO``), *number*, *duration* (``h``/``m``/``s`` units only — see the caveat below), *path* and *unix mode* (octal). .. warning:: ``duration`` values are parsed by ``jiff::SignedDuration``, which accepts only hours/minutes/seconds and below. A calendar unit such as ``5 d`` or ``2 weeks`` does **not** parse. For day-or-longer windows write the equivalent in hours (``120 h``) or use an integer option where one is provided (e.g. ``[pepsi-srs] MAX_AGE_DAYS``). Packaged defaults: ``config.d`` ------------------------------- Below the operator's file there is one more layer. Every ``*.conf`` in ``${DATADIR}/config.d`` (``/usr/share/pepsi/config.d`` on a packaged install) is parsed **before** ``pepsi.conf``, so those files supply defaults that ``pepsi.conf`` overrides option by option. They belong to the package: redefine what you want in ``pepsi.conf`` rather than editing them, or an upgrade will discard the change. Two ship today, and they are the two shapes this layer is for: ``vacation.conf`` Configuration that ships as *wording* rather than as a value — the ``[pepsi-vacation-default-message]`` notices in ten languages that :doc:`programs/pepsi-stage-vacation` falls back to. Redefining ``EN`` in ``pepsi.conf`` leaves the other nine in place. ``thunderbird.conf`` A default an operator must be able to *undo in one step*: ``[pepsi] CRYPTO_ALLOW_DOWNGRADE = yes``, so S/MIME is encrypted as AES-256-CBC ``EnvelopedData``. Thunderbird cannot read the AES-256-GCM ``AuthEnvelopedData`` Pepsi's code default produces, and fails silently when it tries (see :doc:`interoperability`). Deleting the file restores authenticated encryption immediately, because the *code* default was never changed — which is exactly why this lives in ``config.d`` and not in the source. The shared sections =================== These sections are read by more than one component, so they live once in the shared file: ``[pepsi]`` Cross-cutting identity and policy: ``KEY_DIR``, ``DKIM_SELECTOR``, ``ARC_DOMAIN``, ``ARC_ALGORITHM``, ``ORIGINATE_SUCCESS_DSN``, ``LOG_JSON`` (structured JSON-lines logging, off by default), ``LOG`` (the pipeline-wide default log level), and the MTA-STS publishing options. See :manpage:`pepsi.conf(5)`. ``[pepsi-postgres]`` Database connection (``CONFIG`` connection string; ``SQL_DIR`` for ``pepsi-setup``). Every component connects to the same database and the one ``pepsi`` schema. ``[pepsi-srs]`` The SRS engine parameters (``SRS_DOMAIN``, ``SECRET``/``SECRET_FILE``, ``MAX_AGE_DAYS``) shared by ``pepsi-stage-srs`` and ``pepsi-ingress``'s reverse decode. See :doc:`programs/pepsi-stage-srs`. ``[pepsi-dispatch]`` Dispatcher options (``MAX_RUNTIME``, ``WORKER_IDLE_TIMEOUT``, ``POLL_INTERVAL``, ``STATS_INTERVAL``, ``CONFIG_FILE``). Per-stage worker-pool sizing (``PARALLELISM``, ``MAX_MESSAGES``, ``QUEUE_LIMIT``) lives in each ``[stage-]`` section. See :doc:`programs/pepsi-dispatch`. ``[pepsi-httpd]`` / ``[pepsi-httpd-listener-]`` / ``[pepsi-httpd-cert-]`` The HTTP/HTTPS server: a ``MAX_CONNECTIONS`` cap, one listener section per socket (``SERVE``/``MODE`` exactly like the ingress listeners, default port 443), and one certificate section per SNI host name (``SNI``, ``TLS_CERT``, ``TLS_KEY``). The served domains and the MTA-STS policy are taken from the ``[pepsi-ingress]`` and ``[pepsi]`` sections. See :doc:`programs/pepsi-httpd`. ``[pepsi-stage-relay-to-smarthost-mta-]`` One upstream smarthost each, shared by every smarthost-relay stage. See :doc:`programs/pepsi-stage-relay-to-smarthost`. Ingress and listeners ===================== ``[pepsi-ingress]`` holds message-acceptance policy (``HOSTNAME``, ``ACCEPTED_DOMAINS``, ``MAX_MESSAGE_SIZE``, ``MAX_CONNECTIONS``, ``DMARC_ENFORCE``, DNS settings). Each ``[pepsi-ingress-listener-]`` section binds one socket: * ``SERVE = tcp`` (with ``BIND_TO``/``PORT``), ``unix`` (with ``UNIXPATH``) or ``systemd`` (socket activation, ``FD_INDEX``). * ``MODE = plain`` | ``starttls`` | ``tls`` selects the transport security; ``starttls``/``tls`` require ``TLS_CERT`` and ``TLS_KEY``. Declare as many listeners as needed — e.g. an MX listener on 25 with opportunistic STARTTLS and a submissions listener on 465 with implicit TLS. .. _config-pipeline: The stage pipeline ================== The pipeline is wired entirely from ``[stage-]`` sections. The stage **name** is an operator-chosen label; the section's ``PROGRAM`` selects the binary, and ``NEXT_STAGE``/``BOUNCE_STAGE`` connect the graph: ``PROGRAM`` *(required)* The stage binary (found on ``PATH`` unless absolute). One binary may serve several stages — it reads its options from whichever section the message is currently at. ``NEXT_STAGE`` *(optional)* Where a message advances on success. A terminal stage with no ``NEXT_STAGE`` removes the delivered message. ``BOUNCE_STAGE`` *(optional)* Where a permanently-failed (or, with ``ORIGINATE_SUCCESS_DSN``, successfully-delivered) message is routed to generate a DSN — usually a ``pepsi-stage-bounce`` stage. ``PARALLELISM`` / ``MAX_MESSAGES`` / ``QUEUE_LIMIT`` *(optional)* The worker-pool sizing the dispatcher applies to this stage: the cap on concurrent worker processes (``PARALLELISM``, default 4, started on demand and reaped when idle), the number of messages a worker handles before it is recycled (``MAX_MESSAGES``, default 1000), and how many messages are pipelined to one worker at once (``QUEUE_LIMIT``, default 4, so the stage's in-flight capacity is ``QUEUE_LIMIT × PARALLELISM`` while the process and connection count stay set by ``PARALLELISM``). See :doc:`programs/pepsi-dispatch`. New messages always enter at ``[stage-init]``, which **must** exist. Each program reads its own options from its section; those are documented per program under **Programs**. Ordering constraints -------------------- The pipeline graph is yours to draw, but five orderings are not free choices. ``pepsi-setup`` warns about each, and each produces mail that *looks* right. **Encryption comes before DKIM signing.** The recommended outbound chain is:: submission → … → encrypt → srs → dkim-sign → relay :doc:`programs/pepsi-stage-encrypt` rewrites the body, and DKIM must sign the bytes that are actually transmitted. Signing first yields a message whose DKIM signature does not verify at the recipient — which is **worse than no signature**, because a broken signature is a stronger negative signal to a receiving MTA than an absent one. **Anything that reads the body comes before encryption.** The pay-to-send gate (:doc:`programs/pepsi-stage-anti-spam`) and language detection (:doc:`programs/pepsi-stage-detect-language`) both want cleartext, and both get it, because encryption is last on the outbound path. That is not a coincidence to preserve by luck; it is the reason for the ordering above. **SRS runs on the inbound path only.** Rewriting the envelope sender of *our own* users' mail would break DMARC alignment — the address is already in a domain we control, so SPF passes without help. **ARC comes before decryption.** The recommended inbound chain is:: init = arc → decrypt → aliases → (anti-spam / language / …) → local delivery An ARC set seals the message *as it arrived*. :doc:`programs/pepsi-stage-decrypt` rewrites the body, so decrypting first would make the sealed ``AMS`` describe bytes nobody else ever saw — a claim about a message that never existed. **Decryption needs local delivery downstream.** Decrypting invalidates the sender's DKIM signature, which is harmless for a message about to be filed in a mailbox on this host and is not for one being relayed onward. The stage therefore runs only for recipients this host serves and splits a message with both kinds; ``pepsi-setup`` warns when no :doc:`programs/pepsi-stage-relay-to-maildir` or :doc:`programs/pepsi-stage-relay-to-lmtp` is reachable from a decrypt stage, because such a stage is either pointless or harmful. The mirror image of the outbound rule applies too: **anything that reads the body comes after decryption**, which is why the content stages sit where they do. Note that the decrypt stage loads the whole message body for every message that passes through it (its ``Load`` is fixed per stage, and whether a message is protected cannot be told from the envelope columns), so placement is a performance matter as well as a correctness one. Worked examples =============== Each example below is a **complete, minimal** pipeline for one job, with no language classification, ARC/DKIM signing or spam filtering — just the shared sections, one listener, and the stages that do the work. They all share the same shell: * ``[pepsi]`` (only ``KEY_DIR`` here — DKIM/ARC are not used in these examples), * ``[pepsi-postgres]`` for the database, and * ``[pepsi-ingress]`` plus one ``[pepsi-ingress-listener-*]`` socket. The listeners set ``MODE = starttls``/``tls`` but no ``TLS_CERT``/``TLS_KEY``: ``pepsi-setup`` auto-fills the certbot paths (see :doc:`installation`). Set them explicitly, or pass ``--no-certbot``, to manage the certificate yourself. Each is a real, self-contained configuration — to layer ARC, SRS, DKIM signing or spam filtering on top, insert the corresponding stages between ``init`` and the delivery stage as the sample ``pepsi.conf`` shows. Minimal inbound (relay to a smarthost) -------------------------------------- An edge MX that accepts mail for our domains and hands every message to one authenticated upstream smarthost. ``init`` *is* the relay stage; the smarthost authenticates us, so no SRS rewrite is needed here:: [pepsi] KEY_DIR = /var/pepsi/keys [pepsi-postgres] CONFIG = postgres:///pepsi [pepsi-ingress] HOSTNAME = mail.example.org ACCEPTED_DOMAINS = example.org [pepsi-ingress-listener-mx] SERVE = tcp BIND_TO = 0.0.0.0 PORT = 25 MODE = starttls # init: relay every accepted message to the upstream smarthost. A successful # delivery is terminal (no NEXT_STAGE). [stage-init] PROGRAM = pepsi-stage-relay-to-smarthost SERVER_NAME = mail.example.org # The upstream smarthost (one [...-mta-*] section per route; CATCH_ALL takes # everything not matched by a DOMAINS list). [pepsi-stage-relay-to-smarthost-mta-upstream] HOST = smtp.relay.example.net PORT = 587 MODE = starttls AUTH = plain USERNAME = relayuser PASSWORD = changeme CATCH_ALL = yes Minimal outbound (relay direct to the internet) ----------------------------------------------- A submission/relayhost that accepts our own users' mail and delivers it directly to each recipient's MX. The listener trusts a local network (``MYNETWORKS``) so those submissions are marked locally-originated and allowed to relay to any domain; outbound mail keeps its own envelope sender, so there is no SRS stage:: [pepsi] KEY_DIR = /var/pepsi/keys [pepsi-postgres] CONFIG = postgres:///pepsi [pepsi-ingress] HOSTNAME = mail.example.org ACCEPTED_DOMAINS = example.org [pepsi-ingress-listener-submission] SERVE = tcp BIND_TO = 0.0.0.0 PORT = 587 MODE = starttls # Trust submissions from this network without authentication (use SASL_TYPE / # TLS_AUTH_CLIENT instead for roaming users). MYNETWORKS = 10.0.0.0/8 # init: deliver directly to each recipient's mail exchanger. Terminal. [stage-init] PROGRAM = pepsi-stage-relay-to-internet SERVER_NAME = mail.example.org # Read by pepsi-setup to build the SPF record listing our sending hosts. (This # section's name is shared with the smarthost stage even when, as here, no # smarthost is configured.) [pepsi-stage-relay-to-smarthost] PUBLIC_IP = 203.0.113.7 2001:db8::25 Minimal local delivery (relay to Maildir) ----------------------------------------- An MX that delivers mail for our domain into local users' ``Maildir/new/``. Every recipient is in ``ACCEPTED_DOMAINS`` (so ingress accepts it) and resolves to a local account, hence no ``NEXT_STAGE`` is needed — add one (a smarthost or bounce stage) if a recipient in our domain may have no local mailbox:: [pepsi] KEY_DIR = /var/pepsi/keys [pepsi-postgres] CONFIG = postgres:///pepsi [pepsi-ingress] HOSTNAME = mail.example.org ACCEPTED_DOMAINS = example.org [pepsi-ingress-listener-mx] SERVE = tcp BIND_TO = 0.0.0.0 PORT = 25 MODE = starttls # init: write each local recipient's copy into their Maildir via the setuid # helper. Terminal for local recipients. [stage-init] PROGRAM = pepsi-stage-relay-to-maildir SERVER_NAME = mail.example.org A staging deployment that never touches the network swaps the delivery stage for ``pepsi-stage-discard`` (see :doc:`programs/pepsi-stage-discard`). Configuration in the database ============================= The file described above is the base layer. On top of it, Pepsi reads a set of administrator-managed overrides from the ``pepsi.config_override`` table, so a running deployment can be reconfigured without editing files and without a restart. Nothing about this is on by default. **With no rows in that table a deployment behaves exactly as its configuration file says** — there is no implicit configuration hiding in the database, and deleting every override returns the system to the file's behaviour. The scope chain --------------- Five layers, lowest precedence first: .. list-table:: :header-rows: 1 :widths: 22 20 58 * - Layer - Written by - Applies to * - the configuration file - the operator, with a text editor - everything * - ``global`` - ``pepsi-config set`` - everything * - ``domain:`` - ``pepsi-config set --scope`` - messages whose relevant address is at that domain * - ``address:
`` - ``pepsi-config set --scope`` - messages whose relevant address is exactly that one * - ``pepsi.settings`` - **the account owner**, by e-mail - messages to/from that one address Higher layers override lower ones **option by option, never section by section**. An option that no layer mentions keeps its file value, so an override is always a targeted amendment rather than a replacement of a whole section. The "relevant address" is the same one the per-address settings layer already uses: the envelope **sender** for a locally-originated message, otherwise each envelope **recipient**. When one message's recipients resolve to different configurations for the stage about to run, the message is split into one row per distinct configuration, exactly as it already is for ``pepsi.settings``. Why ``pepsi.settings`` is a separate table ------------------------------------------ It would be tidier to keep both in one table, and it would be wrong. The two have different write authorities: * ``pepsi.settings`` is written by **account owners**, from their own mailbox, through :doc:`programs/pepsi-stage-edit-settings`, restricted to the stage sections the operator listed in ``EDITABLE_STAGES``; * ``config_override`` **defines the pipeline** and is writable only through the ``pepsi-config`` database role. Merging them would put user-writable rows in the same table as the definition of the mail server, where one bug in a namespace check stops being an override leak and becomes "a user reconfigured the MTA". The separation is a security boundary, not duplication. PostgreSQL enforces it: ``pepsi-setup`` grants ``INSERT``/``UPDATE``/``DELETE`` on ``config_override`` to the ``pepsi-config`` role alone, revokes them from every service account, and **verifies both against the live database** after each install. A stage worker parses hostile mail for a living; it may read the configuration and may not change it. What stays in the configuration file ------------------------------------ Some sections are never read from the database, whatever rows exist. An override naming one of them is ignored at run time and refused at write time: ``[pepsi]``, ``[pepsi-postgres]``, ``[PATHS]`` Needed to find and open the database in the first place, and — for ``[pepsi]`` — the home of the administrator-only cryptographic policy. ``[pepsi-httpd]``, ``[pepsi-httpd-listener-*]``, ``[pepsi-httpd-cert-*]`` The server that serves the configuration interface must always be able to start, whatever the database says. ``[pepsi-ingress-listener-*]`` Listening sockets are bound at start-up, often under socket activation and before privileges are dropped. ``[pepsi-secure-link]`` Its ``PEPPER`` is the server-side secret that makes a stolen database useless (see :ref:`secure-link`). A database able to *replace* it could quietly arrange for the next message stored to be one the attacker can open — the same class of thing as lowering the crypto policy, and it gets the same answer. The line is "needed before a database connection exists, or a security boundary". Listeners are on the second side of it deliberately: if the database could move a listening socket or change its TLS material, a database compromise would become a compromise of the mail server's own ports. .. important:: **Adding a submission port is a text-editor-and-restart operation.** So is changing a listener's TLS certificate, the database connection, or the ``[pepsi]`` crypto policy. No administrative interface can change these, and none should imply that it can. Two further options are file-only for a plainer reason — they are read to open the database itself, before an overlay can exist: ``[pepsi-ingress] DB_POOL_SIZE`` and ``[pepsi-dispatch] DB_POOL_SIZE``. Hot reload, and what needs a restart ------------------------------------ Every write to the table fires a ``config_changed`` notification. What happens next depends on the section: ``[stage-*]`` — **applied without a restart.** ``pepsi-dispatch`` retires its stage workers when the notification arrives (no message is interrupted: a worker is given no further work and exits once its current messages are done), and their replacements read the new values. The next message is processed with the new configuration. Everything else — **needs a restart of the component that reads it.** ``[pepsi-ingress]``, ``[pepsi-srs]``, ``[pepsi-tlsrpt]`` and the rest are read once at start-up. The value is stored, and takes effect when that program is next started. ``pepsi-config`` says which applies after every write. Two things about the pipeline itself are read once by the dispatcher and are therefore in the restart category even though they live in ``[stage-*]`` sections: **which stages exist** and each one's ``PROGRAM`` and ``PARALLELISM``. Changing a stage's *options* is live; adding a stage to the pipeline is not. A component that misses a notification — because its listener was disconnected — re-reads the overlay when the listener reconnects, so a lost notification costs latency and not correctness. Secrets stay in files --------------------- The database stores only the ``@inline-secret@`` *reference*, never a secret value. This preserves the property the current design rests on: each fragment under ``secrets.d`` is owned by the single reader that needs it, so an unprivileged stage can read its own secret and nothing else. Encrypted values in the database would hand every reader one key that opens everything. Editing the overlay ------------------- :: pepsi-config set stage-relay MAX_LIFETIME '48 h' pepsi-config set --scope domain:example.org stage-relay DELAY_DSN_AFTER '4 h' pepsi-config unset stage-relay MAX_LIFETIME pepsi-config list Every write is validated first, by building the configuration the change would produce and running the owning stage's real parser over it — the same check :doc:`programs/pepsi-stage-edit-settings` applies to an e-mailed override. A value that would stop a program from starting is refused with that program's own error message, and nothing is stored. Should a bad value reach the table anyway, no program refuses to start because of it: an unreadable or unusable overlay is logged loudly and the configuration file is used unchanged. Where a value came from ----------------------- :: pepsi-config dump --origin pepsi-config dump --origin --scope address:user@example.org annotates every effective value with the layer that set it (``file``, or the database scope), and each section with whether a change to it is applied live or needs a restart. This is what makes a layered configuration debuggable rather than mysterious. Backing it up ------------- The two halves are backed up by different tools, on purpose: * what is **in** the database — the overrides — is covered by ``pg_dump`` along with the rest of the schema; * what **cannot** be, because it must exist before a database connection does — the configuration file and the ``secrets.d`` fragments — is covered by ``pepsi-config export``, which writes a single password-encrypted archive. The archive records each file's mode, owner and group **by name**, and ``pepsi-config import`` restores them; it refuses rather than guesses when a named account does not exist on the target machine. That is not a nicety: an archive that lost ownership would either break every unprivileged reader or silently make a secret world-readable. Validating ========== Always validate a configuration before relying on it:: pepsi-setup -c /etc/pepsi/pepsi.conf check # against live DNS pepsi-config -c /etc/pepsi/pepsi.conf dump # effective values ``pepsi-setup run`` refuses to change anything if the configuration is invalid: it checks that ``[stage-init]`` exists, that every ``NEXT_STAGE``/``BOUNCE_STAGE`` resolves, that each stage's ``PROGRAM`` configuration parses, and that domains and ``PUBLIC_IP`` values are well-formed. It validates the database overlay too: a stored override must name a real scope, must not name a file-only section (which would be silently ignored), must name a stage section that exists, and the configuration it produces must still satisfy every affected stage's own parser.