Files
selfpost/docs/guide.md
T
mixandClaude Opus 5.5 c731a98a0f
test / test (push) Canceled after 0s
inbound-antispam-panel: findings of the technical and security reviews
Steps 11 and 12 of docs/plans/inbound-antispam-panel.md: 12/12. Two
read-only reviews of d65d632..HEAD by different models (Opus,
technical; Fable, security), each given the code and questions, not
conclusions. Nothing Critical or High in the feature; the High the
technical review found outside it is the commit before this one.

Log tailer (internal/logtail/inbound.go):
  - the reply of a refusal ends at the first "; from=<", Postfix's own,
    and the HELO name after it is whatever is left. Taking the longest
    reply let a HELO name that spelt "; from=<..> to=<..> proto=.."
    supply the row's sender, recipient and reply - and so make a
    refusal read as the deny list's;
  - an address with a quote, a bracket or a space is not kept: a quoted
    sender could otherwise supply the recipient;
  - the journal is cut to its row cap every 500 rows written, not only
    every six hours;
  - a queue id is forgotten when the message is queued or the id is
    opened by another listener, and a stale place in the queue of
    arrivals no longer drops a live id;
  - milter-hold lines are not read: the held message reaches the
    journal milter, which writes its row - it was written twice.
Lists (internal/inbound/lists.go): a change of the table and the
rewrite of the files are one step under a mutex; a failed rewrite puts
a removed entry back and rewrites after taking a new one out.
Store: the row cap removes the oldest by time, as the page orders them.
Page: on a sheet a refusal's text is a Reply whatever it starts with.

Help and the guide say that an allow entry for a sender address trusts
an address anyone can claim (design commit before this one).

docs/security.md: the two reviews; the roles table gains Inbound > Log
and Filter lists; Mail path describes the milters, the tailer and the
lists; four accepted risks - the filter's verdict while it is down, an
allow entry by sender address, the list files' mode, log rows held by
domain name. The plan's stale lines (size and rate refusals, who reads
the journal) are corrected.

Left as found, told to the owner: the journal's rows after an unclean
stop may be written twice (the tailer saves its place every five
seconds); a "Deny sender" link is offered for an address the form then
refuses; 1.2.3.4 and 1.2.3.4/32 are two entries.

Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:51:50 +03:00

62 KiB
Raw Blame History

SelfPost operator guide

Detailed install, configuration, and day-to-day operations. For a short overview and quick start, see README.md.

