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.
| Shape | The host | Database | TLS | Files |
|---|---|---|---|---|
| Q · the fastest path start here | PostgreSQL already runs on this host | the one you have (Ferroma never creates it) | all plaintext, this host only | your own compose file, or one docker run |
| A · Compose, its own database | anything with Docker | postgres:16-alpine container | Ferroma terminates 465/993; a reverse proxy terminates HTTPS | docker-compose.prod.yml |
| B · Compose, existing PostgreSQL recommended | already runs PostgreSQL and a reverse proxy | your own server, over 127.0.0.1 | your proxy terminates HTTPS; Ferroma terminates 465/993 | docker-compose.external-db.yml + scripts/deploy.sh |
| C · Plain Docker | anything with Docker | a container you run yourself | your own arrangement | docker 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:rodocker 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 codeOr 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 printsBoth 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 7JVTQAHOOne 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 required | generated 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 exist | the role you name only needs the rights to use it; creating it is the operator's job, not Ferroma's |
| The address is remembered | written to <data_dir>/database.json (mode 0600 — it holds a password) and read from disk on every later start |
| There is no restart | the 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
| Requirement | Why it matters |
|---|---|
| A static public IPv4 address | an 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 inbound | receiving mail from other servers. Many VPS providers block it by default — ask them to open it first |
| Port 25 reachable outbound | delivering mail |
| A domain you control | example.com in every example below |
| Docker Engine 24+ with the Compose plugin | docker compose, not docker-compose |
| 1 vCPU, 1 GB RAM, 20 GB disk | a 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.
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:
| Value | Meaning |
|---|---|
starttls | 587: connect in the clear, then upgrade |
implicit | 465: TLS from the first byte |
none | an 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:
| Record | What changes |
|---|---|
| SPF | the 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 |
| PTR | no 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 psThen 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.shIt does the following, in order; any failure prints the exact command that fixes it:
| Step | What it does |
|---|---|
| 1. Preflight | are docker, the compose plugin and the compose files all present |
| 2. Collect configuration | asks interactively: mail domain, MX hostname, admin address, database address, API port (default 127.0.0.1:18080) |
3. Write .env | generates a random database password and FERROMA_JWT_SECRET, mode 600 — it is the only configuration file |
| 4. Create the role and the database | tries 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 image | a 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 schema | runs ferroma database init in the container (which also creates the database when it is missing) |
| 7. Install the certificate | installs it into ./tls as uid 10001 for 465/993, and checks that the SAN covers the MX hostname |
| 8. Start | docker compose up -d, waiting up to 3 minutes for the health check and printing the log on timeout |
| 9. First-run initialisation | creates the domain and the admin account (the password is printed once), generates the DKIM key and prints the TXT record to publish |
| 10. Summary | the 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.pemWith 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.7465 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.7This 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.
| Variable | Required | What it does |
|---|---|---|
DATABASE_URL | yes | PostgreSQL connection string. Required on paths A and C; path B writes it into .env for you |
POSTGRES_PASSWORD | path A | read only by docker-compose.prod.yml, to start the database container |
FERROMA_VERSION | path A | the release tag to pull (e.g. 0.1.7). Pin the exact release for a reproducible deployment; never latest |
FERROMA_DATA_DIR | no | where the Maildir, attachments and the DKIM private key live. /var/lib/ferroma in the image |
FERROMA_JWT_SECRET | no | unset, 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_KEY | no | SMTP/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_PORT | no | the implicit-TLS listeners (465/993). Default 0: without them the published ports map to nothing |
FERROMA__API__SECURE_COOKIES | no | set true once a reverse proxy serves HTTPS; over plain HTTP it makes the first sign-in fail |
FERROMA__API__TRUST_PROXY_HEADERS | no | set 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_DOMAINS | no | the outbound relay (smarthost). Use it when your provider will not set a PTR — see section 4 |
FERROMA_LOG_LEVEL / FERROMA_LOG_FORMAT | no | info / 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.
- 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. - Answer four things
The mail domain, the MX hostname, the public URL (
https://mail.example.com), and the API bind address and port. - 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 - 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 - 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 showOn 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 users11. 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 -dMigrations 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.
| What | Where | How |
|---|---|---|
| Relational state | the ferroma database (the ferroma-postgres-data volume on path A) | pg_dump -Fc, or a storage-level snapshot |
| Mail and attachments | the ferroma-data volume: mail/, attachments/ | a volume-level snapshot, or restic / borg / rsync |
| The DKIM private key | dkim/ in the same volume | the same. Lose it and you re-issue and re-publish the TXT record |
| Certificates | ./tls | re-issuable; no need to back up |
The commands and the restore order are in Deployment reference §8.
13. Troubleshooting
| Symptom | Run this first |
|---|---|
| No inbound mail at all | dig +short MX example.com; confirm inbound 25 is not blocked by the provider |
| Outbound mail lands in spam / is rejected | dig +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 PTR | do 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 587 | ferroma config show inside the container; STARTTLS needs a certificate, and without one there is only plaintext |
| 465 / 993 refuse the connection | the implicit-TLS listeners are off by default. Set FERROMA__SMTP__SMTPS_PORT=465 and FERROMA__IMAP__IMAPS_PORT=993 |
| Sessions drop right after sign-in | set FERROMA__API__SECURE_COOKIES=true once the reverse proxy serves HTTPS |
The container is up but / is a 404 | check that the front-end directory variables were not overridden (the image points them at /usr/share/ferroma) |
| Everything is slow | ferroma 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.com14. Where the detail lives
This page stops at a running server. The complete version of the same material:
- Deployment reference §2 — DNS records: the full record table, a BIND-style zone snippet and how to verify the zone.
- Deployment reference §5 — ports and TLS: the port table, both TLS arrangements, the nginx server block, and Let's Encrypt.
- Deployment reference §8 — backup and restore: the commands, and what a backup must contain.
- Security: the threat model and every control.
- CHANGELOG: read it before an upgrade.