Files
selfpost/docs/guide.md
T
mixandClaude Opus 5.5 546a1aa461
test / test (push) Waiting to run
release: 2.11.0: health-setup-checks - name, greeting, proxy on Health
Cut without asking, as the owner said on 2026-10-15. Server › Health checks
the certificate's name against the hostname, shows Postfix's greeting and
where the panel sees the viewer from (the commit before this; mockups
b2dcefc, cc33890, 0feaa55). go vet, go test (26 packages) and make e2e
passed on the test server for that tree. No migration, schema 12.

CHANGELOG [2.11.0]; pins in deploy/docker-compose.yml, README and the guide
(pin, build, the sample restore message); development's "from pin" and
row 37; roadmap row. No tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:51:43 +03:00

90 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). A scan over IPv4 sees such a port closed - the connection is refused. Over IPv6 the connection goes through Docker's proxy (the host runs docker-proxy on :: for every published port), which accepts it and then closes it, so a scanner may report it open. Measured on a live server, 2026-10-15, Docker 29.6.1, with a published port nothing listened on: refused from outside over IPv4 and from the host's public IPv4 address; accepted and closed through 127.0.0.1, the same proxy. IPv6 itself was not probed for this.

Since 2.7.0 the image runs Postfix 3.10, whose defaults hold every listener - 465 and 587 as well as 25 - to two rules of SMTP: a message ends only at the standard <CR><LF>.<CR><LF> (a client that ends DATA with a bare newline waits for a reply that does not come), and a client that sends commands ahead of the server's replies where RFC 2920 does not allow it is answered 554 5.5.0 Error: SMTP protocol synchronization and disconnected.

Since 2.7.0 a message sent on 465 or 587 does not tell its recipients where the application runs: the Received: header SelfPost adds reads Received: by mail.example.com (Postfix) id 4AbC12xyz for <...>; <date>, without the application's address, host name or login. To trace a message someone complains about, take that queue id to Outbound › Log; mail.log keeps the client address on smtpd's client= line for the same id.

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.11.0. No 2.x image is published on ghcr.io yet (the newest there is 1.3.0); until one is, build it from the checkout under the pinned name, and Compose uses the local image instead of pulling:

docker build -f build/Dockerfile --build-arg VERSION=2.11.0 \
  -t ghcr.io/mixeme/selfpost:2.11.0 .