This guide has three parts: Installation (getting a container running with a working reverse proxy and TLS), Instance administration (running and maintaining the SelfPost server itself — the panel's layout, health, backups, users, upgrades), and Domain administration (day-to-day work on the sending domains hosted on that instance — DNS, the outbound log, rate limits, applications).

SelfPost 2.0 starts from an empty data directory: a /data written by 1.x is refused at start, not upgraded. A domain moves over with its export file, and every panel path changed (see the CHANGELOG, Removed).

Table of contents

Installation

Ports

deploy/docker-compose.yml maps 465, 587, and 25 to the host. Port 465 (smtps) is always active. Port 587 is published even when SUBMISSION_ENABLE=false; nothing listens until you set it to true. Port 25 is published even when INBOUND_RELAY_ENABLE=false; Postfix does not listen on port 25 until you set INBOUND_RELAY_ENABLE=true and/or DMARC_REPORTS_ENABLE=true (see Inbound relay and DMARC reports). Harmless extra publishes can look like open ports in external scans.

Local trial

The README quick start runs a single container with PANEL_COOKIE_SECURE=false and port 8080 published on localhost. No reverse proxy, no ./certs bind mount — Postfix still starts, but Overview and Health will report missing TLS material until you mount PEM files at /etc/postfix/tls/fullchain.pem and privkey.pem.

The one-time setup link is always printed as https://<SELFPOST_HOSTNAME>/setup/<token> (and written the same way to /data/setup-token). For a local trial that means rewriting the host and scheme to http://127.0.0.1:8080/setup/<token> — the path token is what matters; the hostname in the printed URL is not reachable as written.

What works: the full panel — setup, domains, applications, outbound log, queue, system log. What does not: reliable outbound delivery to the public internet (no PTR, no real DNS for your domains, port 25 may be blocked on your network, HELO does not match anything receivers trust).

To exercise SMTP locally as well, generate a throwaway self-signed certificate and mount it before docker run:

mkdir -p /tmp/selfpost-certs
openssl req -x509 -newkey rsa:2048 \
  -keyout /tmp/selfpost-certs/privkey.pem \
  -out /tmp/selfpost-certs/fullchain.pem \
  -days 1 -nodes -subj '/CN=mail.local.test'

Add -v /tmp/selfpost-certs:/etc/postfix/tls:ro to the docker run command (and keep SELFPOST_HOSTNAME=mail.local.test so it matches the certificate CN). Clients must skip TLS verification — the cert is not from a public CA.

Initial setup

On first start the one-time setup URL is printed in the container log (docker compose logs -f) and written to /data/setup-token inside the container — ./data/setup-token on the host, mode 0600 — then deleted when setup completes. The link is https://<SELFPOST_HOSTNAME>/setup/<token> (path token, not a query string), valid for ten minutes. Open it to choose the administrator username and password — until then the panel has no login, and its sign-in page says there is no administrator yet and points at the setup link. If this host ships container logs to a central aggregator, prefer reading the file:

docker compose exec selfpost cat /data/setup-token

Full deployment

Production layout: one docker-compose.yml, a .env, persistent ./data, and TLS PEM files at ./certs (read by Postfix on 465/587). The panel is reached only through a reverse proxy on 443 — port 8080 is bound to localhost in the default compose file.

Artefact Path
Compose file (fixed image tag) deploy/docker-compose.yml
Environment template deploy/.env.example
Apache vhost (recommended) deploy/apache/selfpost-vhost.conf
nginx deploy/nginx/
Caddy deploy/caddy/
Traefik deploy/traefik/

1. Fetch the base files.

mkdir -p selfpost/data selfpost/certs && cd selfpost
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/docker-compose.yml
curl -O https://raw.githubusercontent.com/mixeme/selfpost/main/deploy/.env.example
cp .env.example .env

Edit .env — at minimum set SELFPOST_HOSTNAME to your mail hostname (bare FQDN, e.g. mail.example.com). It must match the PTR record you request from your provider and the certificate your proxy will obtain. See Environment variables for the full list.

2. Reverse proxy and TLS. Pick and set up one proxy — see Reverse proxy (mandatory) for the per-proxy commands. The same certificate must end up under ./certs as fullchain.pem and privkey.pem so Postfix can serve it on 465 (and 587 if enabled).

3. Start SelfPost. If you used Apache on the host (the recommended option), start only the base compose file from your selfpost/ directory:

docker compose up -d

The nginx/Caddy/Traefik fragments already include docker compose up -d — skip this if you ran one of those.

Get the setup URL — open it in a browser to create the admin account (see Initial setup):

docker compose logs selfpost 2>&1 | grep -m1 'http'
cat ./data/setup-token

4. DNS and sending. Before sending real mail:

  1. Confirm PTR/rDNS for the server IP points at SELFPOST_HOSTNAME (Server › Health → Re-check DNS) — see Server-level DNS.
  2. For each domain you add in the panel, publish SPF, DKIM, and DMARC at the same time (Domain-level DNS).
  3. Warm up a new IP gradually (IP warmup).

Fixed image tag

deploy/docker-compose.yml pins an explicit version (ghcr.io/mixeme/selfpost:X.Y.Z), deliberately never :latest. The current pin is 2.0.1. Intermediate CHANGELOG sections (0.2.0…0.6.0) record development cuts from before that image was published. Pinning matters because of the backup version check (see Full backup and restore): the panel binary's embedded version and the image tag that produced it are the same value by construction (the release CI stamps both from one git tag — see .github/workflows/release.yml), so the pin is what makes "restore into the same version" a checkable fact rather than a guess. Upgrade by bumping the tag deliberately, not by riding a moving target — see Upgrading.

Environment variables

Copy deploy/.env.example to .env next to your docker-compose.yml. The table below lists every variable an operator is expected to set; defaults match the code exactly.

Variable Purpose Default Set in
SELFPOST_HOSTNAME Mail-server identity: Postfix HELO/EHLO, SASL realm, certificate CN/SAN, and the hostname the PTR check expects. Bare FQDN only — no scheme or port. (required) .env
SUBMISSION_ENABLE When true, also listen on port 587 with STARTTLS (RFC 6409 submission) alongside the primary 465/smtps listener. false .env
INBOUND_RELAY_ENABLE When true, accept mail on port 25 for domains configured under Inbound › Domains in the panel and forward them to the upstream you set. Off by default — the outbound path is unchanged. See Inbound relay. false .env
DMARC_REPORTS_ENABLE When true, accept DMARC aggregate reports on port 25 only for report addresses configured in the panel, parse gzip/XML, and show summaries under Outbound › DMARC reports. Off by default. See DMARC reports. false .env
DMARC_RATE_LIMIT_MESSAGES_PER_IP Per-client-IP cap on port 25 when DMARC ingest is on. When inbound relay is also enabled, INBOUND_RATE_LIMIT_MESSAGES_PER_IP applies to the shared listener instead. 20 .env
DMARC_MESSAGE_SIZE_LIMIT Maximum report message size in bytes when DMARC ingest is on. 5242880 (5 MiB) .env
INBOUND_ANTISPAM_MILTER Optional milter on the inbound relay listener only (requires INBOUND_RELAY_ENABLE=true; not 465/587 or DMARC-only port 25). Empty = off. Format inet:host:port or unix:/path. Example with deploy/antispam/docker-compose.antispam.yml: inet:antispam:11332. The panel reads it to report on the Spam filter box of the inbound pages and to check that the filter answers (Server › Health). (empty) .env
INBOUND_ANTISPAM_MILTER_ACTION What Postfix does if that milter is down: accept (fail-open) or tempfail (defer). The Spam filter box states which. accept .env
INBOUND_SPAM_LOG_RETENTION_DAYS How long the inbound journal keeps a row, 7-365 days; it also never holds more than 10 000 rows. Seeds the setting on the first start with the inbound relay on. 90 .env
INBOUND_JOURNAL_MILTER_SOCKET Unix socket of the panel's inbound journal milter, the last milter on port 25 while the inbound relay is on. It records accepted inbound mail and never refuses any; there is no reason to change it. /run/selfpost/inbound-journal.sock compose
INBOUND_RATE_LIMIT_MESSAGES_PER_IP Coarse per-client-IP cap on inbound smtpd (smtpd_client_message_rate_limit). Uses the same window as RATE_LIMIT_WINDOW_SECONDS. 20 .env
INBOUND_MESSAGE_SIZE_LIMIT Maximum message size in bytes on inbound smtpd (message_size_limit). When both inbound relay and DMARC ingest are on, the shared listener uses the greater of this and DMARC_MESSAGE_SIZE_LIMIT. 26214400 (25 MiB) .env
RATE_LIMIT_MESSAGES_PER_IP Level-1 backstop: maximum messages one client IP may submit per window (Postfix smtpd_client_message_rate_limit). See Rate limiting — level 1. 100 .env
RATE_LIMIT_WINDOW_SECONDS Level-1 window length in seconds (Postfix anvil_rate_time_unit). 3600 .env
SEND_LOG_RETENTION_DAYS Initial default for how many days of send-log history are kept before the background sweep deletes rows — the main driver of /data growth over time. After the first panel start, change retention under Server › Settings (global role); the env value is only used to seed SQLite when the setting has never been saved. 90 .env
PANEL_SESSION_IDLE_DAYS Sliding idle timeout for the panel login session, in days. There is no absolute cap: a user who keeps coming back stays signed in indefinitely. 7 .env
SELFPOST_DNS_RESOLVERS Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). 1.1.1.1:53, 8.8.8.8:53, 9.9.9.9:53 when unset .env
TRUSTED_PROXY_CIDR Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply X-Forwarded-For for login, setup, and account-change rate-limiting. Leave unset unless you know the exact address of your reverse proxy. A wrong value lets a client spoof its rate-limit key by sending a forged X-Forwarded-For header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. Behind the default Apache host-network setup this is typically the Docker bridge gateway, e.g. 172.18.0.1. (empty — XFF ignored) .env

TLS certificate paths (TLS_CERT_FILE, TLS_KEY_FILE) are fixed in deploy/docker-compose.yml to match the ./certs bind mount — configure the mount, not these variables.

The image also reads a number of internal, non-operator env vars (paths, timeouts, tuning) — not part of this interface; see architecture.md § Configuration if you need them.

Reverse proxy (mandatory)

