Ferroma v0.1.7
Operations · Documentation

Deployment guide

Three supported shapes. Pick the one that matches the host you have, run the commands, then walk the setup wizard. Every command below corresponds to a real artefact in the repository: Dockerfile, the three docker-compose*.yml files, .env.example and scripts/deploy.sh.

1. Pick a shape

All three are supported. The only question is what the host already has. Pick one and stay with it.

ShapeThe hostDatabaseTLSFiles
Q · the fastest path start herePostgreSQL already runs on this hostthe one you have (Ferroma never creates it)all plaintext, this host onlyyour own compose file, or one docker run
A · Compose, its own databaseanything with Dockerpostgres:16-alpine containerFerroma terminates 465/993; a reverse proxy terminates HTTPSdocker-compose.prod.yml
B · Compose, existing PostgreSQL recommendedalready runs PostgreSQL and a reverse proxyyour own server, over 127.0.0.1your proxy terminates HTTPS; Ferroma terminates 465/993docker-compose.external-db.yml + scripts/deploy.sh
C · Plain Dockeranything with Dockera container you run yourselfyour own arrangementdocker run
!

docker-compose.yml (no suffix) is the development stack: plaintext IMAP and API, no resource limits, port 143 bound unencrypted. Do not put it on the internet.

2. The fastest path: one container, the database in the browser

No .env, no build, no pre-configured connection string: one released image, one data volume, and the remaining two steps happen in a browser. It assumes a PostgreSQL already runs on this host (1Panel's, or the distribution's) — Ferroma only connects and applies its schema, it never runs CREATE DATABASE.

With Docker Compose

services:
  ferroma:
    image: wesukilaye/ferroma:0.1.7
    container_name: ferroma
    restart: always
    # Binds 25/587/143/8080 on the host directly, so a PostgreSQL running
    # on this host is simply 127.0.0.1. Linux only.
    network_mode: host
    volumes:
      - ./data:/var/lib/ferroma
      - ./tls:/etc/ferroma/tls:ro
docker compose up -d
docker compose logs ferroma        # read the setup code it prints

# then open http://<host>:8080/ and enter the database address and that code

Or with plain docker run

mkdir -p data tls
sudo chown -R 10001:10001 data     # the container runs as uid 10001

docker run -d --name ferroma --restart always \
  --network host \
  -v "$PWD/data:/var/lib/ferroma" \
  -v "$PWD/tls:/etc/ferroma/tls:ro" \
  wesukilaye/ferroma:0.1.7

docker logs ferroma                # read the setup code it prints

Both start the same container, and its log prints this:

No database is connected yet. Open http://0.0.0.0:8080/ and enter:

    address   postgres://user:password@host:5432/ferroma
    code      7JVTQAHO
i

One address, two steps. An instance with no stated database does not exit — it binds the web port anyway and asks for the connection and the code at /. Once the connection is verified and the schema applied, the same process continues booting on the same port: no restart, and the page turns straight into the wizard that creates the first mail domain and administrator.

Four things worth knowing:

The setup code is requiredgenerated every start, printed only to the log. Without it, anyone who can reach the published web port could point this instance at a database of their choosing
The database must already existthe role you name only needs the rights to use it; creating it is the operator's job, not Ferroma's
The address is rememberedwritten to <data_dir>/database.json (mode 0600 — it holds a password) and read from disk on every later start
There is no restartthe connection takes effect inside the same process. To have the deployment state it instead, set DATABASE_URL in .env — stated, it wins, and a wrong one is a loud failure at startup

After the database is connected, / is still the console until the first administrator exists — only then does it become the Webmail sign-in.

!

This path is plaintext, and meant for a local trial. With network_mode: host the container binds 25/587/143/8080 straight onto the host — 143 unencrypted, no TLS, no resource limits, nothing beyond a restart policy. Click through the console, send yourself a first message, then take path A or B below to put it on the internet.

3. Before the first boot

RequirementWhy it matters
A static public IPv4 addressan MX needs a stable address, and the PTR record must match it. If your provider will not set a PTR, read the next section
Port 25 reachable inboundreceiving mail from other servers. Many VPS providers block it by default — ask them to open it first
Port 25 reachable outbounddelivering mail
A domain you controlexample.com in every example below
Docker Engine 24+ with the Compose plugindocker compose, not docker-compose
1 vCPU, 1 GB RAM, 20 GB diska small deployment. Ferroma itself stays in the tens of megabytes — the memory belongs to PostgreSQL and the page cache — and the mail store grows
!

