Ferroma
Operations · Documentation

Deployment

Who should read this: the operator standing up a Ferroma server, and the one who gets paged when it stops delivering mail.

This document is the complete operational path: the DNS records to create before the first boot, which compose file to use and when, the environment variables that must be set, the port table, the TLS options (Ferroma-terminated versus a reverse proxy) and how to get a Let's Encrypt certificate, first-run setup, creating domains and users, generating and publishing a DKIM key, the backup and restore procedure and why the ordering matters, monitoring and health checks, upgrades and rollback, and a troubleshooting section keyed by symptom with the command that diagnoses each one.

Status: the deployment artefacts are real and complete — Dockerfile, docker-compose.yml, docker-compose.prod.yml, docker-compose.external-db.yml, .env.example, config/ferroma.toml, scripts/deploy.sh, scripts/backup.sh, scripts/restore.sh. The ferroma binary implements every subcommand this document uses: serve, config check|show|default, database init|status, migrate, user, domain, dkim, storage, sync, healthcheck, doctor, version.

There is exactly one exception: the HTTPS listener. api.tls_port (default

  1. is read by the configuration but is never bound — the HTTP API serves

plaintext only (api.port, default 8080). Webmail / Admin / API HTTPS must be terminated by a reverse proxy, see §5.3 and §5.4. SMTP and IMAP implicit TLS (465 / 993) is terminated by Ferroma itself, but note that smtps_port / imaps_port default to 0 (off) and must be enabled explicitly.


1. What a deployment consists of

                       Internet
                           │
        ┌──────────────────┼───────────────────┬──────────────┐
        │                  │                   │              │
      :25                :587/:465           :143/:993      :443
    inbound MX         submission           IMAP           HTTPS (API,
        │                  │                   │            Webmail, Admin)
        └──────────────────┴───────────────────┘              │
                           │                                  │
                  ┌────────▼──────────────────────────────────▼────────┐
                  │                  ferroma container                 │
                  │  one process: SMTP, IMAP, HTTP API, queue workers, │
                  │  sync service, event bus                          │
                  └────────┬───────────────────────────────┬──────────┘
                           │                               │
                  ┌────────▼────────┐            ┌─────────▼──────────┐
                  │ postgres:16     │            │ volume ferroma-data│
                  │ (internal only) │            │  mail/ attachments/│
                  └─────────────────┘            │  tls/ backups/     │
                                                 └────────────────────┘

Two containers minimum. PostgreSQL is never published to the host: it is on ferroma-internal and reachable only by the ferroma service.

If this host already runs PostgreSQL (and a reverse proxy for 443), do not start a second database container: use docker-compose.external-db.yml and ./scripts/deploy.sh from §3.1 — that is Ferroma itself, a backup sidecar, and the database you already have.

Requirements before you start:

RequirementWhy
A host with a static public IPv4 addressan MX needs a stable address, and the PTR record must match it
Port 25 reachable inboundreceiving mail from other servers. Many VPS providers block it by default — ask them to unblock it before you begin
Port 25 reachable outbounddelivering mail. Some providers block outbound 25 to force you through a relay
A domain you controlexample.com below
Docker Engine 24+ with the Compose plugindocker compose, not docker-compose
~4 GB RAM, 2 vCPU, 20 GB diskenough for a small deployment; the mail store grows

2. DNS records

Do this first. Specification §42 and §54: a perfectly configured Ferroma with no DNS records will have all of its outbound mail rejected and will receive nothing. Create every record below before the first boot.

Throughout, the example zone is example.com and the server is mail.example.com at 203.0.113.10. Replace both.

2.1 The records, as a table

TypeNameValueTTLPurpose
Amail.example.com203.0.113.103600the server's address
AAAAmail.example.com2001:db8::103600optional; omit if you have no working IPv6, a broken AAAA breaks delivery
MXexample.com10 mail.example.com.3600where mail for the domain goes
PTR10.113.0.203.in-addr.arpamail.example.com.3600reverse DNS — set at your hosting provider, not in your own zone
TXTexample.com"v=spf1 mx -all"3600SPF: only this host may send
TXTdefault._domainkey.example.com"v=DKIM1; k=rsa; p=…"3600DKIM public key, from §7
TXT_dmarc.example.com"v=DMARC1; p=quarantine; rua=mailto:[email protected]; adkim=r; aspf=r"3600DMARC policy and reporting
TXT_mta-sts.example.com"v=STSv1; id=20260916000000"3600MTA-STS policy version
CNAMEmta-sts.example.commail.example.com.3600where the policy file is served
TXT_smtp._tls.example.com"v=TLSRPTv1; rua=mailto:[email protected]"3600TLS reporting (optional but useful)
CAAexample.com0 issue "letsencrypt.org"3600only this CA may issue certificates

2.2 A BIND-style zone snippet

$TTL 3600
$ORIGIN example.com.

; --- address ---
@               IN  A       203.0.113.10
mail            IN  A       203.0.113.10
; Only publish AAAA if IPv6 genuinely works end to end. A host that
; advertises AAAA and cannot answer on it loses mail from dual-stack senders.
; mail          IN  AAAA    2001:db8::10

; --- mail routing ---
@               IN  MX  10  mail.example.com.

; A "null MX" says this domain accepts no mail. Do not publish it on a
; domain that has users:
; @             IN  MX  0   .

; --- sender authentication ---
@               IN  TXT     "v=spf1 mx -all"

; DKIM: paste the p= value from `ferroma dkim generate` / the Admin panel.
default._domainkey IN TXT  "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..."

; DMARC. Start at p=none to collect reports, then tighten.
_dmarc          IN  TXT     "v=DMARC1; p=quarantine; rua=mailto:[email protected]; ruf=mailto:[email protected]; adkim=r; aspf=r; pct=100"

; --- transport security ---
_mta-sts        IN  TXT     "v=STSv1; id=20260916000000"
mta-sts         IN  CNAME   mail.example.com.
_smtp._tls      IN  TXT     "v=TLSRPTv1; rua=mailto:[email protected]"

; --- issuance control ---
@               IN  CAA     0 issue "letsencrypt.org"
@               IN  CAA     0 iodef "mailto:[email protected]"

; --- optional service hostnames (specification §42) ---
; webmail       IN  CNAME   mail.example.com.
; admin         IN  CNAME   mail.example.com.

2.3 The PTR record

Reverse DNS is set at whoever owns the IP block — your VPS provider's control panel, usually. An MX with no PTR, or a PTR that does not match the name in the EHLO, is the single most common reason legitimate mail is rejected or junked.

# What the world sees for your address. It must equal FERROMA_HOSTNAME.
dig +short -x 203.0.113.10
# expected: mail.example.com.

Set FERROMA_HOSTNAME=mail.example.com and make the PTR match it exactly, including that the forward A record for that name points back at the same address. The round trip is what a receiver checks:

dig +short mail.example.com        # -> 203.0.113.10
dig +short -x 203.0.113.10         # -> mail.example.com.

2.4 MX priority and a backup MX

A single MX with preference 10 is correct for one server. If you ever add a second, give it a higher preference number — lower is preferred:

@   IN  MX  10  mail.example.com.
@   IN  MX  20  mail2.example.com.

Do not add a second MX that does not have the same mailbox data. A backup MX that accepts mail it cannot deliver is worse than no backup MX: the sender believes the message was accepted.

2.5 SPF: getting it right

RecordMeaning
v=spf1 mx -allhosts in this domain's MX records may send. The usual choice for a mail server that is its own MX
v=spf1 a:mail.example.com -allan explicit host, when the MX is elsewhere
v=spf1 mx ip4:203.0.113.10 -allbelt and braces
v=spf1 mx ~allsoftfail: mark as suspicious rather than reject. Use while migrating
v=spf1 mx ?allneutral: SPF proves nothing. Do not ship this
v=spf1 mx include:_spf.other.example -alladd a third party that also sends for the domain

Rules that matter:

# Check what receivers will see.
dig +short TXT example.com | grep spf1

2.6 MTA-STS

MTA-STS tells senders to require TLS to your domain and to validate your certificate, which closes the opportunistic-downgrade hole described in security.md §7.4. It needs both a TXT record and an HTTPS file.

_mta-sts    IN  TXT     "v=STSv1; id=20260916000000"
mta-sts     IN  CNAME   mail.example.com.
# Served at https://mta-sts.example.com/.well-known/mta-sts.txt
version: STSv1
mode: enforce
mx: mail.example.com
max_age: 604800
FieldValuesNotes
modenone, testing, enforcestart at testing for a week and read your TLS reports, then enforce
mxone line per MX hostmust list every MX, or a sender will refuse the ones you omitted
max_agesecondshow long a sender may cache the policy; 604800 is a week

Changing the policy means changing the id in the TXT record. A sender caches by id, so an unchanged id with a changed file is ignored.

GET /.well-known/mta-sts.txt is served by ferroma-api (api.md §2). Point the CNAME for mta-sts.<domain> at this host and let the reverse proxy forward that path to Ferroma: the nginx snippet in §5.4 proxies everything, so no extra rule is needed. The certificate must cover mta-sts.<domain> (§5.5).

2.7 DMARC: roll it out gradually

StageRecordWhat you learn
1. Monitorv=DMARC1; p=none; rua=mailto:[email protected]who sends as your domain, including relays you forgot about
2. Quarantinev=DMARC1; p=quarantine; pct=25; rua=…tightens slowly, so a missed sender only affects a quarter of its mail
3. Enforcev=DMARC1; p=reject; rua=…the target state

Ferroma's own inbound policy mirrors this: policy.dmarc_failure_action defaults to "quarantine", not "reject", because a forwarded message routinely fails SPF and DKIM and is still legitimate (security.md §8.1).

Read the rua reports. A DMARC report you never open is p=none forever, which is the same as having no policy and thinking you have one.

2.8 Verifying the zone

# Everything at once.
dig +short MX  example.com
dig +short A   mail.example.com
dig +short -x  203.0.113.10
dig +short TXT example.com              | grep spf1
dig +short TXT default._domainkey.example.com
dig +short TXT _dmarc.example.com
dig +short TXT _mta-sts.example.com
curl -s https://mta-sts.example.com/.well-known/mta-sts.txt

# Ask a DNSBL whether your IP is already listed (use a real one, this is illustrative).
# 203.0.113.10 is documentation-reserved and will never be listed.
dig +short 10.113.0.203.zen.spamhaus.org

The Admin panel's DNS Health screen (GET /api/v1/domains/:id/dns, api.md §4.4) runs exactly these checks and returns a score out of 7.


3. The three compose files

FileUse it forTLSPostgresImagesExtra
docker-compose.external-db.ymla host that already runs PostgreSQL and a reverse proxy (recommended, see §3.1)the reverse proxy terminates HTTPS; Ferroma terminates 465/993none — it uses the host's Postgresferroma:${FERROMA_IMAGE}, built here or docker pullednightly backup sidecar, network_mode: host, .env is the whole configuration
docker-compose.ymldevelopment, a single host, a first lookoff; ports 25/587/143/8080 plaintextpostgres:16-alpine, defaultsbuilt locally from Dockerfile, tagged ferroma:dev
docker-compose.prod.ymla real MXterminated by Ferroma on 465/993 (HTTPS goes to the reverse proxy)tuned (shared_buffers=512MB, wal_compression=on, …)ferroma:${FERROMA_VERSION} — a released tag, never builtnightly backup sidecar, resource limits, restart: always, bounded logs, ulimit nofile 65536

Do not use docker-compose.yml in production. It serves IMAP and the API in plaintext, has no backup sidecar and no resource limits, and it binds port 143 to the host unencrypted.

If this Linux host already runs PostgreSQL and already has a reverse proxy (nginx, Caddy, …) owning 443, do not start a second database container: use docker-compose.external-db.yml, driven by scripts/deploy.sh. It is the route with the fewest steps and the smallest change to the environment you already have.

git clone … && cd ferroma
./scripts/deploy.sh

It does the following, in order. If any step fails it prints the exact command that fixes it rather than leaving you to guess:

StepWhat it does
1. Preflightare docker, the compose plugin and the compose files all present
2. Collect configurationasks interactively: mail domain, MX hostname, admin address, database address, API port (default 127.0.0.1:18080)
3. Write .envgenerates a random database password and FERROMA_JWT_SECRET, mode 600; it is the only configuration file
4. Create the role and the databasetries, in order: sudo -u postgres (peer auth), the psql inside a PostgreSQL container on this host (how 1Panel and similar panels run it, through docker exec), and the superuser named by --pg-password; if none works it prints SQL you can paste — in the docker exec form when the database is a container
5. Build the imagea local docker build (10–30 minutes the first time); docker pull instead when FERROMA_IMAGE names a registry
6. Create the schemaruns ferroma database init in the container (which also creates the database when it is missing)
7. Install the certificateinstalls the certificate into ./tls as uid 10001 for 465/993, and checks that the SAN covers the MX hostname
8. Startdocker compose up -d, waiting up to 3 minutes for the health check and printing the log on timeout
9. First-run initialisationcreates the domain and the admin account (the password is printed once), generates the DKIM key and prints the TXT record to publish
10. Summarythe reverse-proxy snippet, the DNS records still missing, and the everyday commands

The trade-offs that matter:

The sub-commands you will use:

./scripts/deploy.sh status              # containers / health / database
./scripts/deploy.sh logs
./scripts/deploy.sh backup              # back up once, now (the nightly run is automatic)
./scripts/deploy.sh upgrade             # back up → rebuild the image → restart → wait for health
./scripts/deploy.sh restore /backups/20260916T030000Z
./scripts/deploy.sh dkim --enable       # turn signing on after the TXT record is published
./scripts/deploy.sh certs               # re-install a renewed certificate and restart (used by the certbot hook)
./scripts/deploy.sh doctor
./scripts/deploy.sh down [--volumes]

An unattended run (cloud-init, CI) has a flag for every prompt:

./scripts/deploy.sh --yes --domain example.com --admin [email protected] \
  --db-password "$DB_PW" \
  --tls-cert /etc/letsencrypt/live/mail.example.com/fullchain.pem \
  --tls-key  /etc/letsencrypt/live/mail.example.com/privkey.pem

With no certificate the script turns TLS off and warns explicitly: there is then no STARTTLS on 587, mail clients cannot authenticate, and it is only good for getting the database and the API up.

3.2 Development / single host

cp .env.example .env
# Edit .env: at minimum POSTGRES_PASSWORD and FERROMA_JWT_SECRET.
docker compose up -d
docker compose logs -f ferroma

What it brings up: postgres (internal network only, expose: 5432) and ferroma (ports 25, 587, 143, 8080; volumes ferroma-data, ./config/ferroma.toml mounted read-only, ./tls mounted read-only).

3.3 Production (with its own database)

cp .env.example .env
# Edit .env and set every REQUIRED variable: see §4.
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.prod.yml logs -f ferroma

Differences that matter operationally:

FERROMA_TLS_ENABLED: 'true'
FERROMA_TLS_CERT: /etc/ferroma/tls/fullchain.pem
FERROMA_TLS_KEY: /etc/ferroma/tls/privkey.pem
FERROMA__API__SECURE_COOKIES: 'true'
FERROMA__SMTP__REQUIRE_TLS_FOR_AUTH: 'true'
FERROMA__IMAP__REQUIRE_TLS_FOR_LOGIN: 'true'
# The implicit-TLS listeners are off by default (0). Without these two lines the
# 465/993 published by compose map to a port nobody listens on — the connection
# is refused rather than reporting any error.
FERROMA__SMTP__SMTPS_PORT: '465'
FERROMA__IMAP__IMAPS_PORT: '993'
FERROMA_DKIM_ENABLED: ${FERROMA_DKIM_ENABLED:-false}
FERROMA_DKIM_KEY: /etc/ferroma/dkim/${FERROMA_DKIM_SELECTOR:-default}.private

It publishes no HTTPS port at all: Ferroma has no HTTPS listener (see the status note at the top of this file), and Webmail / Admin / API are reached by the reverse proxy at 127.0.0.1:8080 — add 127.0.0.1:8080:8080 to ports to do that, see §5.3.

It also mounts the backup sidecar's inputs:

backup:
  image: postgres:16-alpine
  entrypoint: ['/bin/sh', '/usr/local/bin/backup.sh']
  # A loop, not a one-shot: a one-shot script under restart: always writes another
  # full backup on every restart backoff, until the disk fills.
  command: ['--loop']
  restart: unless-stopped
  volumes:
    - ./scripts/backup.sh:/usr/local/bin/backup.sh:ro
    - ferroma-data:/mail:ro
    - backups:/backups

3.4 Operating commands you will actually type

# Follow the log of one service.
docker compose -f docker-compose.prod.yml logs -f --tail=200 ferroma

# Restart just Ferroma (PostgreSQL keeps running).
docker compose -f docker-compose.prod.yml restart ferroma

# A shell inside the container, as the ferroma user.
docker compose -f docker-compose.prod.yml exec ferroma sh

# A psql session against the database.
docker compose -f docker-compose.prod.yml exec postgres \
  psql -U ferroma -d ferroma

# Disk usage of the two volumes.
docker system df -v | grep -E 'ferroma-data|ferroma-postgres-data'

# Stop everything, keeping the volumes.
docker compose -f docker-compose.prod.yml down

# Stop everything and DESTROY the volumes. This deletes all mail and users.
# docker compose -f docker-compose.prod.yml down -v

4. Environment variables

Copy .env.example to .env. Compose interpolates it, and the compose files refuse to start without the required ones.

4.1 Required in production

VariableExampleRequired byNotes
POSTGRES_PASSWORDopenssl rand -base64 32all${POSTGRES_PASSWORD:?…} — compose fails without it
FERROMA_JWT_SECRETopenssl rand -base64 48allsigns access/refresh tokens. Without it every session dies on restart
FERROMA_HOSTNAMEmail.example.comprod (:?)must equal the PTR record
FERROMA_PUBLIC_URLhttps://mail.example.comprod (:?)used in .well-known/ferroma and in links
FERROMA_VERSION0.1.0prod (:?)a released image tag; prod never builds

4.2 Commonly set

VariableDefaultMaps to
POSTGRES_USERferromathe database role
POSTGRES_DBferromathe database name
FERROMA_LOG_LEVELinfoserver.log_level
FERROMA_LOG_FORMATtext (dev) / json (prod)server.log_format
FERROMA_TLS_ENABLEDfalsetls.enabled
FERROMA_TLS_CERTtls.cert_path
FERROMA_TLS_KEYtls.key_path
FERROMA_DKIM_ENABLEDfalsedkim.enabled
FERROMA_DKIM_SELECTORdefaultdkim.selector
FERROMA_DKIM_KEYdkim.private_key_path
TRUST_PROXY_HEADERSfalseapi.trust_proxy_headers
BACKUP_RETENTION_DAYS14scripts/backup.sh retention
SMTP_PORT, SUBMISSION_PORT, IMAP_PORT, HTTP_PORT25, 587, 143, 8080dev only — the host side of the published ports
FERROMA_API_PORT8080 (18080 in the external-db stack)api.port — the host side of the plaintext HTTP API

4.3 The generic override form

Any setting can be overridden without editing ferroma.toml, using a double underscore as the path separator:

FERROMA__SMTP__PORT=2525
FERROMA__TLS__ENABLED=true
FERROMA__API__SECURE_COOKIES=true
FERROMA__SMTP__REQUIRE_TLS_FOR_AUTH=true
FERROMA__IMAP__REQUIRE_TLS_FOR_LOGIN=true
FERROMA__API__TRUST_PROXY_HEADERS=true
FERROMA__LIMITS__MAX_MESSAGE_SIZE=52428800

Precedence, from Config::load: embedded defaults < ferroma.toml < environment. Values are type-coerced against the default at that path, so 2525 becomes an integer and true becomes a boolean. Unknown keys are rejected — a typo stops the server at boot rather than silently leaving a limit disabled, which is the behaviour you want.

The shorthand aliases (config.rs, the ALIASES table):

DATABASE_URL                  FERROMA_HOSTNAME        FERROMA_DATA_DIR
FERROMA_LOG_LEVEL             FERROMA_LOG_FORMAT      FERROMA_SMTP_HOST
FERROMA_SMTP_PORT             FERROMA_SMTP_SUBMISSION_PORT
FERROMA_IMAP_PORT             FERROMA_API_HOST        FERROMA_API_PORT
FERROMA_API_PUBLIC_URL        FERROMA_JWT_SECRET      FERROMA_TLS_ENABLED
FERROMA_TLS_CERT              FERROMA_TLS_KEY         FERROMA_DKIM_ENABLED
FERROMA_DKIM_KEY              FERROMA_DKIM_SELECTOR

FERROMA_CONFIG points at the config file; the image sets it to /etc/ferroma/ferroma.toml.

4.4 Secrets

# Generate both, then put them in .env. Never commit .env.
openssl rand -base64 32      # POSTGRES_PASSWORD
openssl rand -base64 48      # FERROMA_JWT_SECRET

.env is gitignored. scripts/backup.sh explicitly excludes *.env and credentials* from the configuration archive, because the backup volume is usually less protected than the secret store. See security.md §13.


5. Ports and TLS

5.1 Port table

PortServiceConfig keyDevProdNotes
25SMTP inbound (MX)smtp.portpublishedpublishedmust be reachable from the internet
587Submission, STARTTLSsmtp.submission_portpublishedpublishedfor your users' mail clients
465SMTPS (implicit TLS)smtp.smtps_portnot publishedpublishedrequires tls.enabled and smtps_port = 465 (the default is 0, i.e. off)
143IMAP, STARTTLSimap.portpublishedpublished
993IMAPS (implicit TLS)imap.imaps_portnot publishedpublishedrequires tls.enabled and imaps_port = 993 (the default is 0, i.e. off)
8080HTTP API + Webmail + Adminapi.portpublishedloopback onlythe healthcheck runs against it; the reverse proxy terminates HTTPS on the public side
8443_(not implemented)_api.tls_portthe key exists in the configuration but there is no listener: HTTPS for the API / Webmail / Admin belongs to the reverse proxy, see §5.3
5432PostgreSQLexpose onlyexpose onlynever publish this

EXPOSE 25 587 465 143 993 8080 in the Dockerfile. Config::validate() refuses to start when two active SMTP ports collide, when smtp.port is 0, or when a TLS port is configured while tls.enabled = false.

5.2 Option A — Ferroma terminates SMTP/IMAP TLS

What docker-compose.prod.yml does: 465 and 993 are served by rustls directly (the network_mode: host of docker-compose.external-db.yml is the same), and the PEM bundle and key are mounted read-only. HTTPS is not part of it — see the status note at the top of this file. Both implicit-TLS listeners are off by default, so turn them on explicitly:

FERROMA__SMTP__SMTPS_PORT=465
FERROMA__IMAP__IMAPS_PORT=993
mkdir -p tls dkim
# Certificate and key, however you obtained them.
ls -l tls/fullchain.pem tls/privkey.pem
# In .env
FERROMA_TLS_ENABLED=true
FERROMA_TLS_CERT=/etc/ferroma/tls/fullchain.pem
FERROMA_TLS_KEY=/etc/ferroma/tls/privkey.pem

tls.cert_path is a bundle: the leaf certificate first, then intermediates. tls.key_path is PKCS#8 or PKCS#1. If you set one and not the other, Config::validate() refuses to boot:

tls.cert_path and tls.key_path must be set together (or enable self_signed_fallback)

tls.self_signed_fallback = true generates a certificate with rcgen at boot when no PEM is configured. It is for local development and CI only, and it is gated behind tls.allow_insecure_dev_mode = true — an MX with a self-signed certificate cannot be validated by any sending server, so all its outbound TLS fails.

5.3 Option B — a reverse proxy terminates HTTPS

Use this when something else already owns 443 and manages certificates, or when you want one place for HTTP security headers. SMTP and IMAP are not proxied; Ferroma still terminates those itself. docker-compose.external-db.yml from §3.1 is exactly this shape: it uses network_mode: host, so there are no port mappings at all and the API listens on 127.0.0.1:18080 directly (FERROMA_API_HOST / FERROMA_API_PORT).

# Add to docker-compose.prod.yml's ferroma service.
    ports:
      - '25:25'
      - '587:587'
      - '465:465'
      - '143:143'
      - '993:993'
      - '127.0.0.1:8080:8080'      # HTTP, bound to loopback: the proxy reaches it
    environment:
      FERROMA_TLS_ENABLED: 'true'  # still needed for 465 and 993
      FERROMA__SMTP__SMTPS_PORT: '465'
      FERROMA__IMAP__IMAPS_PORT: '993'
      FERROMA__API__TRUST_PROXY_HEADERS: 'true'
      FERROMA__API__SECURE_COOKIES: 'true'

api.trust_proxy_headers = true makes Ferroma believe X-Forwarded-For and X-Real-IP. Turn it on only when a proxy you control sets them: with it on and no proxy, a client can forge its own source IP and defeat the per-IP login throttle and connection limits. The proxy must overwrite the header, not append to it. Binding the API to a loopback address (FERROMA_API_HOST=127.0.0.1) is the other half of the same trust: nothing outside this machine can forge those headers directly.

5.4 nginx in front

server {
    listen 443 ssl http2;
    server_name mail.example.com mta-sts.example.com;

    ssl_certificate     /etc/letsencrypt/live/mail.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mail.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;

    add_header Strict-Transport-Security "max-age=31536000" always;
    add_header X-Content-Type-Options nosniff always;
    add_header X-Frame-Options DENY always;
    add_header Referrer-Policy no-referrer always;

    client_max_body_size 30m;      # >= api.max_request_size and limits.max_message_size

    # The MTA-STS policy is served by ferroma-api, so forward it (point the CNAME
    # for mta-sts.<domain> at this host, and cover it in the certificate too).
    location /.well-known/mta-sts.txt {
        proxy_pass http://127.0.0.1:18080;
        proxy_set_header Host $host;
    }

    location / {
        proxy_pass http://127.0.0.1:18080;   # the prod stack uses 8080; §3.1's stack defaults to 18080
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $remote_addr;   # overwrite, never append
        proxy_set_header X-Forwarded-Proto $scheme;

        # The realtime socket needs the upgrade dance and a long read timeout.
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;
    }
}

X-Forwarded-For $remote_addr rather than $proxy_add_x_forwarded_for is deliberate: appending lets a client prepend its own value, and Ferroma would read the first one.

When the proxy is itself a container, or the public port is not 443

The two usually arrive together: nginx runs in a container, still listening on 80 and 443 inside it, and the host publishes those as 180 and 1443 because the host's own 80 and 443 belong to something else. Three things differ from above.

  1. A container cannot reach 127.0.0.1. Each container has its own network
    namespace, so the host's loopback address is not visible to it. Bind the API to
    the host's address on the Docker bridge (usually 172.17.0.1; ask
    docker network inspect bridge, or let the script compute it) and point the
    proxy at that:
    `bash
    ./scripts/deploy.sh --api-host 172.17.0.1 --api-port 18080 --public-port 1443
    `
    `nginx
    # Inside the nginx container: keep listening on 80 / 443 — those are container ports.
    location / { proxy_pass http://172.17.0.1:18080; … }
    # Or give the nginx container --add-host=host.docker.internal:host-gateway
    # and proxy_pass http://host.docker.internal:18080;
    `
    On the host side, publish only nginx's ports: -p 180:80 -p 1443:443.
  2. FERROMA_PUBLIC_URL has to carry the port — that is what --public-port 1443
    is for. Without it, links in notification mail and the address handed to clients
    point at the host's 443, which is somebody else's service.
  3. The certificate can only come from DNS-01. HTTP-01 always connects to port
    80 and TLS-ALPN-01 to port 443, and neither belongs to nginx here. Issue through
    your DNS provider's API (a certbot DNS plugin, or acme.sh --dns) and install it
    as in §5.5; renew through the deploy hook there too.