SelfPost's panel speaks plain HTTP and never terminates TLS itself — a reverse proxy in front of it is not optional. The proxy is also the project's only source of TLS certificates: whatever it obtains via ACME/Let's Encrypt gets bind-mounted read-only into the SelfPost container, and Postfix uses those same PEM files for TLS on 465 (and 587, if enabled). If the panel and the mail service share one hostname — the common case — it's genuinely one certificate serving both.

SelfPost isn't tied to a specific proxy; pick whichever fits your host:

Proxy Where certs live Fragment
Apache (default/recommended) Host disk, via the certbot Apache plugin — PEM files ready to bind-mount, no extraction step. deploy/apache/selfpost-vhost.conf
nginx Host disk, via a certbot sidecar container — same PEM-ready shape as Apache. deploy/nginx/
Caddy Automatic ACME, zero extra containers — simplest, but its on-disk cert path is versioned internal layout, not a stable API; verify it for the Caddy version you run. deploy/caddy/
Traefik Bundled inside acme.json — needs a small extraction script to produce standalone PEM files. deploy/traefik/

Apache is the recommended default because the certbot Apache plugin already writes plain fullchain.pem/privkey.pem files to a predictable path with no extra moving parts between "certificate issued" and "Postfix can read it."

The proxy needs no security configuration of its own. The panel emits its own Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options and Referrer-Policy — deliberately, so the part that's easy to get wrong lives in the service rather than in a config file somebody edits under pressure. There is exactly one thing the proxy must do: pass the original Host header through. All four fragments above already do (Apache ProxyPreserveHost On, nginx proxy_set_header Host $host, Caddy and Traefik by default). A proxy that rewrites Host instead makes the panel reject every form submission as cross-origin — the log says so explicitly, printing the Origin and Host it compared.

In every case the proxy terminates HTTPS for the panel; the resulting certificate must end up under ./certs as fullchain.pem and privkey.pem.

Apache (recommended, on the host). Install Apache with ssl, proxy, and proxy_http enabled. Copy deploy/apache/selfpost-vhost.conf into your vhost directory, replace mail.example.com with your hostname, enable the site, then issue a certificate:

sudo certbot --apache -d mail.example.com

Point ./certs at the PEM files certbot wrote (symlink is fine):

ln -s /etc/letsencrypt/live/mail.example.com certs

nginx (containerised). From the deploy/ directory, merge the nginx fragment and issue the first certificate before nginx can serve HTTPS:

docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
  run --rm certbot certonly --webroot -w /var/www/certbot \
  -d mail.example.com --email you@example.com --agree-tos --no-eff-email

docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml up -d

Edit deploy/nginx/nginx.conf.example and replace mail.example.com first. The fragment bind-mounts certbot's output into both nginx and SelfPost.

Caddy (containerised, automatic ACME). Edit deploy/caddy/Caddyfile and the <hostname> placeholders in deploy/caddy/docker-compose.caddy.yml, then:

docker compose -f docker-compose.yml -f caddy/docker-compose.caddy.yml up -d

Verify Caddy's on-disk cert path for your version before relying on the default mount — see the comment at the top of the Caddy compose fragment.

Traefik (containerised). Edit the Host(...) label and ACME email in deploy/traefik/docker-compose.traefik.yml, start the stack, then extract PEM files for Postfix whenever Traefik issues or renews a certificate:

docker compose -f docker-compose.yml -f traefik/docker-compose.traefik.yml up -d
./traefik/extract-cert.sh ./traefik/letsencrypt/acme.json mail.example.com ./traefik/extracted-certs

Schedule extract-cert.sh (cron or a timer) alongside Traefik's renewals.

Instance administration

After sign-in the global role opens on Overview — the place to answer "is the service healthy and will mail be accepted?" A user with the domain role opens on the first list they reach: Outbound › Domains, or Inbound › Domains when that is all they are assigned.

The panel

The menu is grouped by mail direction, and a path reads like the menu: /<group>/<page>, an entity under its list, an action under the thing it changes.

Menu Pages Path
Overview Overview /overview
Outbound Domains · Log · Queue · DMARC reports /outbound/domains, /outbound/log, /outbound/queue, /outbound/dmarc
Inbound Domains /inbound/domains
Server Health · System log · Backup · Users · Settings /server/health, /server/log, /server/backup, /server/users, /server/settings
User menu (your name) Account · Help · Sign out /account, /help, POST /logout

A page a user cannot reach, or a feature that is off, leaves no gap in the menu: a group with nothing in it is absent. Inbound appears only with INBOUND_RELAY_ENABLE=true and DMARC reports only with DMARC_REPORTS_ENABLE=true. Everything under /server/, and Overview and the queue, answer 404 to a user with the domain role — the same answer a missing page gets.

Pages that used to be one are now several. The old Status page is Overview (one verdict per check) and Server › Health (the tables behind them). The old Settings page is Account (your own name, e-mail and password) and Server › Settings (the instance). A domain has its own page, Domain settings under it, and an application form of its own under the domain.

Help

Help (user menu) is a page of topics, one box per section of the panel — Overview, Outbound, Inbound, Server, Account — and a user is shown the sections their menu shows. The ? in the head of a box opens the topic about that box on the Help page, scrolled to it. The topics explain what a check or control means — kernel counters, TLS on port 465, forward-confirmed PTR, DNS publish hints, rate limits, DMARC report addresses, the spam filter, and similar. They are not a second copy of this document: installation, reverse-proxy setup, backup/restore, environment variables, and security assumptions stay here in docs/guide.md.

Overview and Health

Overview (/overview, global role) shows the stamp — the worst of its checks — and one card per check: machine (processor and memory), supervised processes (Postfix, OpenDKIM, panel), TLS certificate, queue, milter sockets and reverse DNS. A check the panel could not run shows as a warning, never as fine. Below the cards are the outbound domain list and, with the inbound relay on, the inbound one. Each card leads to its table on Server › Health (/server/health) — the queue card to the Outbound queue — and Health has the readings:

  • Machine — processor (core and thread counts), memory and swap, and per-interface network throughput and totals, read from the kernel's counters; CPU and throughput are measured between refreshes, so they appear one refresh after the page opens. A fully busy processor or a machine out of memory is a warning, because both delay or kill the mail path; throughput is only reported.
  • Processes, TLS certificate (validity and expiry) and Milter sockets (OpenDKIM and the journal-milter).
  • Hostname and reverse DNS — compares SELFPOST_HOSTNAME against the PTR record the internet publishes for this server's IP (forward-confirmed reverse DNS), see Server-level DNS; use Re-check DNS after changing DNS.
  • Configuration — the Reload configuration button re-applies OpenDKIM tables and the Postfix sender map from the database (and inbound relay maps when INBOUND_RELAY_ENABLE=true) — use it if daemons drifted from what the panel shows after manual edits under /data. It does not rebuild DMARC Postfix maps; a full restore Resync does (see Restore).

