Phase 10: deployment (Apache compose + proxy fragments, CI release) + docs

- deploy/docker-compose.yml: pinned-tag ghcr image, hardened (cap_drop ALL +
  minimal cap_add, no-new-privileges, panel bound to 127.0.0.1 only). Apache
  itself runs on the host (spec 10.5), fragment at deploy/apache/.
- Alternative reverse-proxy fragments: nginx (+certbot sidecar), Caddy
  (automatic ACME), Traefik (+acme.json PEM extraction script).
- .github/workflows/release.yml: tag-triggered ghcr.io publish, version piped
  from the git tag into both the binary ldflags and the image tag (spec 10.1).
- Closed a gap from Phase 1: logrotate was installed but never invoked;
  wired up build/logrotate-mail.conf + logrotate-loop.sh + a supervisor
  program (copytruncate, since postlogd holds mail.log open with nothing to
  signal on rotation).
- README rewritten: site requirements checklist, reverse-proxy comparison,
  DNS setup (server- vs domain-level), IP warmup, backup/restore vs domain
  export/import, fixed-tag rationale, machine requirements.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 21:23:19 +03:00
parent f88d8dabcb
commit 61f525e2d7
16 changed files with 594 additions and 8 deletions
+130 -5
View File
@@ -13,12 +13,137 @@ does not receive mail, provide mailboxes, or offer webmail.
> for the full requirements and [docs/implementation-plan.md](docs/implementation-plan.md)
> for the phased build plan.
## Requirements (site prerequisites)
## Requirements (site checklist)
SelfPost assumes the host already provides the conditions for sending from your
own IP — an unblocked outbound port 25, a static IP, configurable PTR/rDNS and a
reasonable IP reputation. Providing these is the operator's job, not a feature of
SelfPost. Detailed deployment docs land in a later phase.
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](#dns-setup)).
- [ ] Reasonable starting IP reputation — a fresh IP still needs [warmup](#ip-warmup).
- [ ] A reverse proxy in front of the panel (see [Reverse proxy](#reverse-proxy-mandatory)) — SelfPost never terminates HTTPS itself.
- [ ] Docker + Compose v2 on the host.
## Quick start
```sh
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.
## 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](deploy/apache/selfpost-vhost.conf) |
| nginx | Host disk, via a certbot sidecar container — same PEM-ready shape as Apache. | [deploy/nginx/](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/](deploy/caddy/) |
| Traefik | Bundled inside `acme.json` — needs a small extraction script to produce standalone PEM files. | [deploy/traefik/](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."
## 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.
## 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 (dashboard → *Backup & migration*), or from the
host:
```sh
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** (dashboard → domain page → *Export
domain*): 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