mix 25cc34426c docs: decide item B.2 — rotate mail.log by rename + postfix reload
copytruncate loses log records twice per rotation: everything written
since the tailer's last poll (kept in mail.log.1, but skipped because the
descriptor points at the truncated inode) and whatever lands between the
copy and the truncate (gone for good). Those records carry the final
delivery statuses the send log is reconciled from, so a dropped line
means a row stuck in "queued" — not just a gap in the monitoring view,
as the item previously assumed.

Decision recorded, implementation deferred to its own step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 22:23:35 +03:00

SelfPost

Self-hosted outbound SMTP relay with a web control panel, shipped as a single Docker image. Postfix + OpenDKIM + a small Go panel run together under supervisord; the panel manages multiple sending domains, per-domain DKIM keys and SASL-authenticated applications bound to their domain.

SelfPost sends mail straight to the internet from your own IP, with DKIM signing, and is configured once through the panel. It is outbound only — it does not receive mail, provide mailboxes, or offer webmail.

Status: under active development. See docs/specification.md for the full requirements and docs/implementation-plan.md for the phased build plan.

Requirements (site checklist)

Providing these is the operator's job, not a feature of SelfPost — the panel can't fix a blocked port or a missing PTR record for you.

  • A static IP address.
  • Outbound TCP port 25 unblocked (many consumer/cloud hosts block it by default — check with your provider before anything else).
  • PTR/rDNS for that IP set to your mail hostname (see DNS setup).
  • Reasonable starting IP reputation — a fresh IP still needs warmup.
  • A reverse proxy in front of the panel (see Reverse proxy) — SelfPost never terminates HTTPS itself.
  • Docker + Compose v2 on the host.

Quick start

mkdir -p selfpost && 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
mv .env.example .env   # then edit SELFPOST_HOSTNAME etc.
docker compose up -d

This starts SelfPost alone; it assumes Apache is already installed on the host as the reverse proxy (see below) and expects certificates at ./certs. The first log line (docker compose logs -f) prints the one-time setup link — open it to create the admin account. That username and password can be changed later from the panel's Account page (changing the password signs out every other session).

The same link is also written to /data/setup-token inside the container — ./data/setup-token on the host, mode 0600 — and deleted the moment setup completes. If this host ships its container logs to a central aggregator, prefer the file: the link is a bearer token valid for ten minutes, and reading it this way keeps it out of the log pipeline (and out of whatever retains it afterwards) entirely.

docker compose exec selfpost cat /data/setup-token

Reverse proxy (mandatory)

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

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

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

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

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

DNS setup

Two different scopes — don't confuse them:

Server level (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.

Domain level (for every sending domain you add in the panel):

  • 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 (domain page → DKIM TXT record), one selector per domain.
  • DMARC — a _dmarc TXT record (even a conservative p=none starts building reporting/reputation history).

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

The panel checks both scopes for you and tells you what is actually published: the Status page verifies the server's hostname and its reverse record (forward-confirmed reverse DNS), and each domain's page shows a DNS status card comparing the published DKIM record against the key this server signs with, plus the domain's SPF and DMARC records. Results are cached for a few minutes; use Re-check 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.

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.

Backup, restore, and moving a single domain

Two related but distinct operations — spec 7.5:

  • Full backup (whole /data: SQLite, all domains' DKIM keys, all applications' SASL credentials, manifest.json with the version that created it): panel button (BackupFull backup), or from the host:

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

    Restore means unpacking that archive into a fresh /data bind mount and starting a container of the exact same image version that created it — SelfPost refuses to start otherwise and tells you which tag to use. This is why the compose file below 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.

  • Export/import a single domain (domain page → Export domain to write the file, BackupImport a domain to read it back in): moves one domain — its DKIM key and its applications' working SASL passwords — to a different SelfPost instance without regenerating anything, so DNS (the DKIM TXT record) doesn't need to change. Unlike a full restore, this works across different hostnames/instances.

Both files are secrets — they contain the admin password hash (full backup) or working application credentials (domain export) in the clear or in directly reversible form. Treat them like any other credential material: encrypt at rest, restrict who can read them, don't email them around.

Fixed image tag

deploy/docker-compose.yml pins an explicit version (ghcr.io/mixeme/selfpost:X.Y.Z), deliberately never :latest. This is a direct consequence of the backup version check above: 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 pinning the tag 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.

Machine requirements

Rough guide, not a hard floor: 1 vCPU, 512MB1GB RAM (the stack — three processes plus SQLite — idles around 100150MB; the rest is headroom for backups, log-tailer/retention sweeps and concurrent TLS handshakes coinciding), 810GB disk. Disk usage grows mainly from the send log (bounded by SEND_LOG_RETENTION_DAYS, default 90) and the rotated mail.log (kept 14 days in-image), not from the application itself. On boxes with little RAM, a small swap file is cheap insurance against those occasional coincident spikes.

Repository

License

AGPL-3.0. The AGPL closes the "SaaS loophole": if you run a modified version as a network-accessible service, you must make the modified source available to its users — not only when you distribute copies of the code.

S
Description
No description provided
Readme AGPL-3.0 10 MiB
Languages
Go 83%
HTML 7%
CSS 5.3%
Shell 2.7%
JavaScript 0.8%
Other 1.2%