6. Configuration

All Pepsi components read one INI-style configuration file (the format is shared with the GNU Taler tools). The exhaustive option reference lives in the single man page pepsi.conf(5) and in the per-program chapters under Programs.

6.1. 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).

6.1.1. 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, 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 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 Client interoperability). The code default remains AuthEnvelopedData, so deleting the file restores authenticated encryption immediately — which is why this lives in config.d and not in the source.

6.2. 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, DKIM_ALGORITHMS, 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 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 pepsi-stage-srs.

[pepsi-dispatch]

Dispatcher options (MAX_RUNTIME, WORKER_IDLE_TIMEOUT, POLL_INTERVAL, STATS_INTERVAL, CONFIG_FILE, DB_POOL_SIZE). Per-stage worker-pool sizing (PARALLELISM, MAX_MESSAGES, QUEUE_LIMIT) lives in each [stage-<name>] section. See pepsi-dispatch.

[pepsi-httpd] / [pepsi-httpd-listener-<name>] / [pepsi-httpd-cert-<name>]

The HTTP/HTTPS server: a MAX_CONNECTIONS cap, one listener section per socket (SERVE exactly like the ingress listeners; MODE = plain (the default) or tls; 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 pepsi-httpd.

[pepsi-stage-relay-to-smarthost-mta-<name>]

One upstream smarthost each, shared by every smarthost-relay stage. See pepsi-stage-relay-to-smarthost.

6.3. 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-<name>] 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.

6.4. The stage pipeline

The pipeline is wired entirely from [stage-<name>] 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 pepsi-dispatch.

FUSION

(optional) Whether a predecessor may run this stage in its own worker process instead of handing the row back to the dispatcher. Defaults to YES when PROGRAM is one of the fast body-free stages (pepsi-stage- + if, discard, auto-whitelist, block-language, check-whitelist, srs, list or edit-settings) and NO otherwise; [pepsi] ALLOW_FUSION = NO disables fusion globally.

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.

6.4.1. Ordering constraints

The pipeline graph is yours to draw, but five orderings are not free choices, and getting any of them wrong produces mail that looks right. pepsi-setup warns about three of them: a DKIM-signing stage that leads to an encrypt stage, a decrypt stage that leads to an ARC stage, and a decrypt stage with no local delivery downstream.

Encryption comes before DKIM signing. The recommended outbound chain is:

submission → … → encrypt → srs → dkim-sign → relay

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 (pepsi-stage-anti-spam) and language detection (pepsi-stage-detect-language) both want cleartext, and both get it, because encryption is last on the outbound path — which is the reason for the ordering above.

SRS is for forwarded mail, and harmless on the outbound relay tail. It rewrites a sender in somebody else’s domain — mail this host forwards — so that SPF passes at the next hop. The null sender and a sender already in the SRS domain are left alone, so with SRS_DOMAIN set to the primary domain (the wizard’s default) our own users’ mail passes through unchanged, and a relay tail shared by forwarded and submitted mail can run it before signing, in the order pepsi-setup checks for: encrypt → srs → dkim-sign → relay. A user of a further served domain is rewritten too; that costs SPF alignment for their domain, but DMARC still passes on the aligned DKIM signature dkim-sign adds afterwards.

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. 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 pepsi-stage-relay-to-maildir or 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.

6.5. 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 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.

6.5.1. 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

6.5.2. 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, and
# to check each address's PTR against SERVER_NAME above. PUBLIC_IP belongs in
# the relay *stage's* section: pepsi-setup finds it by walking [stage-*] and
# keeping the sections whose PROGRAM is a relay, never by the program's own
# section name.
PUBLIC_IP = 203.0.113.7 2001:db8::25

6.5.3. 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 pepsi-stage-discard).

6.6. 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.

6.6.1. The scope chain

Five layers, lowest precedence first:

Layer

Written by

Applies to

the configuration file

the operator, with a text editor

everything

global

pepsi-config set

everything

domain:<domain>

pepsi-config set --scope

messages whose relevant address is at that domain

address:<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 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 for pepsi.settings.