DNS first.Until MX / A / PTR / SPF / DKIM / DMARC are in place, a perfectly configured Ferroma will have its outbound mail rejected and will receive nothing. The full record table is in Deployment reference §2

4. No PTR from your provider: relay the outbound path

Reverse DNS is set by whoever owns the IP block — usually your VPS provider's control panel. Plenty of providers do not offer the setting at all, or want a support ticket first. And an IP with no PTR, or a PTR that disagrees with the name in the EHLO, is the single most common reason legitimate mail is junked or refused: Gmail files it as spam, Microsoft's properties refuse it outright.

The way out is an outbound relay (a smarthost). Hand delivery to something that already has its PTR right — another machine of yours, or a commercial SMTP relay. What the receiver sees is the relay's address and the relay's PTR; your IP stops appearing in the outbound path at all.

i

Receiving is untouched. Only outbound needs the relay: the MX still points at your server, port 25 still has to be open, and mail from everyone else still arrives.

services:
  ferroma:
    image: wesukilaye/ferroma:0.1.7
    environment:
      FERROMA__QUEUE__RELAY_HOST: smtp.example-relay.com
      FERROMA__QUEUE__RELAY_PORT: "587"
      FERROMA__QUEUE__RELAY_TLS: starttls
      FERROMA__QUEUE__RELAY_USERNAME: your-username
      FERROMA__QUEUE__RELAY_PASSWORD: your-password
      # Empty means every outbound message goes through the relay.
      # FERROMA__QUEUE__RELAY_FROM_DOMAINS: '["example.com"]'

The three relay_tls values:

ValueMeaning
starttls587: connect in the clear, then upgrade
implicit465: TLS from the first byte
nonean internal relay on a trusted network. Never combined with credentials

relay_from_domains empty means every outbound message goes through the relay; a list of domains sends only those through it and leaves the rest resolving by MX as usual.

Two DNS records change when you relay:

RecordWhat changes
SPFthe sender is now the relay's IP, so the record has to include the provider's own domain — v=spf1 include:_spf.relay.example -all. Nothing can guess that name from the relay hostname; ask the provider for it
PTRno longer needed. The reverse lookup a receiver performs is on the relay's address, not yours

The DNS panel in the console reads [queue] relay_host to decide both rows: with a relay configured, a missing PTR drops from a defect to a note, and the SPF row asks for that include instead.

5. Path A · Compose with its own database

Compose brings up two containers: postgres, reachable only on the ferroma-internal network, and ferroma, which publishes 25/587/465/143/993 plus one HTTP port. The database is tuned for a real workload, and both containers carry resource limits, restart: always and bounded logs.

git clone https://github.com/z1HwanG/Ferroma && cd Ferroma
cp .env.example .env

# .env — two values are required before the first pull
POSTGRES_PASSWORD=<a long random password>
FERROMA_VERSION=0.1.7

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

Then open http://<host>:8080 in a browser and walk the wizard. In production put a reverse proxy in front of that port for HTTPS — Ferroma has no HTTPS listener of its own; Webmail, Admin and the API all come out of this one plaintext port.

6. Path B · Compose on a host that already runs PostgreSQL

The host already runs PostgreSQL and a reverse proxy owns 443 — do not start a second database container. scripts/deploy.sh walks the whole first run and never edits your database server's configuration: the container uses network_mode: host, so the local database is simply 127.0.0.1:5432, and neither listen_addresses nor pg_hba.conf is touched.

git clone https://github.com/z1HwanG/Ferroma && cd Ferroma
./scripts/deploy.sh

It does the following, in order; any failure prints the exact command that fixes it:

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 sudo -u postgres, then the psql inside a PostgreSQL container on this host, then the superuser named by --pg-password; prints pasteable SQL if none works
5. Build the imagea local docker build (10–30 minutes the first time). Pass --image wesukilaye/ferroma:0.1.7 to pull the release instead and skip the build
6. Create the schemaruns ferroma database init in the container (which also creates the database when it is missing)
7. Install the certificateinstalls it 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

After that these are the only sub-commands you will type:

./scripts/deploy.sh                 # first deployment, or re-apply .env and restart
./scripts/deploy.sh status          # containers / health / database
./scripts/deploy.sh logs            # follow the Ferroma log
./scripts/deploy.sh upgrade         # rebuild the image, restart, wait for health
./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
./scripts/deploy.sh doctor          # run `ferroma doctor` inside the container
./scripts/deploy.sh down [--volumes]

For an unattended run (cloud-init, CI), every prompt has a flag:

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

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.

7. Path C · Plain Docker, no Compose