Queue and System log

  • Outbound › Queue (/outbound/queue, global role) — live view of messages Postfix is still trying to deliver or deferring, as postqueue -p lists them. It only shows: nothing there deletes or forces a message. A box states this instance's retry policy — first retry delay, later backoff cap, how long a message stays in the queue — from postconf -h, read once when the panel starts. A postconf -e override inside the container is visible after the next panel (or container) restart. There is no maximum attempt count: Postfix retries until the message is delivered or the queue lifetime runs out.
  • Server › System log (/server/log) — tail of /data/log/mail.log (Postfix and related daemon lines). The log rotates daily (14 files kept) with a postfix reload after each rotation; a background loop checks every six hours. It lives in the data volume, so it survives a container recreate along with the rest of the state — ./data/log/ on the host — but it is not included in backups: it is diagnostics, not state.

Account

Account (/account, every role) is the signed-in user's own: Profile (username and e-mail) and Password. Saving the profile does not ask for the password; changing the password asks for the current one and is rate-limited like a sign-in. The e-mail is the user's own address. Nothing is sent to it yet; it is what a domain's Use my e-mail button fills in (see Domain-level DNS). Application SASL logins are separate and are not changed here.

Settings

Server › Settings (/server/settings, global role) holds what is true for the whole instance: how many days the Outbound log keeps its rows before the background sweep deletes them (7–365 days; takes effect on the next six-hour prune cycle without a container restart), and, read-only, the level-1 rate limit that comes from .env (Rate limiting — level 1). There is no panel-wide DMARC report address: each domain has its own.

Users

Server › Users (/server/users, global role only) creates, edits, and deletes panel users. Everyone in the panel is an administrator, so the two roles name a reach, not a rank, and are called global and domain in the list, the role select and the page headers:

  • global — every page and every domain: Overview, Server (Health, System log, Backup, Users, Settings), the Outbound queue, and every outbound and inbound domain. The only role that adds or deletes a domain and that manages users.
  • domain — only the domains assigned to the user, outbound and inbound separately: the user form has one list for each, and the same name on both lists is two assignments. Each list has All, which includes domains added later; with it ticked the rows below are ignored. A domain user needs at least one domain, or All, on either list. A group with nothing assigned is absent from that user's menu.

What the domain role reaches inside what is assigned:

global domain
Overview, Server, the Outbound queue yes no (404)
Outbound: domain page, applications, Domain settings, log every domain assigned domains
Outbound › DMARC reports every report, with the ingest statistics reports of assigned domains, without the ingest box
Inbound: domain page, upstream, recipients every domain assigned inbound domains
Add or delete a domain, either direction yes no
Export a domain (Domain settings) yes assigned domains
Import a domain (Server › Backup) yes no
Account, Help yes yes

A domain that is not assigned, one that does not exist, and a user with no reach in that direction all get the same 404.

The panel refuses to remove or demote the last global user, so it can never end up with none. A user cannot delete the account they are signed in with. The user form also sets the user's e-mail, which the users list shows.

Sessions

A login survives a container restart: sessions live in SQLite, not in memory. A session belongs to the user it was created for, by id: deleting the user ends their sessions, and renaming them keeps theirs. Expiry is a sliding idle window (PANEL_SESSION_IDLE_DAYS, default seven days) with no absolute lifetime cap — a user who keeps using the panel stays signed in indefinitely. HTMX polling on the monitoring screens (Overview, Health, Outbound log and queue, System log) does not count as activity, so an auto-refreshing tab left open will not keep a session alive forever.

  • Changing your own password on Account signs out every other session of yours but leaves the current browser signed in.
  • A password that a global user sets for another user on the user form ends all of that user's sessions; set for oneself, it behaves like the previous item.
  • Signing out (POST /logout) ends only the current session — other browsers or tabs for the same user keep working until their session rows expire.

Upgrading

Bump the pinned image tag in docker-compose.yml to the target release, then docker compose up -d. The backup version check requires the running image to match the version that created a full backup — see Fixed image tag.

Container health

The image declares a Docker HEALTHCHECK that probes GET /healthz on port 8080 (unauthenticated). It returns 200 ok when OpenDKIM, the panel, and Postfix are all RUNNING under supervisord; otherwise 503 unhealthy. This catches a dead mail path that would still leave the HTTP server up, but it does not verify TLS certificates, DNS records, or end-to-end delivery — use the authenticated Overview and Health pages for that. External monitoring can use the same endpoint through the reverse proxy if you expose it, or poll docker inspect health state on the host.

Server-level DNS (PTR/rDNS)

Once, for the machine itself: PTR/rDNS for the server's IP, pointing at its mail hostname. Most receiving mail servers weigh this heavily; get it from whoever assigns the IP (hosting provider's panel/support), not from your own DNS zone.

Server › Health verifies the server's hostname against this record (forward-confirmed reverse DNS), and Overview shows the verdict. Results are cached for about one minute; use Re-check DNS right after publishing a record.

Per-domain DNS (SPF, DKIM, DMARC) is a separate scope — see Domain-level DNS.

Rate limiting — level 1 (IP backstop)

SelfPost applies two independent layers of rate limiting; both can refuse a submission, but only level 2 (domain/application, see Domain administration) writes a rejected row in the send log. Level-2 ceilings set in the panel cannot exceed level 1 (the panel shows the level-1 values and rejects higher numbers).

Level 1 is always on, configured via .env:

  • RATE_LIMIT_MESSAGES_PER_IP → Postfix smtpd_client_message_rate_limit
  • RATE_LIMIT_WINDOW_SECONDS → Postfix anvil_rate_time_unit

This is an anvil limit per connecting client IP. It keeps working even if the journal-milter (level 2) is down. There is no per-IP bypass.

Full backup and restore