The domain: and address: layers hold [stage-*] sections only: a stage consults them for the section it is running, per message. Every other section is read once per process and sees the file plus the global layer, so pepsi-config refuses to store a scoped override of one and pepsi-setup run reports any such row as an error.

6.6.2. Why pepsi.settings is a separate table

The two have different write authorities:

  • pepsi.settings is written by account owners, from their own mailbox, through 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”. PostgreSQL enforces the separation: 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.

6.6.3. 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 The secure-link fallback portal). 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.

[pepsi-admin]

Decides who may administer the server and how the administrative surface reaches the database; a database opinion about it would be a way to grant yourself the console.

[pepsi-crypto], [pepsi-srs], [pepsi-origin]

Each holds a server-side key that exists so that the database alone is not enough: the key-encryption keys (and which of them wraps new private keys), the SRS key that authorises relaying to any address, and the proof-of-origin key a returning bounce or payment demand is checked against. A database able to replace one could arrange for the next wrapped key to be one an attacker can open, or for a forged bounce to be relayed or paid. The rest of each section rides along.

[pepsi-wizard]

Here for a third reason: it is not configuration at all, but the setup wizard’s record of the answers it was given — which the browser interview stages as draft rows in this very table. Drafts are invisible to every reader, but a hand-written non-draft row would otherwise land in the effective configuration of every process. Nothing reads the section at run time, so the safe answer is that the database never supplies it either.

The line is “needed before a database connection exists, a security boundary, or the home of a server-side secret”. 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.

Credentials are never stored in the database, whatever section they are in. An option whose name marks it as one — containing PASS, SECRET, TOKEN, CREDENTIAL, CLIENT_ID or PEPPER, the rule that masks a value wherever a configuration is shown — is refused by pepsi-config set and by the administrative API, and ignored if a row for one exists anyway. The rule covers the file a credential is read from and the endpoint it is sent to as well (TOKEN_FILE, TOKEN_ENDPOINT), since a database able to change those can redirect the credential. Put them in the file or in a secrets.d fragment.

Likewise a domain: or address: override is accepted for a [stage-*] section only (see above: nothing else reads those layers), and refused for any other section rather than stored where nothing will read it.

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.

6.6.4. 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 does two things when the notification arrives: it rebuilds its own view of the pipeline from the overlay — which stages exist, and each one’s PROGRAM, PARALLELISM, MAX_MESSAGES and QUEUE_LIMIT — and it retires its stage workers (no message is interrupted: a worker is given no further work and exits once its current messages are done), so their replacements read the new values. The next message is routed and run with the new configuration, which is what makes adding, editing and removing a stage all live. The one hard edge: if the reloaded graph does not parse, the dispatcher shuts down and exits EX_CONFIG rather than route by a pipeline its workers no longer share. The shipped unit restarts it (Restart=always, backing off to once a minute), so it resumes on its own once the configuration is fixed. An overlay that cannot be read (a database error) is different: the dispatcher keeps its current stage graph and retries the reload, rather than dropping every stage defined only in the database.

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.

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.

6.6.5. Secrets stay in files

The database stores only the @inline-secret@ reference, never a secret value, so each fragment under secrets.d stays owned by the single reader that needs it: 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.

6.6.6. 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 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 row reach the table anyway, what happens depends on how bad it is. A row that cannot be represented in the configuration (an unknown scope, a file-only section, a malformed name) is logged loudly and skipped; an overlay that cannot be read at all, or whose merged text does not parse, is logged and the program runs on the configuration file alone. A value that is well-formed but that the program itself rejects — an unparseable duration, a NEXT_STAGE naming no stage — is not skipped: it is part of the effective configuration, and the program fails on it exactly as it would on the same value in the file. pepsi-setup run checks for both (see Validating).

6.6.7. 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.

6.6.8. 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. An archive that lost ownership would either break every unprivileged reader or silently make a secret world-readable.

6.7. 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 not put anything but a [stage-*] section in a domain: or address: scope, and must name a stage section that exists — in the file or defined by the overlay itself, since the dispatcher loads a stage added with pepsi-config set without a restart. The configuration it produces must then still satisfy every affected stage’s own parser: the global layer over the file, and each domain:/address: scope’s chain over that, each running the parsers of the stages it touches.