Compose is only a declaration of the wiring below. If you would rather not introduce Compose, do the same thing by hand: one private network, one database container, one schema creation, one start.

DB_PW=$(openssl rand -hex 24)
DATABASE_URL="postgres://ferroma:$DB_PW@ferroma-postgres:5432/ferroma"

# 1. one private network for the two containers
docker network create ferroma

# 2. the database — never published to the host
docker run -d --name ferroma-postgres --restart always --network ferroma \
  -e POSTGRES_USER=ferroma \
  -e POSTGRES_PASSWORD="$DB_PW" \
  -e POSTGRES_DB=ferroma \
  -e POSTGRES_INITDB_ARGS='--encoding=UTF8 --locale=C' \
  -v ferroma-postgres-data:/var/lib/postgresql/data \
  postgres:16-alpine

# 3. create the schema, then migrate (idempotent; safe to re-run)
docker run --rm --network ferroma -e DATABASE_URL="$DATABASE_URL" \
  -v ferroma-data:/var/lib/ferroma wesukilaye/ferroma:0.1.7 database init

# 4. start the server
docker run -d --name ferroma --restart always --network ferroma \
  -e DATABASE_URL="$DATABASE_URL" \
  -e FERROMA_DATA_DIR=/var/lib/ferroma \
  -p 25:25 -p 587:587 -p 143:143 -p 8080:8080 \
  -v ferroma-data:/var/lib/ferroma \
  wesukilaye/ferroma:0.1.7

465 and 993 are 0 (off) in the shipped default configuration. Once you have a certificate, recreate the container with TLS on:

# Once ./tls holds fullchain.pem and privkey.pem, recreate the container
# with the implicit-TLS listeners on and the certificate mounted.
docker rm -f ferroma
docker run -d --name ferroma --restart always --network ferroma \
  -e DATABASE_URL="$DATABASE_URL" \
  -e FERROMA_DATA_DIR=/var/lib/ferroma \
  -e FERROMA_TLS_ENABLED=true \
  -e FERROMA_TLS_CERT=/etc/ferroma/tls/fullchain.pem \
  -e FERROMA_TLS_KEY=/etc/ferroma/tls/privkey.pem \
  -e FERROMA__SMTP__SMTPS_PORT=465 \
  -e FERROMA__IMAP__IMAPS_PORT=993 \
  -p 25:25 -p 587:587 -p 465:465 -p 143:143 -p 993:993 -p 8080:8080 \
  -v ferroma-data:/var/lib/ferroma -v "$PWD/tls:/etc/ferroma/tls:ro" \
  wesukilaye/ferroma:0.1.7
!

This path gives up three things Compose was doing for you: resource limits, bounded logs, and orchestration beyond restart: always. In production add --log-opt max-size=20m --log-opt max-file=10 and --memory 2g yourself, or a json-file log will fill the disk.

8. The environment variables that matter

Names use the double-underscore form: FERROMA__API__PORT is [api] port in ferroma.toml. Everything not listed here already has a working default.

VariableRequiredWhat it does
DATABASE_URLyesPostgreSQL connection string. Required on paths A and C; path B writes it into .env for you
POSTGRES_PASSWORDpath Aread only by docker-compose.prod.yml, to start the database container
FERROMA_VERSIONpath Athe release tag to pull (e.g. 0.1.7). Pin the exact release for a reproducible deployment; never latest
FERROMA_DATA_DIRnowhere the Maildir, attachments and the DKIM private key live. /var/lib/ferroma in the image
FERROMA_JWT_SECRETnounset, every session is invalidated when the process restarts; the image generates one into the data volume and reuses it
FERROMA_TLS_ENABLED / FERROMA_TLS_CERT / FERROMA_TLS_KEYnoSMTP/IMAP TLS terminated by this process. Enabling it before the files exist is refused with an explanation rather than applied and failing later
FERROMA__SMTP__SMTPS_PORT / FERROMA__IMAP__IMAPS_PORTnothe implicit-TLS listeners (465/993). Default 0: without them the published ports map to nothing
FERROMA__API__SECURE_COOKIESnoset true once a reverse proxy serves HTTPS; over plain HTTP it makes the first sign-in fail
FERROMA__API__TRUST_PROXY_HEADERSnoset true behind a reverse proxy, or the logs and the login throttle see the proxy
FERROMA__QUEUE__RELAY_HOST and the rest of its group: _PORT / _TLS / _USERNAME / _PASSWORD / _FROM_DOMAINSnothe outbound relay (smarthost). Use it when your provider will not set a PTR — see section 4
FERROMA_LOG_LEVEL / FERROMA_LOG_FORMATnoinfo / text by default; containers usually want json