Full backup is a self-contained project archive: data/ (SQLite, all domains' DKIM keys, all applications' SASL credentials, manifest.json with the version that created it), plus docker-compose.yml, .env, and certs/ from the operator directory next to ./data. Delivery logs under data/log/ are excluded, and so is the Postfix queue: the archive is taken while the instance runs, and a copy of a queue Postfix is working on can hold a message twice or not at all. Mail still waiting for delivery is therefore not carried over, and a restored instance starts with an empty queue; to keep the queue, stop the instance and use tar (methods 3 and 4 below). The base compose file mounts the project directory read-only at /selfpost-deploy so the panel and CLI can read those deploy files — without that mount, Full backup refuses with an error.

Taking a full backup

There are four ways to capture a full backup. They differ in whether the instance keeps running, what goes into the archive, and what happens on restore.

1. Panel — while the container is running (no downtime). Server › Backup → Full backup. Encrypt with a password is ticked; untick it for a plain archive. Same archive as the CLI below. The panel runs as its own user, not as root, so the private key in certs/ has to be readable by it: give the file to the container's selfpost group, which has the fixed id 998 and holds both the panel and Postfix —

chgrp 998 certs/privkey.pem && chmod 640 certs/privkey.pem

— and have whatever renews the certificate do the same (a certbot deploy hook, for one). A key readable by root alone is enough for Postfix, but then the panel refuses to make a backup and names the file: it does not hand over an archive without the key.

2. selfpost-backup CLI — while the container is running (no downtime). The panel button and this command call the same code path: a consistent SQLite snapshot via VACUUM INTO, then a gzip tar of data/ (minus diagnostics) plus deploy files. docker exec runs the command as root, so it reads the private key whatever its permissions.

docker exec <container> selfpost-backup > selfpost-backup.tar.gz

Use -o /path/inside/container to write inside the container instead of stdout. Optional encryption: set SELFPOST_BACKUP_PASSWORD or pass -password-file — see Encrypting a backup or export.

3. tar of the whole project directory — with the service stopped. Stop the instance so nothing is writing to SQLite, then archive the operator directory (the folder that contains docker-compose.yml, .env, data/, and usually certs/):

docker compose down
tar czf ../selfpost-backup.tar.gz .
docker compose up -d

Postfix delivery logs under data/log/ are included and are often the bulk of the archive; add --exclude='./data/log' if you only want state. Unlike the panel/CLI backup, this archive has no data/manifest.json, so restore does not run the version guard or the one-time post-restore Resync (see Restore below) — a normal docker compose up is enough when the files were captured cleanly while stopped.

4. tar of ./data only — with the service stopped. Same as (3), but only the data volume:

docker compose down
tar czf ../selfpost-data.tar.gz ./data
docker compose up -d

This is not self-contained: you must keep docker-compose.yml, .env, and certs/ separately (another backup, or unchanged on the same host). Restore is tar xzf selfpost-data.tar.gz into an existing project directory, not into an empty one. Also includes data/log/ unless you exclude it.

Do not tar while the container is running. SQLite uses WAL mode; copying or archiving ./data (or the whole project directory) while the panel and Postfix are writing can produce an inconsistent database. The panel and CLI backup avoid this by snapshotting the database on a live instance; the tar methods avoid it by stopping first.

Panel / selfpost-backup tar project dir (stopped) tar ./data only (stopped)
Downtime None Yes (docker compose down) Yes
Self-contained archive Yes Yes No — deploy files separate
SQLite consistency VACUUM INTO snapshot Safe when stopped Safe when stopped
data/log/ Excluded Included (optional exclude) Included (optional exclude)
Postfix queue No Yes Yes
certs/ private key Yes (the panel needs it readable by group 998) Yes No — deploy files separate
data/manifest.json Written (version stamp) Absent Absent
Restore version check Yes — must match image tag No No
Post-restore Resync Yes, on first boot No No
Password encryption (.spbk) Yes No — encrypt the .tar.gz yourself if needed No

Prefer the panel or CLI when you cannot afford downtime. Prefer a stopped tar of the whole project directory when you already plan maintenance and want a plain archive without going through the container. Use tar of ./data only when deploy files are managed elsewhere and never change.

Restore

Panel and CLI archives (with data/manifest.json) restore by unpacking into an empty project directory (not into ./data alone) and starting a container of the exact same image version that created the backup — SelfPost refuses to start otherwise and tells you which tag to use. On the first successful start after restore, data/manifest.json from the archive is deleted — it guards only that one boot, so a later in-place upgrade is not blocked. On that same first boot the panel also runs one Resync — OpenDKIM's tables and Postfix's sender map are re-derived from SQLite (inbound relay maps when INBOUND_RELAY_ENABLE=true; DMARC maps when DMARC_REPORTS_ENABLE=true) and both daemons are reloaded, healing any drift between the extracted files and the database. The Reload configuration button on Server › Health resyncs OpenDKIM, the sender map, and inbound maps only — not DMARC maps. This is why the compose file pins a fixed tag rather than :latest: without a known version, there'd be no way to tell which image restoring a given backup actually requires (see Fixed image tag).

Stopped-tar archives (methods 3 and 4 above) have no manifest.json, so there is no version guard and no automatic post-restore Resync. Unpack a self-contained project archive into an empty directory (or replace ./data in place for a data-only archive) and docker compose up -d. Use an image tag compatible with the data on disk; when in doubt, match the tag that was running when the archive was taken.

Restoring in place (same host — recovering from data loss, or rolling back after a bad change):

# 1. Stop the instance being replaced
docker compose down

# 2. Move the current project aside rather than deleting it
mv . ../selfpost.before-restore
mkdir selfpost && cd selfpost

# 3. Unpack the backup into the fresh directory
tar xzf ../selfpost-backup.tar.gz

# 4. docker-compose.yml in the archive must pin the exact tag the backup was
#    made with — check if unsure:
tar xzf ../selfpost-backup.tar.gz -O data/manifest.json

# 5. Start it and watch the boot
docker compose up -d
docker compose logs -f selfpost

A version mismatch at step 5 refuses to start and leaves /data untouched — the panel exits with a message naming the tag to use, e.g.:

backup: this backup was created by SelfPost 1.2.3 but this image is 1.5.0 — restore into the matching image (selfpost:1.2.3)

Fix the tag in docker-compose.yml, docker compose pull && docker compose up -d again — the manifest is still there because the failed boot never got to delete it.

Moving to a different host is the same flow: create an empty project directory, unpack the backup there, edit .env (and docker-compose.yml if needed) for the new hostname or proxy, then docker compose up -d. The archive carries certs/ from the old host — re-issue certificates when the hostname or IP changes. Set up the reverse-proxy vhost separately (not in the backup).