VERSION must equal the tag: it is the version the backup check compares (see below). 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, zip or plain 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 (refuse with a temporary error). 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_RATE_LIMIT_MESSAGES_PER_IP Coarse per-client-IP cap on port 25: how many times one client IP may start a message (MAIL FROM) per window, accepted or not (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. This is port 25's whole limit; 465 and 587 keep Postfix's own, 10 240 000 bytes. 26214400 (25 MiB) .env
RATE_LIMIT_MESSAGES_PER_IP Level-1 backstop: how many times one client IP may start a message (MAIL FROM) on one listener per window, accepted or not; 465 and 587 count separately (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, and with them the mail.log lines kept for each message (1–2 KB a message) — the main driver of /data growth over time and of the size of a full backup. 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). Each is given 2 seconds to answer before the next is asked, and the one that answered last is asked first; a lookup none of them answered shows as not known, never as a wrong record. 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. With Apache on the host (the default), the panel sees the proxy's connections come from the Docker network's gateway, e.g. 172.18.0.1 (docker network inspect shows it; measured 2026-10-15: the panel's 127.0.0.1:8080 is published through Docker's proxy, and a connection to such a port from the host arrives from the gateway). (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 — certs/ links to them, no extraction step. deploy/apache/selfpost-vhost.conf
nginx Host disk, via a certbot sidecar container — the same 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 keeps fullchain.pem/privkey.pem at 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 breaks the panel in a browser that does not send Sec-Fetch-Site (Safari before 16.4, or any browser on a panel served over plain HTTP at a name): there the panel compares Origin with Host and refuses every change as cross-origin - the log says so explicitly, printing the Sec-Fetch-Site, Origin and Host it compared. A request that carries neither header - a browser too old to send Origin, or a script posting to the panel without one - is refused the same way since 2.10.4.

In every case the proxy terminates HTTPS for the panel; the resulting certificate must end up under ./certs as fullchain.pem and privkey.pem — the files, or links that lead to them inside the container.

Apache (recommended, on the host). Install Apache with ssl, proxy, proxy_http and rewrite 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

Link the two files into ./certs and let the container see what the links lead to. certbot's live/ files are themselves relative links into archive/ (certbot's documentation, "Where are my certificates?", advises pointing servers at them or linking to them), so the whole of /etc/letsencrypt is mounted, read-only and at the same path — uncomment that line in docker-compose.yml. Mounting live/mail.example.com alone, or linking certs to it, gives the container links into a directory it does not have: Postfix then starts without a certificate.

mkdir certs
ln -s /etc/letsencrypt/live/mail.example.com/fullchain.pem certs/fullchain.pem
ln -s /etc/letsencrypt/live/mail.example.com/privkey.pem certs/privkey.pem

The panel reads them as its own user (Health shows the certificate, a full backup carries both files), through the container's group 10998. certbot's documentation gives the way for a server that is not root, and certbot keeps the key's group and mode at every renewal:

sudo chmod 0755 /etc/letsencrypt/live /etc/letsencrypt/archive
sudo chgrp 10998 /etc/letsencrypt/archive/mail.example.com/privkey*.pem
sudo chmod 0640 /etc/letsencrypt/archive/mail.example.com/privkey*.pem

nginx (containerised). Edit deploy/nginx/nginx.conf.example and replace mail.example.com first. Then, from the deploy/ directory, issue the first certificate - before nginx can serve HTTPS, so certbot answers the challenge itself (--standalone), and port 80 must be free while it runs. The certbot service's own entrypoint is its renewal loop, which ignores any arguments, hence --entrypoint certbot:

docker compose -f docker-compose.yml -f nginx/docker-compose.nginx.yml \
  run --rm -p 80:80 --entrypoint certbot certbot certonly --standalone \
  -d mail.example.com --email you@example.com --agree-tos --no-eff-email

The fragment mounts certbot's directory into both nginx and SelfPost, at /etc/letsencrypt; ./certs holds two links to /etc/letsencrypt/live/mail.example.com/ — a path that exists inside the container, not on the host — and the panel reads the key through group 10998, as in the Apache case. Then start everything:

mkdir certs
ln -s /etc/letsencrypt/live/mail.example.com/fullchain.pem certs/fullchain.pem
ln -s /etc/letsencrypt/live/mail.example.com/privkey.pem certs/privkey.pem
sudo chmod 0755 nginx/certbot-etc/live nginx/certbot-etc/archive
sudo chgrp 10998 nginx/certbot-etc/archive/mail.example.com/privkey*.pem
sudo chmod 0640 nginx/certbot-etc/archive/mail.example.com/privkey*.pem

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

Renewals cannot use the standalone method: nginx holds port 80 by then. The fragment's renewal loop runs certbot renew --webroot -w /var/www/certbot, the directory nginx serves the challenges from; certbot renews with the options it is given and keeps them for the certificate once a renewal with them succeeds (certbot's documentation, "Renewing certificates").

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.

