architecture § OpenDKIM becomes § DKIM signing (keys, the selector table and when it is rewritten, the two sockets, what is signed and the limits, the old tree); the process table, start order, liveness, the mail-path diagram, § Sender check, Persistence and the restore Resync follow the code. security.md: the panel signs with the login's key, the keys are the panel's alone, group selfpost and CAP_KILL as they are now; OpenDKIM's accepted risks go; new ones: what sendmail(1) submits is signed as the server, Sender: is signed but not judged (the owner's decision), and go-milter keeps every header per session, so the signer's 1 MiB refusal does not bound the library's copy. guide: Health, the container log's dkim-signer lines, backups, the long-line limit, and upgrading to 2.8.0 - with the rollback to 2.7.x: its start reads the queue as its own postfix (uid 101) before it hands the queue over, so the hand-over by name is run in the 2.7.x image first (measured on a copy of the test server's queue: postsuper failed before, passed after). development, README, SECURITY, product, the assumptions register's header: what left the image. entrypoint.sh: a comment, the rollback note as the guide gives it. Plan stage 3 lines ticked, roadmap 12/14. Written by four agents, one document each, against a brief of what the code does; reviewed here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
87 KiB
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
- Instance administration
- Domain administration
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:
- Confirm PTR/rDNS for the server IP points at
SELFPOST_HOSTNAME(Server › Health → Re-check DNS) — see Server-level DNS. - For each domain you add in the panel, publish SPF, DKIM, and DMARC at the same time (Domain-level DNS).
- 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.7.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.7.0 \
-t ghcr.io/mixeme/selfpost:2.7.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.
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 Milter
sockets: the DKIM signer's two -
dkim-signer · applicationsfor what applications send on 465/587,dkim-signer · notificationsfor 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_HOSTNAMEagainst 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 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 (withINBOUND_RELAY_ENABLE=true), the DMARC report maps (withDMARC_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, aspostqueue -plists 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 — frompostconf -h, read once when the panel starts. Apostconf -eoverride 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 apostfix reloadafter 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 inmail.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 beginsdkim-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) ordkim-signer: no sending domain for login "shop" - deferring. The sender check's lines beginsender-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.
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.
Rolling back to 2.7.x after that: 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→ Postfixsmtpd_client_message_rate_limitRATE_LIMIT_WINDOW_SECONDS→ Postfixanvil_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
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, so is data/opendkim/ (the key tree an
installation upgraded from 2.7.x keeps for a rollback, see
Upgrading), 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.6.1 but this image is 2.7.0 — restore into the matching image (selfpost:2.6.1)
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. 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 whileDMARC_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 tellOpsfromops, 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.
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 intoReply-To:and send from an address of your own, which is also what DMARC requires.
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
_dmarcTXT record. The panel suggestsp=none(monitoring only, safe to publish immediately). Setrua=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 leavesrua=out. The record the page shows follows that choice. Ifrua=points at another domain, publish_report._dmarcon 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.