The inbound log and the filter lists are in the database and so in the backup, and so are the list files under data/antispam/; the panel writes those files again from the database on the first start after a restore. What the backup does not hold is the spam filter's own side: the antispam/ directory with the compose fragment and the rspamd configuration. Like the reverse-proxy fragments it is part of the installation, not of its state - copy it from deploy/antispam/ again, or the restored instance runs with no filter.

Restoring an encrypted (.spbk) backup needs a running container to decrypt it first — any container with the selfpost-backup CLI works; decryption does not read /data and performs no version check. Start one normally (step 5, but on an empty project you have not unpacked yet), then:

docker exec -i <container> selfpost-backup -decrypt < backup.spbk > selfpost-backup.tar.gz

Stop it, wipe the project directory again, and continue from step 2 above with the resulting .tar.gz — see Encrypting a backup or export for the decrypt command's password options.

Archives and data directories from 1.x cannot be restored into 2.0. The database is refused at start and left untouched — 2.0 starts from an empty data directory. Carry each domain over with its export file instead.

Restoring an archive taken before a session row was removed can bring that session back: session rows travel with the backup, and a browser that still holds the matching cookie is signed in again on the next request if the restored row's idle expiry has not passed. POST /logout removes only the current session; there is no "logout everywhere" — see Sessions for what ends a user's sessions.

See also Exporting and importing a single domain — a different, domain-scoped operation: Export domain is on the domain's settings page, Import a domain on Server › Backup (/server/backup).

Both a full backup and a domain export are secrets — they contain the password hashes of the panel's users (full backup), TLS private keys and .env (full backup), or working application credentials (domain export) in the clear or in directly reversible form. Treat them like any other credential material: restrict who can read them, don't email them around — and encrypt them, which SelfPost does unless you untick the box.

Encrypting a backup or export

Both download forms carry an Encrypt with a password checkbox, ticked by default. Ticked, the file that comes down is an encrypted envelope instead of the plain archive; unticked, it is the plain archive:

Artefact Plain Encrypted
Full backup .tar.gz .spbk (SelfPost backup)
Domain export .json .spde (SelfPost domain export)

The suffixes are for the operator only — the server detects an encrypted file by its magic bytes (SELFPOST1), not by the extension.

The key is derived from the password with scrypt and the contents are sealed with AES-256-GCM, in chunks, so a truncated or altered file fails to open rather than restoring quietly. SelfPost does not store the password — lose it and the file is unrecoverable, which is the entire point.

Import a domain takes an encrypted export directly: choose a .spde file and the password field appears (driven by the file extension in the browser; the server also detects the envelope by its magic bytes). A plain .json export needs no password.

A full backup has to be turned back into a plain archive before it can be unpacked into /data, which the CLI does with the same password:

docker exec -i <container> selfpost-backup -decrypt < backup.spbk > backup.tar.gz

The CLI also writes encrypted backups for scripted/cron use. The password comes from SELFPOST_BACKUP_PASSWORD or -password-file <path> (first line), never from a command-line argument, which would be visible in the process list:

docker exec -e SELFPOST_BACKUP_PASSWORD="$PW" <container> selfpost-backup > backup.spbk

With no password set, the CLI keeps writing the plain .tar.gz it always has.

Inbound relay

Optional backup-MX / forwarder: Postfix accepts mail on port 25 for domains you list under Inbound › Domains and hands each message to the upstream host you configure. It is not mailboxes, IMAP, or webmail — SelfPost never stores the message locally.

Off by default. Set INBOUND_RELAY_ENABLE=true in .env and recreate the container. Until then there is no inbound-relay smtp inet listener, no Inbound group in the menu, and /inbound/… is 404. (Port 25 can still listen when only DMARC_REPORTS_ENABLE=true — see DMARC reports.) Outbound 465/587 is unchanged.

Panel (/inbound/domains): the global role adds and deletes domains. On a domain's page, set the upstream host/port and TLS to that hop (opportunistic / required / off), and choose recipients — an allow-list, or any address at that domain. Inbound domains are assigned to a user with the domain role separately from outbound ones (see Users); such a user sees and changes the upstream and recipients of the inbound domains assigned to them, and cannot add or delete one. The upstream may be any host and port, including loopback and private addresses — see the accepted risks in security.md. A domain with an empty upstream is kept in the database but is not published into Postfix maps, so mail is never accepted with nowhere to send it.

DNS. Unlike sending domains, an inbound domain needs an MX record that points at SELFPOST_HOSTNAME. The domain page shows the value to publish (10 <hostname>.) and a check that succeeds when any MX host matches this server — other MX targets (a primary mail server) are fine; this is how backup-MX is meant to work. Use Re-check DNS after publishing.

Not an open relay. The inbound smtpd offers no SASL. It accepts only domains in relay_domains and only listed recipients (relay_recipient_maps); everything else is reject_unauth_destination / reject_unlisted_recipient. Prefer an explicit recipient list so unknown addresses are refused at RCPT and never generate a bounce (backscatter).

Anti-spam. SelfPost does not ship a filter. To attach one, set INBOUND_ANTISPAM_MILTER (requires inbound relay enabled; not on DMARC-only port 25) and merge deploy/antispam/docker-compose.antispam.yml the same way as the nginx/Caddy fragments:

docker compose -f docker-compose.yml -f antispam/docker-compose.antispam.yml up -d

The milter sees the real client IP, HELO and PTR — unlike the upstream, which only sees SelfPost. Default action is fail-open (accept) so a down sidecar does not block backup-MX; set INBOUND_ANTISPAM_MILTER_ACTION=tempfail to defer instead.

The fragment mounts one file into the sidecar, deploy/antispam/rspamd/milter_headers.conf: it makes rspamd write its verdict - the action and the score with its symbols

  • into two headers of every message it lets through. SelfPost records them with the message, and the upstream receives them with the mail. Copy the antispam/rspamd/ directory next to the fragment; without the file accepted mail is still recorded, with no verdict.

The same directory holds multimap.conf, the rules for the allow and deny lists kept in the panel. SelfPost does not apply the lists itself: it writes them as six files under data/antispam/ (allow_ip.map, allow_from.map, allow_domain.map and the three deny_ ones), the fragment mounts that directory into the sidecar, and rspamd rereads a file within seconds of a change - no restart. An entry is a sender address, a sender domain (that domain, not its subdomains) or a client address or network. A denied sender is refused outright. An allowed one is given a weight that no spam score outweighs, so it is accepted - unless a deny entry matches the same message: deny wins, whichever entries they are. A sender address or domain on the allow list is not proof of anything: it is the envelope sender, which the sending server states and nobody checks, so whoever knows an allowed address can send as it. Allow a client address or network where you can. The files are readable by anyone on the host; they hold addresses, not secrets.