Whatever the proxy, SelfPost notices a renewed certificate in the mount within a day (TLS_RELOAD_INTERVAL_SECONDS) and starts serving it on 465 and 587 without a restart.

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, the panel - whose roles include the DKIM signer - and their helpers), 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, in the order of what stops mail first:

  • Machine — processor (core and thread counts), memory and swap of the host (a memory limit set on the container is not reflected), and per-interface network throughput and totals of the container's own network interfaces, 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 whether its names cover SELFPOST_HOSTNAME - the tag beside Names; a certificate for another name fails, as a client that checks the name refuses it) and Milter sockets: the DKIM signer's two - dkim-signer · applications for what applications send on 465/587, dkim-signer · notifications for the panel's own mail - the sender check, the send log (the journal-milter) and, with one configured, the spam filter. Postfix attaches the signer strictly, so either of its rows down means the mail that goes through it waits.
  • Sender check — what the strict check of the From: line has cost today, in the last 30 days and in the last 12 months: for how long the panel did not answer while Postfix was running, how many connections Postfix turned away because of it, and how many messages it refused with a temporary error in the middle (see Who an application may send as). A restart of the whole container is not counted: mail was stopped for another reason then. A connection turned away is refused before a message is offered, so a monitor that only connects during an outage is counted too; a message refused in the middle is one the panel stopped answering for after it had begun. A time marked ≈ is at most what it shows: at that start supervisord could not say since when Postfix ran. Days are UTC days. The card warns when a connection was turned away or a message refused in the last 24 hours. If these numbers do not stay at nothing, the panel is in the way of your mail more than it should be - report it.
  • 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. Greeting is the 220 line Postfix opens every connection with; it must name the hostname, and another name there is a warning.
  • Your address (in the Configuration card) — the address the panel sees your browser at, and whether it took it from X-Forwarded-For. Behind a reverse proxy that the panel does not trust it is the proxy's own: every visitor then shares one sign-in limit, and the card says what to set in TRUSTED_PROXY_CIDR.
  • Configuration — the Reload configuration button writes again, from the database, every file the panel keeps for the mail path - the DKIM selector table (/data/dkim/selectors), the Postfix sender map, the inbound relay maps and the filter lists (with INBOUND_RELAY_ENABLE=true), the DMARC report maps (with DMARC_REPORTS_ENABLE=true) - removes the keys of deleted domains, and has Postfix re-read them; the signer needs no reload, it reads the table for every message. This is what the first start after a restore does (see Restore). Use it if the daemons drifted from what the panel shows after manual edits under /data. A step that fails is named on the page, and the panel's log says why.

Queue and System log

  • Outbound › Queue (/outbound/queue, global role) — live view of messages Postfix is still trying to deliver, deferring or holding, 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.
  • The container log (docker compose logs selfpost) — what the panel itself says, its milters among it; none of it is in mail.log. A message the DKIM signer cannot sign is not sent unsigned, it waits: Postfix answers an application with a temporary error and the application sends it again later. Why is a line of the container log that begins dkim-signer: and ends - deferring, e.g. dkim-signer: no signing key for example.com - deferring (the selector table has no line for the domain: Reload configuration on Server › Health writes it again from the database) or dkim-signer: no sending domain for login "shop" - deferring. The sender check's lines begin sender-check:.

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: it is where the panel sends notifications while Send me notifications is ticked beside it, and 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. The page also has the switch for notifications and the two DNS records that make them arrive, with when they were last checked; Re-check DNS at the top of the page checks them again once they are published (results are otherwise kept a few minutes).

Notifications

The panel writes to its users when something needs them. Four things are reported, each once:

What When a message is sent A second message
A sending domain's DKIM, SPF or DMARC record stops matching after two hourly checks in a row found it wrong. A resolver that did not answer is not a mismatch; a missing DMARC record is advice on the domain's page, not a notification when two checks in a row find the records right again
A level-2 rate limit refuses mail of a sending domain's applications within a minute of the first refusal at most once a day for the same domain, while it goes on
The TLS certificate is about to expire 14 days before 3 days before, if it is still the same certificate
A message has waited in the queue for more than three days at the next hourly check when no message that old is left

A user with the global role is told all of it; a user with the domain role is told about the DNS records and rate limits of the outbound domains assigned to them. A message goes to the e-mail on the user's Account page and only while Send me notifications is ticked there; a user without an e-mail gets none. Server › Settings has one switch that turns notifications off for everyone. With it off nothing is checked, so what is wrong when it is turned on again is reported then.

Notifications are the panel's own mail. They are sent from postmaster@<SELFPOST_HOSTNAME>, one message per recipient, are not rows of the Outbound log and count against no rate limit. They name the page of the panel to open and carry no link: the panel does not know the address its reverse proxy serves it under.

Two DNS records make them arrive. The server's own name is not one of your sending domains, so it needs records of its own, shown with the values to publish under Server › Settings › Notification mail:

  • a DKIM key for the hostname, which the panel generates and signs notifications with (<selector>._domainkey.<hostname>, TXT);
  • an SPF record for the hostname: v=spf1 a -all (TXT).