9. First boot and the setup wizard

On the very first boot the configuration still lacks the mail domain, the hostname, the public URL and the API listener. The wizard collects them, stores them in the database, and the server adopts them on its next start. That is why the production compose file sets no FERROMA_HOSTNAME: leaving it unset is what makes the wizard's answers authoritative.

  1. Open the wizard

    Before setup finishes it is the root path: http://<host>:8080. The console itself is always at /admin/. A server that cannot reach PostgreSQL does not exit; it stays on that console and lets you fix the database first.

  2. Answer four things

    The mail domain, the MX hostname, the public URL (https://mail.example.com), and the API bind address and port.

  3. Create the domain and the administrator

    The wizard does it for you; the same thing from the shell inside the container:

    docker exec ferroma ferroma domain create example.com
    docker exec ferroma ferroma user create you@example.com --admin
  4. Generate a DKIM key and publish the TXT record

    Publish the public key in DNS before you turn signing on.

    docker exec ferroma ferroma dkim generate --domain example.com
    docker exec ferroma ferroma dkim show --domain example.com
  5. An end-to-end check

    Send a message from the outside to a local address, then confirm it landed.

10. Everyday commands

# the container is the only thing to look at on this host
docker logs -f --tail=200 ferroma

# a shell inside it, as the ferroma service user
docker exec -it ferroma sh

# the preflight: what serve needs, and what would go wrong
docker exec ferroma ferroma doctor

# which ports are actually bound
docker exec ferroma ferroma config show

On a Compose deployment, replace docker with docker compose -f docker-compose.prod.yml:

docker compose -f docker-compose.prod.yml logs -f --tail=200 ferroma
docker compose -f docker-compose.prod.yml restart ferroma
docker compose -f docker-compose.prod.yml exec ferroma sh
docker compose -f docker-compose.prod.yml down        # keeps the volumes
# docker compose -f docker-compose.prod.yml down -v   # destroys all mail and users

11. Upgrades and rollback

An upgrade is a new tag and a recreated container; migrations run at startup. A rollback is the old tag. Neither touches the volumes or the database.

# .env
FERROMA_VERSION=0.1.7

docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d

# rollback: put the previous tag back and repeat
docker compose -f docker-compose.prod.yml up -d
!

Migrations only move forward. Before rolling an image back, check CHANGELOG.md for a schema change in that release, and take a database dump first.

12. Backups

Nothing in the deployment backs anything up, on purpose: use the host's own tooling. There are two halves and they must be restored together — a database restored without its Maildir loses messages, and vice versa.

WhatWhereHow
Relational statethe ferroma database (the ferroma-postgres-data volume on path A)pg_dump -Fc, or a storage-level snapshot
Mail and attachmentsthe ferroma-data volume: mail/, attachments/a volume-level snapshot, or restic / borg / rsync
The DKIM private keydkim/ in the same volumethe same. Lose it and you re-issue and re-publish the TXT record
Certificates./tlsre-issuable; no need to back up

The commands and the restore order are in Deployment reference §8.

13. Troubleshooting

SymptomRun this first
No inbound mail at alldig +short MX example.com; confirm inbound 25 is not blocked by the provider
Outbound mail lands in spam / is rejecteddig +short TXT example.com and dig +short TXT default._domainkey.example.com; confirm the PTR matches the MX hostname
Your provider will not set a PTRdo not deliver directly — relay the outbound path, see "No PTR from your provider" above. The SPF record has to include the relay provider's domain
A mail client cannot connect on 587ferroma config show inside the container; STARTTLS needs a certificate, and without one there is only plaintext
465 / 993 refuse the connectionthe implicit-TLS listeners are off by default. Set FERROMA__SMTP__SMTPS_PORT=465 and FERROMA__IMAP__IMAPS_PORT=993
Sessions drop right after sign-inset FERROMA__API__SECURE_COOKIES=true once the reverse proxy serves HTTPS
The container is up but / is a 404check that the front-end directory variables were not overridden (the image points them at /usr/share/ferroma)
Everything is slowferroma config check --dns-domain example.com: a resolver that does not answer is paid for in full timeout, before the SMTP reply
docker exec ferroma ferroma healthcheck
curl -s localhost:8080/api/v1/health
dig +short MX example.com
dig +short TXT default._domainkey.example.com

14. Where the detail lives

This page stops at a running server. The complete version of the same material:

Ferroma · AGPL-3.0-only · Source · built from docs/