In the panel the lists are Inbound › Filter lists (global role), and what port 25 decided about each message is Inbound › Log: accepted mail with the filter's verdict, refused mail with the reply it was given. A row of the log leads to the lists with its sender filled in. Beside each entry of a list is how often it decided a message, counted from the log - so over the days the log keeps - and never marks an entry that may have outlived its use. The count depends on two things in multimap.conf: the message of the deny rules and the names of the allow symbols; change them and every entry reads never. A user with the domain role sees the log of the inbound domains assigned to them, and not the lists. How long the log keeps a row is set under Server › Settings.

The panel only reports this setting; it does not change it. Every inbound domain page has a Spam filter box — on or off, what happens to mail while the filter is down, and that the filter belongs to the server, not to the domain — and the domain list says in its head whether the filter is on. The filter's address (INBOUND_ANTISPAM_MILTER) is shown to the global role only, so a user with the domain role sees the state without the server's internals. For the global role the filter is also one of the milter sockets on Server › Health and in the Overview card: the panel connects to the address (one second at most, nothing is sent) and, if there is no answer, says whether inbound mail is being deferred (tempfail) or going through unfiltered (accept).

Inbound mail is not in the Outbound log. Port 25 runs its own milter chain: the optional anti-spam milter, and nothing else. OpenDKIM does not sign relayed mail (and a signing outage therefore cannot defer it), and the journal-milter does not file it — the Outbound log stays a record of what you sent. This also keeps a forged From: on inbound mail from counting against a sending domain's level-2 rate limit.

Inbound configuration lives in SQLite and /data/postfix/ map files, so it is included in a full backup. Single-domain export/import is sending domains only.

DMARC reports

Optional ingest of DMARC aggregate reports (rua=): Postfix accepts mail on port 25 only for addresses you configure, pipes each message to the panel ingest worker, and stores parsed summaries in SQLite. Forensic reports (ruf=) are not stored. This is separate from Inbound relay — no backup-MX, no forwarding upstream.

Off by default. Set DMARC_REPORTS_ENABLE=true in .env and recreate the container. Until then there is no report ingest, no DMARC reports entry in the Outbound menu, and /outbound/dmarc is 404. Outbound 465/587 is unchanged.

Addresses. A domain's report address (rua=) is one of three choices, set in that domain's Domain settings (Outbound › Domains › the domain › Domain settings) and nowhere else:

  • No reports — the state of a new domain; rua= is left out of the DMARC record.
  • SelfPost hosted — dmarc-reports+<domain>@<hostname>, an address on this server whose reports are parsed and shown under DMARC reports. Offered only while DMARC_REPORTS_ENABLE=true.
  • A specific address — any mailbox, typed for this domain; it receives the raw XML. Beside the field, Use my e-mail fills it with the e-mail of your profile (Account); it only fills the field, and saving is the form's own button. The button is absent when your profile has no e-mail.

Changing the choice changes the DMARC record to publish; the domain page shows the new value. There is no panel-wide or per-user default. Only addresses on SELFPOST_HOSTNAME that a domain has chosen are accepted on port 25 - the hosted address dmarc-reports+<domain>@<hostname> of a domain set to SelfPost hosted, or a typed address that happens to be on this host. There is no mailbox for the server as a whole.

DNS. Publish the usual _dmarc TXT on each sending domain with rua=mailto:… pointing at the address the domain shows. Receivers deliver to the address domain — publish MX for SELFPOST_HOSTNAME (or the report address domain if different) so reports reach this server. If a hub domain authorises external destinations, publish _report._dmarc there too; the panel checks it on the domain page.

Panel. Outbound › DMARC reports lists recent reports. The global role also sees the Ingest box — whether ingest works, the last report received, reports kept this week and parse failures. Open a domain's roll-up from the list or from DMARC reports in the domain page's menu. A user with the domain role sees the reports of the domains assigned to them, without the Ingest box. Summaries are pruned (500 kept for each domain, 90 days max).

A message that is not a report the relay keeps — unreadable as an aggregate report, about a domain not sent from here, or delivered to another domain's report address — is dropped without a retry and without a bounce. It adds one to parse failures, and the reason is on the message's delivery line in the mail log (Server › System log, dropped: …).

Not an open relay. The inbound smtpd offers no SASL. With DMARC ingest alone, check_recipient_access permits only configured report addresses; everything else is rejected. With inbound relay enabled too, both allow-lists apply.

Reports are checked before they are stored. The address form is predictable, so the report XML is treated as untrusted input: a report is accepted only when the domain it claims in policy_published is a sending domain configured here, and — when it arrived at a per-domain hosted address — only when that domain matches the address's +tag. Anything else is refused and counted in parse failures on the DMARC reports page, so a stranger cannot file reports under your domain or push genuine ones out through the retention cap. Reports are accepted as gzip, zip, or plain XML.

Parsed report data lives in SQLite and is included in a full backup.

Domain administration

Outbound domains

Outbound › Domains (/outbound/domains) lists sending domains with the verdict of each DNS check, the DKIM selector, the number of applications and recent mail, and hosts the add-domain form (global role only). A user with the domain role sees only the domains assigned to them. Three pages belong to a domain:

  • The domain page (/outbound/domains/{id}) — the DNS records to publish and what is in DNS now, the Applications (SASL logins) with New password and Delete in their rows, and the Connection settings. Re-check DNS and Add application are in its head; its menu leads to the domain's log and DMARC reports.
  • The application form (…/applications/new, …/applications/{aid}) — who the application may send as, its client IP allow-list and its rate limit are validated and saved together, or not at all, also when the application is created. A password is shown once, on the page that answers the creation or New password, and is never shown again.
  • Domain settings (…/settings) — the DMARC report address (see DMARC reports), the domain's Rate limit (see Rate limiting — level 2), Export domain and, for the global role, Delete domain, which asks once more.

Export domain writes a single-domain file; Import a domain on Server › Backup reads one back in (global role only) — see Exporting and importing a single domain.

Domain-level DNS (SPF, DKIM, DMARC)