Server › Health shows whether each is in place in the Notification mail card. Without them notifications are still sent, and a receiver may file them as spam or, if the parent domain publishes a strict DMARC policy, refuse them. Mail of your sending domains depends on neither. If the hostname is itself one of your sending domains, the DKIM record is that domain's and is already published - and whoever administers that domain can then send mail that looks like the panel's; a hostname that is nobody's sending domain is the better choice.

The e-mail on Account may be at a public mail host (Gmail and the like); only a domain's DMARC report address may not.

A relay that cannot send cannot say so: none of the four is "the relay is down". Watch that from outside (Container health).

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, build that tag as under Fixed image tag while no 2.x image is published, 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.

To 2.8.0 there is nothing to do. From 2.8.0 the panel signs outgoing mail itself, in a milter of its own, and OpenDKIM is no longer in the image; the keys, the selectors and the DNS records stay as they are. The keys move from data/opendkim/keys to data/dkim/keys: on the first start the entrypoint copies them, as the panel's user, into data/dkim.new, renamed into place when the copy is whole, and says so in the container log (entrypoint: the DKIM keys were copied from /data/opendkim to /data/dkim; /data/opendkim is left for a rollback). A copy that fails stops the start - the panel would otherwise give the server's own name a new key, and its published record would no longer match. data/dkim is the panel's alone (directories 0700, files 0600); so is data/opendkim, which is left as it was, for a rollback, until the next release, and which a full backup leaves out. The selector table, data/dkim/selectors, is written from the database at every start, before the signer serves.

To 2.9.0 from 2.8.x there is nothing to do: the first start removes data/opendkim, the key tree 2.7.x read, which 2.8.x kept for a rollback, and says so (entrypoint: removed /data/opendkim, the key tree of 2.7.x; the keys are under /data/dkim); the keys under data/dkim stay as they are. From 2.7.x, start 2.8.x once first: an image of 2.9.0 on a data whose keys are under data/opendkim only stops at the start and says so, rather than give the server's own name a new key. Rolling back from 2.9.x to 2.8.x needs nothing; to 2.7.x, the old tree is gone - only a full backup of 2.7.x brings it back.

Rolling back to 2.7.x from 2.8.x (only): the 2.7.x image signs with OpenDKIM from data/opendkim, which holds the keys as they were at the upgrade. A domain added under 2.8.x has no key there, and 2.7.x sends its mail unsigned; one deleted and added again under 2.8.x has its old key there, which no longer matches its published record. Postfix's queue under data/postfix/queue belongs to Postfix's users, which have other numbers in the two images (postfix 100 in 2.8.0, 101 in 2.7.x); a 2.7.x start reads the queue before it hands it over and stops at postfix check ("scan_dir_push: open directory defer: Permission denied"). With the instance stopped, hand the queue over in the 2.7.x image itself, by name, before its first start (the image is the one you roll back to):

docker run --rm -v ./data:/data --entrypoint sh ghcr.io/mixeme/selfpost:2.7.0 -c 'chown -R postfix:postfix /data/postfix/queue && chgrp postdrop /data/postfix/queue/public /data/postfix/queue/maildrop && chown root /data/postfix/queue /data/postfix/queue/pid'

(Measured on the test server, 2026-10-16, on a copy of its queue: 2.7.x's postsuper failed before and passed after.) Going forward again needs nothing for the queue - 2.8.0 hands it over itself. A domain added under 2.7.x has its key only in data/opendkim/keys/<domain>/, and the entrypoint copies only while data/dkim holds no key at all: with the instance stopped, copy that directory into data/dkim/keys/ (the start makes it the panel's). Removing data/dkim instead has the old tree copied again whole and loses the keys of domains added under 2.8.x.

Container health

The image declares a Docker HEALTHCHECK that probes GET /healthz on port 8080 (unauthenticated). It returns 200 ok when the panel - whose roles include the DKIM signer - and Postfix are both 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 client IP and listener. It keeps working even if the journal-milter (level 2) is down. There is no per-IP bypass except the container's own loopback.