Two things stop working by default, because both are defined to live on 443:

Both come back if you can forward ports upstream (public 443 → this machine's 1443): then FERROMA_PUBLIC_URL is the portless https://mail.example.com and you do not pass --public-port at all.

5.5 Let's Encrypt

The certificate must cover the names your users and peers connect to: at minimum mail.example.com (SMTP, IMAP and the API), plus mta-sts.example.com if you serve the policy from it.

# certbot, HTTP-01. Port 80 must be reachable.
sudo certbot certonly --standalone \
  -d mail.example.com -d mta-sts.example.com \
  --agree-tos -m [email protected] --no-eff-email

# Files land here; symlink or copy them into ./tls/.
sudo ls -l /etc/letsencrypt/live/mail.example.com/
#   fullchain.pem  -> tls/fullchain.pem
#   privkey.pem    -> tls/privkey.pem
# Copy into the mounted directory with the ownership the container expects
# (the image runs as uid 10001).
sudo install -o 10001 -g 10001 -m 0640 \
  /etc/letsencrypt/live/mail.example.com/fullchain.pem tls/fullchain.pem
sudo install -o 10001 -g 10001 -m 0600 \
  /etc/letsencrypt/live/mail.example.com/privkey.pem   tls/privkey.pem
docker compose -f docker-compose.prod.yml restart ferroma

Notes:

# A renewal rehearsal: certbot really does issue (against staging) and fires the
# deploy hook above.
sudo certbot renew --dry-run

6. First-run setup

6.1 Boot

docker compose -f docker-compose.prod.yml up -d
docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.prod.yml logs ferroma | tail -50

A healthy start logs the resolved configuration summary, an info line per listener, and the migration outcome when database.run_migrations = true.

6.2 The setup wizard

While no admin exists, GET /api/v1/setup returns { "required": true } (api.md §4.7). Open https://mail.example.com/ and the Webmail redirects to the Admin setup screen.

# Or drive it from the shell.
curl -s https://mail.example.com/api/v1/setup
curl -s -X POST https://mail.example.com/api/v1/setup \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"…","hostname":"mail.example.com","domain":"example.com"}'

POST /setup creates the first admin, the domain and its primary address, and returns a normal token pair. Both endpoints return 409 conflict afterwards. The wizard is disabled entirely by api.enable_setup_wizard = false — set that in ferroma.toml if you would rather create the first admin out of band, and remember that it means the endpoints 404 rather than fail.

6.3 Creating a domain without the wizard

curl -s -X POST https://mail.example.com/api/v1/domains \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"example.com","description":"primary"}'

Or directly in the database, if the API is not up yet:

-- Domains are lower-cased; the schema enforces it.
INSERT INTO domains (name, description) VALUES ('example.com', 'primary')
RETURNING id, name, enabled;

6.4 Creating users and addresses

# The password is hashed with Argon2id by the server; never insert a hash by hand.
curl -s -X POST https://mail.example.com/api/v1/users \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"…","display_name":"Alice","quota_bytes":1073741824}'

# Attach an address to that user. This creates the Maildir and the standard folders.
curl -s -X POST https://mail.example.com/api/v1/users/7/mailboxes \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"domain":"example.com","local_part":"alice","is_primary":true}'

The Maildir and folder rows are created by Maildir::ensure_mailbox and FoldersRepository::ensure_standard, which produce INBOX, Sent, Drafts, Trash, Junk, Archive with their special_use markers (imap.md §4.3).

# Confirm on disk, inside the container.
docker compose -f docker-compose.prod.yml exec ferroma \
  ls -la /var/lib/ferroma/mail/example.com/alice/Maildir

6.5 A first end-to-end check

# Local delivery to a local address, by hand. Use a real From for a real test.
printf 'EHLO test\r\nMAIL FROM:<[email protected]>\r\nRCPT TO:<[email protected]>\r\nDATA\r\nSubject: hello\r\n\r\nfirst\r\n.\r\nQUIT\r\n' \
  | nc 127.0.0.1 25

# Did it land?
docker compose -f docker-compose.prod.yml exec postgres \
  psql -U ferroma -d ferroma -c \
  "SELECT id, uid, subject, sender, size_bytes, storage_path FROM messages ORDER BY id DESC LIMIT 5;"

7. DKIM: generating and publishing a key

DKIM signing is what keeps your outbound mail out of spam folders and makes DMARC alignment possible.

7.1 Generate a key pair

# Preferred: the CLI generates a 2048-bit key and writes it into the domain record
# (or --out to a file).
docker compose exec ferroma ferroma dkim generate --domain example.com

# Print the TXT record to publish:
docker compose exec ferroma ferroma dkim show --domain example.com
# Or with OpenSSL. 2048-bit RSA is the size receivers
# expect; 1024 is too weak and 4096 is slow to verify.
openssl genrsa -out dkim/default.private 2048
openssl rsa -in dkim/default.private -pubout -out dkim/default.public

# The TXT record value, on one line:
printf 'v=DKIM1; k=rsa; p=%s\n' \
  "$(openssl rsa -in dkim/default.private -pubout 2>/dev/null \
     | grep -v '^-----' | tr -d '\n')"

# The private key must be readable only by the service user (uid 10001).
sudo chown 10001:10001 dkim/default.private
chmod 0600 dkim/default.private

The docker-compose.external-db.yml of §3.1 spares you the manual steps above: ./scripts/deploy.sh generates the key inside the ferroma-data volume at /var/lib/ferroma/dkim/<selector>.private (already owned by uid 10001) and prints the TXT record to publish; ./scripts/deploy.sh dkim --enable turns signing on once the record is out.

The p= value is base64 and may be long. Most DNS providers accept a TXT record of 255 characters and some require you to split longer values into quoted chunks; dig reassembles them. If your provider rejects the whole string, split it:

default._domainkey IN TXT (
    "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA"
    "…the rest of the base64…"
)

7.2 Publish it

Add the record from §2.2, wait for the TTL, and verify:

dig +short TXT default._domainkey.example.com
# expected (illustrative): "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A..."

7.3 Enable signing

# config/ferroma.toml
[dkim]
enabled = true
selector = "default"
private_key_path = "/etc/ferroma/dkim/default.private"
canonicalization = "relaxed"
verify_inbound = true
add_auth_results = true
# Or through the environment, which is how docker-compose.prod.yml does it.
FERROMA_DKIM_ENABLED=true
FERROMA_DKIM_SELECTOR=default
FERROMA_DKIM_KEY=/etc/ferroma/dkim/default.private
docker compose -f docker-compose.prod.yml restart ferroma

Config::validate() refuses to boot when dkim.enabled = true and dkim.private_key_path is unset, when dkim.selector is empty, or when dkim.canonicalization is neither relaxed nor simple.

The signing domain is per-domain by default and can be pinned with dkim.domain. The private key can also live in domains.dkim_private_key (DomainsRepository::set_dkim) — pick one place and stay there, and back up whichever you chose.

7.4 Verify a signature end to end

# Send a message to a checker that reports the DKIM result.
swaks --server mail.example.com --port 587 --tls \
      --auth PLAIN --auth-user [email protected] --auth-password '…' \
      --from [email protected] --to [email protected] \
      --body "dkim test"

# Or read the Authentication-Results header in a message you sent to yourself.

Because policy.add_auth_results = true, a message delivered to one of your own mailboxes carries the verdict, which is the quickest way to confirm the signer is running:

docker compose -f docker-compose.prod.yml exec postgres \
  psql -U ferroma -d ferroma -Atc \
  "SELECT storage_path FROM messages ORDER BY id DESC LIMIT 1"

7.5 Rotating a DKIM key

  1. Generate a new key with a new selector (default2).
  2. Publish default2._domainkey and wait for the TTL to pass everywhere.
  3. Switch dkim.selector = "default2" and restart.
  4. Keep the old record published for at least the DMARC report window — a month
    is comfortable. Mail signed with the old key is still in flight and still being
    verified.
  5. Only then remove the old record and the old key file.

8. Backup and restore

8.1 What the backup contains

scripts/backup.sh writes one timestamped directory per run:

/backups/20260916T030000Z/
├── ferroma.dump      pg_dump --format=custom --compress=6
├── schema.sql        pg_dump --schema-only: an empty but correct structure
├── maildir.tar.gz    tar -czf of the mail root
├── config.tar.gz     ferroma.toml, DKIM keys, TLS material
├── MANIFEST          version, created_at, database, postgres_version,
│                     ferroma_version, hostname
└── SHA256SUMS        sha256sum ./*, so a restore can prove the archive survived

The script's own header states the rule that governs everything else:

A backup that contains only one of the first two is not a backup: the database says a message exists and the Maildir holds its bytes, and restoring either alone gives you a mailbox full of dangling rows or a directory of orphaned files.

Restored aloneWhat the user sees
Database onlyan inbox of subjects with no bodies — every read is StorageError::BodyMissing
Maildir onlyempty folders; the bytes are on disk and nothing knows about them

8.2 Running it

The production stack runs a nightly sidecar:

backup:
  image: postgres:16-alpine
  environment:
    PGHOST: postgres
    PGUSER: ${POSTGRES_USER:-ferroma}
    PGPASSWORD: ${POSTGRES_PASSWORD}
    PGDATABASE: ${POSTGRES_DB:-ferroma}
    BACKUP_DIR: /backups
    RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-14}
    BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
  entrypoint: ['/bin/sh', '/usr/local/bin/backup.sh']
  command: ['--loop']
  restart: unless-stopped
  volumes:
    - ./scripts/backup.sh:/usr/local/bin/backup.sh:ro
    - ferroma-data:/mail:ro
    - backups:/backups

command: ['--loop'] is not decoration: a one-shot script under restart: always writes a full backup again on every restart, backing off to one a minute, until the disk fills.

By hand:

# One pass, then exit (--once is the explicit "run once").
docker compose -f docker-compose.prod.yml run --rm backup --once

# A loop in the foreground: every 24 h, retention 14 days.
docker compose -f docker-compose.prod.yml run --rm \
  -e BACKUP_INTERVAL_SECONDS=86400 -e RETENTION_DAYS=14 \
  backup --loop

# Off-host, which is what makes a backup a backup. Run it from the host:
docker run --rm -v ferroma-backups:/backups -v "$PWD:/out" alpine \
  tar -czf /out/ferroma-backups-$(date -u +%Y%m%d).tar.gz -C /backups .

With the §3.1 stack it is shorter: ./scripts/deploy.sh backup.

The sidecar mounts the mail root read-only (ferroma-data:/mail:ro) and never writes to it. Get the archives off the machine: a backup on the same disk as the mail store is not a backup, and neither is one in the same cloud account without versioning.

8.3 Consistency while live

For a perfectly consistent pair, stop the service for the duration:

docker compose -f docker-compose.prod.yml stop ferroma
docker compose -f docker-compose.prod.yml run --rm backup --once
docker compose -f docker-compose.prod.yml start ferroma

That is the only way to guarantee no transaction is split across the two halves, and it takes seconds on a small store.

8.4 Restore

scripts/restore.sh follows specification §47: database, then mail store, then configuration. The database goes first because it defines what should exist; the Maildir second so every row has its file before the server starts; configuration last so a half-finished restore does not leave a running server pointed at the wrong certificate.

A restore writes to the mail store, and the nightly sidecar mounts that read-only (deliberately), so a restore runs as a separate one-off container: the restore service with profiles: ['tools'] in docker-compose.external-db.yml (the prod stack can do the same, with an entrypoint override).

# Verify only: check the checksums and print the manifest, restore nothing. The
# service can keep running.
./scripts/deploy.sh restore /backups/20260916T030000Z --verify-only

# A full restore: the script stops Ferroma first, restores, then brings it back up
# and waits for the health check.
./scripts/deploy.sh restore /backups/20260916T030000Z

# The raw equivalent (external-db stack).
docker compose -f docker-compose.external-db.yml stop ferroma
docker compose -f docker-compose.external-db.yml --profile tools run --rm \
  restore /backups/20260916T030000Z
docker compose -f docker-compose.external-db.yml up -d ferroma

The script refuses to restore over a populated database:

database ferroma is not empty (19 tables). Set FORCE_RESTORE=1 to overwrite,
or restore into a fresh database.

That guard is the most valuable line in the file. Merging two mail stores silently is how an operator loses a week of mail, and no tool can tell "restore on top" from "wrong database". For a genuine overwrite:

# external-db stack: adding -e FORCE_RESTORE=1 is enough.
docker compose -f docker-compose.external-db.yml stop ferroma
docker compose -f docker-compose.external-db.yml --profile tools run --rm \
  -e FORCE_RESTORE=1 restore /backups/20260916T030000Z
docker compose -f docker-compose.external-db.yml up -d ferroma

Partial modes exist for disaster recovery and both print a warning:

./scripts/deploy.sh restore /backups/20260916T030000Z --db-only
./scripts/deploy.sh restore /backups/20260916T030000Z --mail-only

8.5 After a restore

# 1. The checks first: rows without bodies, files without rows, counters, uid_next.
#    See docs/storage.md §9 for the queries and the shell loops.
docker compose -f docker-compose.prod.yml exec ferroma sh -c '
  psql "$DATABASE_URL" -Atc "SELECT COUNT(*) FROM messages WHERE expunged_at IS NULL"'

# 2. Reconcile the counters that are allowed to drift.
#    FoldersRepository::recount and MailboxesRepository::recompute_usage, triggered
#    through POST /api/v1/storage/gc (an admin token is required) and the Admin
#    storage screen.
#    curl -s -X POST https://mail.example.com/api/v1/storage/gc \
#      -H "Authorization: Bearer $TOKEN"

# 3. Start Ferroma and watch the log for the first minute.
docker compose -f docker-compose.prod.yml up -d ferroma
docker compose -f docker-compose.prod.yml logs -f --tail=100 ferroma

The full integrity procedure — the orphan query, the checksum loop, the counter comparison, the uid_next/uid_validity rule — is in storage.md §9. Run it after every restore; a restore is the one operation guaranteed to produce inconsistencies if anything went wrong.

8.6 A restore drill

Test the restore, not the backup. A backup that has never been restored is a hypothesis (specification §46: "必须实际测试恢复" — recovery must actually be tested).

# Restore into a scratch database on the same host, without touching production.
docker compose -f docker-compose.prod.yml exec postgres createdb -U ferroma ferroma_drill
docker compose -f docker-compose.prod.yml exec postgres \
  pg_restore --no-owner --no-privileges --dbname=ferroma_drill /backups/…/ferroma.dump
docker compose -f docker-compose.prod.yml exec postgres \
  psql -U ferroma -d ferroma_drill -c 'SELECT COUNT(*) FROM messages;'
docker compose -f docker-compose.prod.yml exec postgres dropdb -U ferroma ferroma_drill

Do this quarterly, and after every schema migration.


9. Upgrades and rollback

9.1 Upgrade

# 1. Back up first. Always. An upgrade is the second-most-likely time to need it.
docker compose -f docker-compose.prod.yml run --rm backup --once
# On the external-db stack one step does all of it: ./scripts/deploy.sh upgrade
# (back up → rebuild → restart → wait for health)

# 2. Read the release notes for migration and configuration changes.
#    A new required key, or a removed one, stops the new version at boot
#    (unknown keys are rejected).

# 3. Update the image tag. Prod never builds from source.
sed -i 's/^FERROMA_VERSION=.*/FERROMA_VERSION=0.2.0/' .env

# 4. Pull and recreate only the ferroma service.
docker compose -f docker-compose.prod.yml pull ferroma
docker compose -f docker-compose.prod.yml up -d ferroma

# 5. Watch it come up.
docker compose -f docker-compose.prod.yml logs -f --tail=100 ferroma

Migrations run at startup when database.run_migrations = true. They are forward-only: migrations/ is an ordered list (currently one file, 0001_initial.sql) applied in order, and there is no down migration. That is why step 1 is step 1.

9.2 Rollback

# Roll the image back.
sed -i 's/^FERROMA_VERSION=.*/FERROMA_VERSION=0.1.0/' .env
docker compose -f docker-compose.prod.yml pull ferroma
docker compose -f docker-compose.prod.yml up -d ferroma

An image rollback works only if the schema is compatible. If the new version applied a migration that the old version cannot read, rolling back the binary is not enough and you must restore the database from the pre-upgrade backup:

docker compose -f docker-compose.external-db.yml stop ferroma
docker compose -f docker-compose.external-db.yml --profile tools run --rm \
  -e FORCE_RESTORE=1 restore /backups/<pre-upgrade-stamp>
docker compose -f docker-compose.external-db.yml up -d ferroma

Rolling back the mail store is unnecessary when only the database changed, and restoring the mail store from a pre-upgrade backup would lose every message received since — which is why --db-only exists and why it prints a warning.

9.3 Zero-downtime is not supported

One process, one event bus (security.md §15.4). Running two replicas behind a load balancer gives each user a realtime experience that depends on which replica they hit. Scale the database and the storage before you consider a second Ferroma process; both are likelier bottlenecks.

A restart costs the duration of server.shutdown_timeout_secs (30 s) plus the boot: listeners stop accepting, in-flight SMTP transactions and queue deliveries finish, then the process exits. stop_grace_period: 60s in docker-compose.prod.yml gives it room. Inbound mail during the gap is retried by the sending MTA, because SMTP is store-and-forward by design.


10. Monitoring and health checks

10.1 The health endpoint

GET /api/v1/health — no authentication, drives the container healthcheck (api.md §2):

{
  "status": "ok",
  "version": "0.1.0",
  "protocol_version": 1,
  "uptime_secs": 84213,
  "database": { "ok": true, "server_version": "PostgreSQL 16.15",
                "pool": { "size": 4, "idle": 3, "max": 20 } },
  "smtp": { "enabled": true, "connections": 3 },
  "imap": { "enabled": true, "connections": 1 },
  "queue": { "pending": 0, "delivering": 0, "retry": 2, "failed": 1 }
}

503 with "status": "degraded" when the database is unreachable.

docker compose -f docker-compose.prod.yml exec ferroma \
  ferroma healthcheck --url http://127.0.0.1:8080/api/v1/health

# Or without the CLI.
docker compose -f docker-compose.prod.yml exec ferroma \
  sh -c 'wget -qO- http://127.0.0.1:8080/api/v1/health || echo unreachable'

The Docker healthcheck in the compose files and the Dockerfile runs ferroma healthcheck --url http://127.0.0.1:8080/api/v1/health every 30 s with a 20–30 s start period and 3 retries. The address in docker-compose.external-db.yml follows FERROMA_API_HOST and FERROMA_API_PORT (default 127.0.0.1:18080) — the probe follows wherever the API is bound. When a containerised proxy forces the API onto the Docker bridge address (the end of §5.4), the probe moves with it instead of reporting a healthy server as unhealthy.

10.2 What to watch

SignalWhereHealthyAct when
Container healthdocker compose pshealthyunhealthy twice in a row
Restart countdocker inspect -f '{{.RestartCount}}' ferromastableit climbs
mail_queue.status = 'failed'GET /api/v1/queue/statsnear zeroany sustained non-zero
mail_queue.status = 'retry'samesmallit grows for hours
Disk freedf -h on the host> 20 %< 15 %: mail stops being accepted
mail_queue_due_idx backlogGET /api/v1/queue/statsnext_due_atin the past or nowit lags more than a minute behind
IMAP/SMTP connections/api/v1/healthwell under limits.max_connectionspinned at the cap
Failed loginslogin_attemptsa tricklea burst, or one email/IP repeatedly
Certificate expiryopenssl s_client, certbot certificates> 21 days< 21 days: renew before it lapses
Backup freshnessthe newest directory in /backups< 26 h oldolder than 48 h
# The queue, grouped.
docker compose -f docker-compose.prod.yml exec postgres \
  psql -U ferroma -d ferroma -c \
  "SELECT status, COUNT(*) FROM mail_queue GROUP BY status ORDER BY 2 DESC;"

# The oldest thing waiting to be retried.
docker compose -f docker-compose.prod.yml exec postgres \
  psql -U ferroma -d ferroma -c \
  "SELECT id, recipient, attempts, next_attempt_at, last_status_code, left(last_error,60)
     FROM mail_queue WHERE status IN ('pending','retry')
    ORDER BY next_attempt_at LIMIT 20;"

# Storage.
docker compose -f docker-compose.prod.yml exec postgres \
  psql -U ferroma -d ferroma -c \
  "SELECT pg_size_pretty(pg_database_size('ferroma')) AS db,
          (SELECT COUNT(*) FROM messages WHERE expunged_at IS NULL) AS live_messages,
          (SELECT COUNT(*) FROM users) AS users,
          (SELECT COUNT(*) FROM domains) AS domains;"
du -sh "$(docker volume inspect -f '{{.Mountpoint}}' ferroma-data)"

# Failed logins in the last day.
docker compose -f docker-compose.prod.yml exec postgres \
  psql -U ferroma -d ferroma -c \
  "SELECT email, ip, COUNT(*) FROM login_attempts
    WHERE NOT success AND created_at > NOW() - INTERVAL '1 day'
    GROUP BY email, ip ORDER BY 3 DESC LIMIT 20;"

10.3 Logs

server.log_format = "json" in production (FERROMA_LOG_FORMAT=json), which is what Loki/ELK want. Every SMTP session carries connection_id, remote_ip, helo, authenticated_user, sender, recipient, message_id, result, duration (security.md §12.2), and connection_id also appears in the Received: header Ferroma prepends — so a log line and a message header can be joined.

# Just the errors.
docker compose -f docker-compose.prod.yml logs ferroma | grep -i '"level":"ERROR"'

# Everything about one message id.
docker compose -f docker-compose.prod.yml logs ferroma | grep '4821'

# A delivery that failed.
docker compose -f docker-compose.prod.yml logs ferroma | grep -i 'delivery'

# Fresh log lines as they happen, filtered.
docker compose -f docker-compose.prod.yml logs -f ferroma | grep -E 'WARN|ERROR'

Log rotation is bounded in the compose files (max-size: 20m, max-file: 10 in prod), so a log flood cannot fill the disk. Do not raise database.log_statements in production: it prints message subjects.

10.4 Metrics and alerting are (planned)

Specification §40 lists Prometheus metrics (smtp_connections_total, smtp_messages_received_total, queue_pending_messages, mail_storage_bytes, sync_operations_total, …) and §41 places Prometheus and Grafana in a later version. Neither exists yet: there is no /metrics endpoint. Until there is, monitor with the health endpoint and the psql queries above, and alert on:


11. Troubleshooting, by symptom

11.1 "Mail is rejected as spam" / lands in the recipient's Junk

Almost always DNS, not Ferroma.

# 1. Does the PTR match FERROMA_HOSTNAME, and does it point back?
dig +short -x 203.0.113.10            # must equal FERROMA_HOSTNAME
dig +short mail.example.com           # must be the same address
grep FERROMA_HOSTNAME .env

# 2. Is SPF present, single, and terminating in -all?
dig +short TXT example.com | grep spf1

# 3. Is the DKIM record published and does it match the key you sign with?
dig +short TXT default._domainkey.example.com
openssl rsa -in dkim/default.private -pubout 2>/dev/null | grep -v '^-----' | tr -d '\n'

# 4. Is DMARC present?
dig +short TXT _dmarc.example.com

# 5. Is the IP on a blocklist?
dig +short 10.113.0.203.zen.spamhaus.org

# 6. Is the queue reporting failures with a remote status code?
docker compose -f docker-compose.prod.yml exec postgres \
  psql -U ferroma -d ferroma -c \
  "SELECT recipient, last_status_code, last_status_text FROM mail_queue
    WHERE status = 'failed' ORDER BY updated_at DESC LIMIT 10;"

The usual causes, in order of frequency: no PTR or a mismatched one; a missing or duplicated SPF record; more than ten SPF DNS lookups; a DKIM record published but dkim.enabled = false so nothing is signed; a DMARC p=reject with no alignment; a brand-new IP with no sending history (which only time and volume fix); and a shared IP whose reputation you inherited.

11.2 "Cannot receive mail from Gmail" (or another large provider)

Large providers require TLS and behave strictly.

# 1. Is a connection from the internet reaching port 25 at all?
#    From a machine outside your network:
nc -vz mail.example.com 25

# 2. Does the MX record resolve, and is it the host you think?
dig +short MX example.com
dig +short A mail.example.com

# 3. Is Ferroma listening on 25 inside the container?
docker compose -f docker-compose.prod.yml exec ferroma \
  sh -c 'netstat -tlnp 2>/dev/null || ss -tlnp'

# 4. Did the connection even arrive? If there is no log line, it never got here.
docker compose -f docker-compose.prod.yml logs ferroma | grep -i 'connection\|reject\|550\|554'

# 5. Is the certificate valid from outside?
openssl s_client -starttls smtp -connect mail.example.com:25 -crlf < /dev/null 2>&1 | head -30

# 6. Does the greeting name match the PTR?
printf 'EHLO test\r\nQUIT\r\n' | nc mail.example.com 25

Common causes: the host firewall or the provider blocking port 25 (very common on new VPSs — ask them to open it); a NAT/port-forward that maps 25 to the wrong host; an AAAA record that advertises IPv6 the host cannot serve, which makes dual-stack senders time out; and a certificate that expired, which makes senders that require TLS (MTA-STS, or a provider policy) defer the mail with a 4xx.

11.3 "Mail is accepted but never arrives" / "the queue is growing"

# 1. Is the queue actually growing, and is the first attempt recent?
docker compose -f docker-compose.prod.yml exec postgres \
  psql -U ferroma -d ferroma -c \
  "SELECT status, COUNT(*), MIN(next_attempt_at), MAX(created_at)
     FROM mail_queue GROUP BY status;"

# 2. What are the top failures saying?
docker compose -f docker-compose.prod.yml exec postgres \
  psql -U ferroma -d ferroma -c \
  "SELECT recipient, attempts, last_status_code, last_error
     FROM mail_queue WHERE status IN ('retry','failed')
    ORDER BY attempts DESC LIMIT 20;"

# 3. The attempt history of one stuck delivery.
docker compose -f docker-compose.prod.yml exec postgres \
  psql -U ferroma -d ferroma -c \
  "SELECT a.attempt, a.remote_mx, a.status_code, a.status_text, a.duration_ms, a.created_at
     FROM delivery_attempts a JOIN mail_queue q ON q.id = a.queue_id
    WHERE q.id = 1234 ORDER BY a.attempt;"

# 4. Can this host reach the remote MX on port 25 at all?
docker compose -f docker-compose.prod.yml exec ferroma \
  sh -c 'nc -vz gmail-smtp-in.l.google.com 25'

# 5. Is outbound 25 blocked by the provider? Test from the host.
nc -vz alt1.gmail-smtp-in.l.google.com 25

# 6. Is the dispatcher running at all?
docker compose -f docker-compose.prod.yml logs ferroma | grep -i 'queue\|dispatch'
grep -A6 '^\[queue\]' config/ferroma.toml

Read the diagnostics in the reply codes:

SymptomMeaningAction
421 4.7.0 Try again later from a big providerrate-limited or IP reputationslow down, request delisting, check for a compromised account
450/451 repeatedlythe remote is greylistingnormal; the retry schedule handles it
550 5.7.1 from the remotetheir policy rejects youSPF/DKIM/DMARC/PTR, or a blocklist
550 5.1.1the recipient does not existthe message will bounce; correct the address
Nothing in delivery_attemptsthe worker never claimed the rowcheck queue.enabled, queue.workers, and mail_queue_due_idx
Attempts climbing to max_attempts (12)a persistent failureread last_status_text
Everything stuck at pending with next_attempt_at in the pastdispatcher not runningcheck the log for panics; restart

A burst of outbound mail that is not the user's suggests a compromised account: check login_attempts, sessions (look at ip), and mail_queue.user_id for one account dominating.

-- Who is sending the most?
SELECT user_id, COUNT(*) FROM mail_queue
 WHERE created_at > NOW() - INTERVAL '1 hour'
 GROUP BY user_id ORDER BY 2 DESC LIMIT 10;

Then revoke the account's sessions (POST /api/v1/client/devices/:id/revoke) and change the password.

11.4 "IMAP login fails"

# 1. Is IMAP listening?
docker compose -f docker-compose.prod.yml exec ferroma \
  sh -c 'netstat -tlnp 2>/dev/null | grep -E "143|993"'

# 2. What does the greeting and capability list say?
openssl s_client -crlf -connect mail.example.com:143 < /dev/null 2>&1 | head -20
# for implicit TLS:
openssl s_client -connect mail.example.com:993 < /dev/null 2>&1 | head -20

# 3. Try a real login.
printf 'a LOGIN [email protected] "…"\r\nb LOGOUT\r\n' | \
  openssl s_client -quiet -crlf -connect mail.example.com:143

# 4. Is TLS required, and is your client doing it?
grep -E 'require_tls_for_login|imaps_port' config/ferroma.toml
grep FERROMA__IMAP__REQUIRE_TLS_FOR_LOGIN .env

# 5. Is the account locked by the login throttle?
docker compose -f docker-compose.prod.yml exec postgres \
  psql -U ferroma -d ferroma -c \
  "SELECT id, email, enabled, failed_logins, locked_until FROM users WHERE email='[email protected]';"

# 6. What do the recent attempts say?
docker compose -f docker-compose.prod.yml exec postgres \
  psql -U ferroma -d ferroma -c \
  "SELECT email, ip, success, created_at FROM login_attempts
    ORDER BY created_at DESC LIMIT 20;"

# 7. The server's view of the failure.
docker compose -f docker-compose.prod.yml logs ferroma | grep -i 'imap\|login'
ResponseCauseFix
NO [PRIVACYREQUIRED]imap.require_tls_for_login = true and the client is on cleartextconfigure STARTTLS (port 143) or implicit TLS (993) in the client
NO [AUTHENTICATIONFAILED]wrong password, or the address is not the login namethe login is the full address, lower-cased: [email protected]
Connection refusedthe listener is down, or the port is not publishedgrep -A4 '^\[imap\]' config/ferroma.toml; check docker compose ps ports
TLS handshake errorexpired or mismatched certificateopenssl s_client -connect mail.example.com:993 -servername mail.example.com
* BYE Autologout immediatelyimap.idle_timeout_secs too low, or a clock problemraise it; check the host clock
Works locally, not remotelya firewall, or the client configured with the wrong hostnc -vz mail.example.com 993 from outside

A user who cannot send but can receive has a submission problem, not an IMAP one: check port 587, smtp.require_auth_on_submission, and that the address they are sending as is one they own (security.md §6.3).

11.5 "The API is down" / Webmail will not load

# 1. Health, from inside the container.
docker compose -f docker-compose.prod.yml exec ferroma \
  sh -c 'wget -qO- http://127.0.0.1:8080/api/v1/health || echo unreachable'

# 2. Is the container healthy?
docker compose -f docker-compose.prod.yml ps
docker inspect -f '{{.State.Health.Status}} restarts={{.RestartCount}}' ferroma

# 3. Why did it exit?
docker compose -f docker-compose.prod.yml logs --tail=200 ferroma | grep -iE 'error|panic|config'

# 4. A configuration typo stops the server at boot. That is by design.
docker compose -f docker-compose.prod.yml run --rm ferroma \
  ferroma serve --config /etc/ferroma/ferroma.toml

500 storage_error from every endpoint means the database is unreachable: check docker compose ps postgres, the DATABASE_URL, and the pool size against database.max_connections. A 503 from /health with "status": "degraded" is the same condition reported gracefully.

11.6 "Quota says full but the mailbox looks empty"

# What the database believes.
docker compose -f docker-compose.prod.yml exec postgres \
  psql -U ferroma -d ferroma -c \
  "SELECT m.id, d.name||'@'||m.local_part AS addr, u.used_bytes, u.quota_bytes
     FROM mailboxes m JOIN domains d ON d.id=m.domain_id JOIN users u ON u.id=m.user_id
    ORDER BY u.used_bytes DESC LIMIT 10;"

# What is actually on disk.
docker compose -f docker-compose.prod.yml exec ferroma \
  du -sb /var/lib/ferroma/mail/example.com/alice

# The files that make up the total.
docker compose -f docker-compose.prod.yml exec ferroma \
  sh -c 'find /var/lib/ferroma/mail/example.com/alice -type f -printf "%s %p\n" | sort -rn | head -20'

used_bytes is a cache and MailboxesRepository::recompute_usage corrects it (storage.md §5). A large discrepancy in either direction is worth investigating rather than just recomputing: files deleted behind Ferroma's back mean someone else is writing to the mail root.

11.7 "Disk is filling up"

# Where.
du -sh /var/lib/docker/volumes/ferroma-data/_data/*
du -sh /var/lib/docker/volumes/ferroma-data/_data/mail/*

# Garbage from interrupted writes.
docker compose -f docker-compose.prod.yml exec ferroma \
  sh -c 'find /var/lib/ferroma/mail -type d -name tmp -exec du -sh {} +'

# Unreferenced attachment blobs.
docker compose -f docker-compose.prod.yml exec postgres \
  psql -U ferroma -d ferroma -Atc 'SELECT DISTINCT storage_path FROM attachments' | wc -l
docker compose -f docker-compose.prod.yml exec ferroma \
  sh -c 'find /var/lib/ferroma/attachments -type f ! -name "*.tmp" | wc -l'

# Expunged messages still holding their files.
docker compose -f docker-compose.prod.yml exec postgres \
  psql -U ferroma -d ferroma -c \
  "SELECT COUNT(*), pg_size_pretty(SUM(size_bytes)) FROM messages WHERE expunged_at IS NOT NULL;"

Levers, in order: run the sweeps (Maildir::sweep_tmp, AttachmentStore::gc); prune delivery_attempts and login_attempts by age; lower queue.retention_days; and only then look at quotas. The blob/file count mismatch is expected — identical attachments share a blob — so compare sizes, not counts.


TopicDocument
Endpoints used in every command aboveapi.md
SMTP reply codes, retry schedule, bounce formatsmtp.md
IMAP capability list, folder names, client compatibilityimap.md
Schema, Maildir layout, quota, GC, integrity procedurestorage.md
Threat model, relay defence, TLS decision, known gapssecurity.md
Sync model and the client's failure matrixsync.md
The official desktop clientclient.md
Crate graph and request lifecyclearchitecture.md
Build quirks on this machine (proxy, CARGO_HOME, PostgreSQL)../AGENTS.md
Ferroma · MIT OR Apache-2.0 · built from docs/