For every sending domain you add in the panel (outbound). An inbound forwarding domain is a different object — it needs an MX, not these TXT records; see Inbound relay.

  • SPF — a TXT record on the domain authorizing this server to send on its behalf (e.g. v=spf1 a mx ip4:<server IP> -all, adjusted to your setup).
  • DKIM — a TXT record with the exact value the panel shows on that domain's page (DNS records box), one selector per domain.
  • DMARC — a _dmarc TXT record. The panel suggests p=none (monitoring only, safe to publish immediately). Set rua= to receive aggregate reports by choosing the domain's report address in Domain settings: with DMARC reports enabled, the SelfPost-hosted address; otherwise an address typed for the domain — a mailbox elsewhere that receives mail (Use my e-mail fills in your profile's) — or no reports, which leaves rua= out. The record the page shows follows that choice. If rua= points at another domain, publish _report._dmarc on that hub domain too; the panel checks it. Public mail hosts (Gmail, Outlook, …) cannot be used as external report destinations.

Skipping any of the three records is the single most common reason mail lands in spam even though SelfPost delivered it correctly — DKIM passing doesn't help if SPF/DMARC are absent. Whenever you add a new domain in the panel, add its DNS records at the same time, not later.

Each domain's page has a DNS records box comparing the published DKIM record against the key this server signs with, plus the domain's SPF, DMARC, and (when configured) DMARC report-authorisation records. Results are cached for a few minutes; use Re-check DNS right after publishing a record. The SPF check is deliberately shallow — it looks for a mechanism that literally covers this server's address and does not follow include: or redirect=, so a record that authorizes the server through an include is reported as "cannot tell" rather than as a failure.

Server-level DNS (the PTR/rDNS record) is a separate, once-per-machine scope — see Server-level DNS.

IP warmup

A brand-new IP has no sending history, so receiving servers are cautious with it regardless of how correct your DKIM/SPF/DMARC are. Start with low volume to a domain, increase gradually over days/weeks rather than sending everything on day one, and check the IP against major blocklists (Spamhaus and similar) before and during warmup. This is inherent to how mail reputation works on the public internet, not something SelfPost's configuration can shortcut.

Rate limiting — level 2 (domain and application)

Level 2 is optional, configured in each domain's settings (the domain) and on each application's form (the application), and layers on top of the always-on level-1 IP backstop. Level-2 ceilings cannot exceed level 1 (the panel shows the level-1 values and rejects higher numbers). When a level-2 ceiling is exceeded, Postfix returns a 4xx and the refusal is recorded in the Outbound log as rejected.

Level 2 — domain — a message ceiling and window for every client IP sending as that domain. When unset, only level 1 applies for non-privileged senders.

Client IP allow-list (application) — optional restriction on an application: when enabled, list one or more client IPs that may submit mail as that application; a session from any other address is refused with a 4xx at MAIL FROM. When disabled, any client IP is allowed. This is independent of rate limits, and it takes exact addresses only — CIDR ranges are not parsed.

It is not an authentication boundary. The SASL login itself still succeeds; the check runs in the journal-milter, on the same fail-open path as level 2 (below), so if the milter is unavailable the restriction is skipped rather than enforced. Use it to narrow where an application may send from, not to contain a leaked password — for that, regenerate the password.

Level 2 — application — optional override of the domain limit for one application (≤ level 1). The ceiling may be higher or lower than the domain limit. When active, it replaces the domain limit for that login; when unset, the domain limit (or level 1 alone) applies.

Manual and auto mode — each domain and application limit can be Manual (you set the ceiling and window) or Auto. Auto derives max_messages from sending statistics: ceil(average msg/h × multiplier) over the level-1 window (RATE_LIMIT_WINDOW_SECONDS), capped at level 1. The panel shows 30-day statistics (total, peak and average msg/h) on the domain page; level-1 refusals are not in the send log, so totals under-count strict IP limits. When retention is below 30 days, statistics use min(30, retention) days. With no traffic in the window, auto stays inactive until messages are sent. Auto limits are recalculated every six hours and on demand via Recalculate now.

Level 2 is best-effort, not a guarantee. It runs inside the journal-milter and is deliberately fail-open: if the rate-limit lookup hits a store error, or the connecting client's IP is not available to the milter, level 2 is skipped and the message is accepted rather than held up. The same applies if the milter itself is down — Postfix is configured to accept rather than defer when it cannot reach it. Level 1 is the backstop that keeps working even when level 2 cannot run, and it is the only one of the two that does not depend on the panel process.

Outbound log

Outbound › Log (/outbound/log) is a searchable send log with server-side filters by domain and application; a user with the domain role sees only the rows of their domains. It refreshes its rows without resetting a filter being chosen. A row identifies its message and nothing more — time, sender, recipient, subject and status queued (accepted, not yet delivered), sent (handed off successfully), deferred (Postfix is retrying), bounced (final failure), or rejected (refused — for example by a level-2 rate limit); Details opens that row's own page (/outbound/log/{id}). That page carries the sending domain, the application it was submitted under, the Postfix queue id and the journal id, beside the message's history — when it was accepted and what Postfix later reported for the recipient. A deferred or bounced row includes this Postfix's retry intervals (first delay, backoff cap, queue lifetime), the same numbers the Outbound queue shows; users with the domain role see them here because they cannot open the queue. Under both sit the mail.log lines for its queue id: the connection to the receiving server, the server's reply, and the status that reply was filed as. Rows outlive mail.log, so an older message's lines may have rotated away; the page says so. Retention is set under Server › Settings (global role); SEND_LOG_RETENTION_DAYS in .env is only the initial default until it is changed there.

Exporting and importing a single domain

Domain settings → Export domain to write the file, Server › Backup → Import a domain to read it back in. This moves one domain — its DKIM key, its applications' working SASL passwords, and configured rate limits (mode, ceilings, multipliers) and client IP allow-lists — to a different SelfPost instance without regenerating anything, so DNS (the DKIM TXT record) doesn't need to change. Unlike a full restore (see Full backup and restore), this works across different hostnames/instances. Import is for the global role only; export is available to any user who can access the domain, including a user with the domain role for a domain assigned to them — so that user can walk away with the domain's working SASL passwords, in the clear if they untick the encryption box. Weigh that when deciding which domains to assign to a domain user.

A domain export is a secret in the same way a full backup is. The Encrypt with a password box is ticked by default, and the export is sealed the same way — see Encrypting a backup or export.