The client IP is the real one over IPv4 only. Docker's port publishing keeps an IPv4 client's address, but the compose network has no IPv6, so a client that reaches the server over IPv6 is carried by Docker's proxy and arrives from the network's gateway (172.18.0.1 by default) - and so does a connection from the host to 127.0.0.1. All such clients share one level-1 budget, an application's client IP list cannot name them, and the spam filter sees the gateway. Measured on a live server, 2026-10-15, Docker 29.6.1: an outside IPv4 client and the host's own public IPv4 address logged as themselves, the host's IPv6 address and 127.0.0.1 as 172.18.0.1.

Full backup and restore

Both downloads - the full backup on Server › Backup and a domain's export on its settings - ask for your account password, the one you sign in with, every time: a session left open is not enough to take them. Wrong guesses count against the same limit as a password change (ten in fifteen minutes).

Full backup is a self-contained project archive: data/ (SQLite, all domains' DKIM keys under data/dkim/, 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 10998 and holds both the panel and Postfix —

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

— and have whatever renews the certificate do the same; certbot does it by itself, keeping the group and mode of a key on renewal (with certs/ as links into /etc/letsencrypt, the commands of "Reverse proxy" above give certbot's own files the group). 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. Links under certs/ are followed: the archive holds the files they lead to. A certs/ that is there with no file in it is refused for the same reason; what a backup leaves out - a link to a directory under certs/, a link under data/ - it names in a warning (the CLI's standard error, the panel's log).

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; the file is written beside that name and takes it only when it is whole, so a backup that fails leaves no half-written file and whatever had the name before. A database that is not where SELFPOST_DB_PATH points is refused, rather than archived empty. 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 10998; links are followed) Yes when certs/ holds the files; links are stored as links unless tar -h 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 — the DKIM selector table 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 Postfix is reloaded, healing any drift between the extracted files and the database. The manifest is deleted only after that Resync is done: a first start that fails on the way leaves it, and the next start is a restore again and runs the Resync. The Reload configuration button on Server › Health runs the same Resync. 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
cd .. && mv selfpost 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 2.10.4 but this image is 2.11.0 — restore into the matching image (selfpost:2.10.4)

Fix the tag in docker-compose.yml (build that tag as under Fixed image tag if it is not published), then docker compose up -d again — the manifest is still there because the failed boot never got to delete it.

An archive unpacked in part — a download cut short, or a tar that stopped at a damaged block (it says so and exits non-zero) — is refused at step 5 as well, before the database is opened:

backup: the archive was not unpacked whole (data/contents.json, its last entry, is not there) - it was cut short or is damaged; nothing was opened. Unpack it again, from an intact copy, into an empty directory

The last entry of every panel and CLI archive, data/contents.json, records each file under data/ with its size and SHA-256, and the first start compares /data with it; a file that differs is named in the message. docker-compose.yml, .env and certs/ are not compared — they are yours to edit before that start. A journal file beside the database (selfpost.db-wal, -shm, -journal) is refused too: no archive holds one, so it is left from what the directory held before, and SQLite would take it up. To unpack again, unpack into an empty directory.

The files are compared once: a first start that gets past the comparison and then fails (its Resync, say) leaves the manifest, and the next docker compose up -d runs the Resync without comparing again - that start had the database open, so its files are no longer the archive's.

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 changes; a new IP needs only DNS (the A record and the PTR). 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 password is checked on the file's first block, before the command writes anything - but the shell's > has already emptied backup.tar.gz before the command starts, so a wrong password leaves it empty; -o keeps a file that was there. A file cut short fails at its end, with the command's exit status non-zero, after what came before has been written: an archive cut so is refused at the restore when it lost any file (see Restore - the record is the archive's last entry). With -o the output takes its name only when it is whole; a command killed outright leaves a .NAME.tmp-* file beside it, readable by its owner alone, to remove by hand.

The CLI also writes encrypted backups for scripted/cron use. The password must be at least 12 characters, as on the panel's forms (decrypting takes any), and it 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. -e with the name only takes the value from your shell's environment, so it is not written on the command line:

SELFPOST_BACKUP_PASSWORD="$PW" docker exec -e SELFPOST_BACKUP_PASSWORD <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. Port 25 ends a message only at the standard <CR><LF>.<CR><LF> (the SMTP-smuggling defence, since 2.6.1); a sending server that ends DATA with a bare newline waits for a reply that does not come, and there is no allow-list for such a sender.

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 upstream has one TLS mode: Postfix keeps the TLS policy per next hop, not per domain, so the mode saved for one domain is the mode of every domain forwarded to the same host and port, and they are changed with it; a domain user who does not manage all of them is refused, and told the mode in use. Recipients are 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. When SelfPost is the backup of another MX, give it a larger number than the primary's: MX records of equal preference are tried in no fixed order, so the mail would be shared, not backed up. 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 (over IPv4; see Rate limiting — level 1 for IPv6), 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 refuse with a temporary error instead, so the sending server tries again.

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 file makes rspamd sign each message, in a third header, X-SelfPost-Filter. The filter is attached so that mail still flows when it is down, and then a sender could write a verdict of his own into those headers; SelfPost therefore records a verdict only next to a signature it can recompute. The key is data/antispam/filter.key: SelfPost creates it on its first start with the inbound relay on, and the sidecar reads it from the directory it already mounts - there is nothing to configure. If Inbound › Log shows selfpost for mail the filter did judge, the sidecar is running without this file: copy antispam/rspamd/ again and restart the sidecar. Nothing of rspamd's needs clearing, then or after a change of the lists or of the key: it rereads the list files by itself and reads the key anew for every message. A restart is needed only for a change of its own configuration - the files of antispam/rspamd/.

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 changed file within about a minute - 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. Only the envelope sender is looked at: a bounce, whose envelope sender is empty, matches no sender entry, whatever its From: line says. 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. For accepted mail its Upstream column says what the upstream made of it - queued (not tried yet), held (in the queue by the filter's verdict, until released), sent, deferred (a greylisting, an upstream that cannot be reached: Postfix tries again) or bounced (refused for good, or expired in the queue; the sender was sent a bounce) - and opens the last reply and the host that gave it. Rows logged before 2.10.0 have no upstream result. 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 refused with a temporary error (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. The DKIM signer does not sign relayed mail (and a signing outage therefore cannot hold it up), 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's domain: to its MX, or, without one, to its A record - SELFPOST_HOSTNAME has that already; a report address on another domain needs that domain's MX to point here. 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. A new login is stored in lower case - Postfix does not tell Ops from ops, so the panel does not let two applications be told apart by case - and the page that shows the password shows the login as it was stored.
  • 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.

Who an application may send as

An application sends as any address of its domain, or only as the addresses listed for it. Since 2.4.0 that rule covers both senders a message has:

  • the envelope sender (MAIL FROM), which Postfix checks;
  • the From: line of the message - the one a mail program shows and the one DMARC holds the DKIM signature's domain to - which the panel checks.

An address with an extension counts as the address: an application listed as invoices@… may also send as invoices+march@…, in the envelope and in From: alike.

An address listed for an application is that application's alone: another application of the same domain, set to any address, may not send as it - Postfix asks who owns the address before it asks who owns the domain.

A message whose From: is anything else is refused with 550 5.7.1 and a text naming the line: another of your domains, the server's own name, an address at a domain that is not on this server, a line with two addresses, or no From: at all. The line must be one address as the mail standard (RFC 5322) writes it - the address a mail program shows - and only that address counts: what a display name or a comment around it says is not judged, and a line with more than one address is refused. A display name with a comma or brackets has to be quoted ("Shop, Inc." <orders@…>), as the standard asks and mail libraries do; unquoted, the line does not read as one address and is refused. The address is held to the application's list the way Postfix holds the envelope sender, a quoted local part as the same address unquoted. The refusal is permanent, so the application does not send the message again; it is a row of the Outbound log tagged wrong From, and the row's page shows what the line said.

Since 2.8.0 a Sender: line - the one a mail program shows beside From: as "on behalf of" - is held to the same rule when the message has one: one address, one the application may send as, and no more than one such line. Otherwise the message is refused the same way, 550 5.7.1 Sender: …, tagged wrong Sender. Most mail libraries write no Sender:; one that writes the application's own address passes. A system that writes for people outside your domains - a ticket system, a CRM - puts its own address in From: with the person's name ("Jane via Support" <support@…>) and the person's address in Reply-To:: their domain in From: would fail DMARC at the receiver anyway.

What this stops working when you upgrade from an earlier version:

  • an application that sends with the From: of another of your domains - give that domain an application of its own;
  • an application with a list whose envelope address and From: address differ - add the second address to the list;
  • a contact form that puts its visitor's address into From: - put it into Reply-To: and send from an address of your own, which is also what DMARC requires;
  • (2.8.0) a message with a Sender: of another domain, or of an address the application is not listed for - drop the line or write the application's own address there.

The check is strict: while the panel does not answer, Postfix answers with a temporary error (451, and 454 to the login): an application that queues its mail sends it again later, one that sends directly gets an error to retry; nothing is accepted unchecked. Server › Health counts how often that happens.

Since 2.8.0 the check also guards the signature: the panel signs an application's message with the key of the domain its login belongs to, and DMARC counts that signature only when its domain aligns with the domain of From:. Without the check an application could send under this server's signature as another of its domains, or as the server itself.

Lines of more than 998 bytes. The signer signs the message as the application handed it over. Postfix's SMTP client breaks a body line longer than 998 bytes when it delivers the message (smtp_line_length_limit), after the signature was made, and the receiver's check of the signature then fails. Such a message is still sent; the container log names its domain and login (dkim-signer: example.com (login "shop"): a body line longer than 998 bytes - ...). Mail libraries keep lines short with quoted-printable or base64; an application that writes its own body has to as well. A message whose header section is over 1 MiB is refused, 552 5.3.4.

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.

Publish all three: receivers weigh SPF, DKIM and DMARC together, and large receivers require them for bulk senders - a domain without them is a common way for mail SelfPost delivered correctly to land in spam. 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. An application that connects over IPv6 is seen from the Docker network's gateway, not its own address (see Rate limiting — level 1).

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 that milter does not answer 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 journal-milter itself does not answer — Postfix is configured to accept rather than refuse it when it cannot reach it. (Since 2.4.0 a panel that is down altogether makes Postfix refuse mail with a temporary error anyway, through the sender check of the same process.) 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, application, status and day (from and to, UTC days, as on the Inbound log); the status rejected is the three kinds of refusal together, which the table tells apart by their tags. A user with the domain role sees only the rows of their domains. The filters are kept by the pages, the refresh and the way back from a message, and the log 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: refused for good by the receiving server, or given up when the queue lifetime ran out), or refused before it was queued. bounced is usually Postfix's word - it returns a message it gave up on to the sender, as it does one refused for good; it is the panel's when Postfix no longer holds a message and mail.log never said what became of it (the log rotated away while the panel was down, or was not readable) - the panel then closes the row as a failure rather than show a delivery it cannot evidence, and counts that it did. A refused row says why in its tag: rate-limited (a level-2 rate limit), wrong client (the application's client IP list) or wrong From (see Who an application may send as); a row refused before 2.4.0 says rejected. A refused message's page shows what Postfix wrote about the refusal: for wrong From the lines of the queue id the message had, for the other two the one NOQUEUE line; a row refused before 2.5.1 says the line was not kept, and one the panel wrote from mail.log after a restart - it had stopped before writing the refusal itself - says so; 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. The lines are kept in the database with the row, so they stay on the page after mail.log has rotated. They go at the next six-hourly prune after the row is deleted by retention, and not while another row carries the same queue id (Postfix reuses ids); a message accepted before 2.2.0 shows them only while they are still in the current file, and the page says so when there are none. 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. The file is checked like the forms before anything is created - the report address, every rate limit, every client address - and refused with the form's own message, naming the field and the application. An application login keeps the case it had (new logins are created in lower case), and one that differs from a login of this server only in case is refused. 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.