Ferroma v0.1.7
运维 · 文档

部署指南

三种受支持的部署形态,选一种照着命令走完,再走一遍设置向导。下面每一条命令都对应仓库里的真实产物:Dockerfile、三个 docker-compose*.yml.env.examplescripts/deploy.sh

1. 先选形态

三种形态都是受支持的部署方式,区别只在于“这台主机上已经有什么”。选一种走到底,不要混着来。

形态适合的主机数据库TLS用到的文件
Q · 最快的一条路 先看这个主机上已经跑着 PostgreSQL你已有的那一个(Ferroma 不建库)全明文,仅限本机你自己的一个 compose 文件,或一条 docker run
A · Compose,自带数据库只有 Docker 的干净主机postgres:16-alpine 容器Ferroma 终止 465/993;HTTPS 交给反向代理docker-compose.prod.yml
B · Compose,复用现有 PostgreSQL 推荐已经在跑 PostgreSQL 和反向代理你自己的服务器,经 127.0.0.1反向代理终止 HTTPS;Ferroma 终止 465/993docker-compose.external-db.yml + scripts/deploy.sh
C · 纯 Docker只有 Docker 的干净主机你自己起的容器你自己安排docker run
!

docker-compose.yml(没有后缀的那个)是开发栈:IMAP 与 API 明文、没有资源限制、143 端口未加密地暴露。不要把它放到公网上。

2. 最快的一条路:一个容器,数据库在浏览器里填

这条路上没有 .env、没有构建、没有预先配好的连接串:一个发布镜像、一个数据卷,剩下的两步都在浏览器里完成。前提是这台主机上已经有一个 PostgreSQL(1Panel 装的、发行版装的都算),因为 Ferroma 只连接、只应用 schema,从不执行 CREATE DATABASE

用 Docker Compose

services:
  ferroma:
    image: wesukilaye/ferroma:0.1.7
    container_name: ferroma
    restart: always
    # 直接绑定宿主的 25/587/143/8080,于是宿主上的 PostgreSQL 就是
    # 127.0.0.1。仅限 Linux。
    network_mode: host
    volumes:
      - ./data:/var/lib/ferroma
      - ./tls:/etc/ferroma/tls:ro
docker compose up -d
docker compose logs ferroma        # 读它打印出来的 setup code

# 然后打开 http://<host>:8080/,填入数据库地址和那个 code

或者,纯 docker run

mkdir -p data tls
sudo chown -R 10001:10001 data     # 容器以 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                # 读它打印出来的 setup code

两种写法起的是同一个容器。它的日志会打印这一段:

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

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

一个地址走完两步。没有声明数据库的实例不会退出——它照常绑定 web 端口,在 / 上要连接串和 code。连接被验证、schema 被应用之后,同一个进程、同一个端口继续启动,没有重启,页面直接变成引导界面,在那里建第一个邮件域和管理员账号。

四件必须知道的事:

setup code 是必需的每次启动生成、只打印在日志里。否则任何能访问这个 web 端口的人,都能把你的实例指向他自己的数据库
数据库必须已经存在角色只需要那个库的使用权限;建库是操作员的事,不是 Ferroma 的事
地址会被记住写进 <data_dir>/database.json(0600,里面有密码),之后每次启动从磁盘读,不再问
没有重启连接在同一个进程里生效。想让部署来指定连接串,就在 .env 里写 DATABASE_URL——写了就以部署为准,启动失败也会是响亮的那种

数据库连上之后,/ 仍然是控制台,直到第一个管理员存在;再之后它才变成 Webmail 的登录页。

!

这条路是明文,而且只限本机试跑。容器以 network_mode: host 把 25/587/143/8080 原样绑在主机上,143 未加密,没有 TLS,没有资源上限,也没有重启策略之外的保护。把界面点一遍、给自己发第一封信,然后按下面的路径 A 或 B 上公网。

3. 开工之前

前提为什么
一个静态公网 IPv4MX 需要一个稳定地址,而且 PTR 记录必须与它一致。服务商不给设 PTR 的话,看下一节
25 端口入站可达接收别的服务器投来的邮件。很多 VPS 厂商默认封禁,开工前先让厂商放开
25 端口出站可达把邮件投递出去
一个你控制的域名下文一律用 example.com
Docker Engine 24+ 与 Compose 插件docker compose,不是 docker-compose
1 vCPU、1 GB 内存、20 GB 磁盘小型部署足够。Ferroma 自身只占几十 MB,内存是给 PostgreSQL 和页缓存的;邮件存储会一直长
!

DNS 先行。MX / A / PTR / SPF / DKIM / DMARC 没有就位之前,无论 Ferroma 配得多正确,发出的邮件都会被拒收,也收不到任何邮件。完整记录表在 部署参考 §2

4. 服务商不给 PTR:让出站走中继

PTR(反向 DNS)由 IP 段的持有者设置 —— 通常是 VPS 服务商的控制面板。不少服务商根本不提供这个入口,或者要开工单才给设。而没有 PTR、或 PTR 与 EHLO 名不符的 IP,是"合法邮件被丢弃或拒收"最常见的原因:Gmail 丢进垃圾箱,Microsoft 名下各个域直接拒收。

出路是让出站走中继(smarthost)。把外发交给一个已经做好 PTR 的中继 —— 你自己的另一台机器,或商业 SMTP relay 服务。收件方看到的是中继的 IP 和它的 PTR,你的 IP 不再出现在出站路径上。

i

接收完全不受影响。只有出站需要中继:MX 仍然指向你的服务器,25 端口仍然要开,别人发来的邮件照收不误。

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: 你的用户名
      FERROMA__QUEUE__RELAY_PASSWORD: 你的口令
      # 留空 = 所有外发邮件都走中继
      # FERROMA__QUEUE__RELAY_FROM_DOMAINS: '["example.com"]'

三个 relay_tls 取值:

取值含义
starttls587:先明文连接,再升级到 TLS
implicit465:连上就是 TLS
none内部中继、可信网络。绝不要和凭据一起用

relay_from_domains 留空 = 每一封外发邮件都走中继;填了域名列表 = 只有列出的域走中继,其余仍然按 MX 直发。

配了中继之后,DNS 里有两行要跟着变:

记录怎么变
SPF投递方现在是中继的 IP,所以记录要 include 中继服务商自己的域,例如 v=spf1 include:_spf.relay.example -all。那个名字无法从中继主机名推出来,得问服务商要
PTR不再需要。收件方反查的是中继的地址,不是你的

管理面板的 DNS 面板就是按 [queue] relay_host 判断这两行的:配了中继之后,缺失的 PTR 从"缺陷"降级为"仅供参考",而 SPF 会提示你补上那个 include。

5. 路径 A · Compose,自带数据库

Compose 起两个容器:postgres 只在 ferroma-internal 网络里可达,ferroma 发布 25/587/465/143/993 与一个 HTTP 端口。数据库带真实负载的调优参数,两个容器都有资源上限、restart: always 和有界的日志。

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

# .env —— 首次拉取之前,这两行是必填的
POSTGRES_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

随后浏览器打开 http://<host>:8080 走向导。生产环境要在这个端口前面放反向代理做 HTTPS —— Ferroma 自己没有 HTTPS 监听器,Webmail / Admin / API 都走这一个明文端口。

6. 路径 B · Compose,复用现有 PostgreSQL

主机已经在跑 PostgreSQL,也已经有反向代理占着 443 —— 不要再起第二个数据库容器。scripts/deploy.sh 一次走完首次部署,并且不改动你数据库的任何配置:容器用 network_mode: host,本地数据库就是 127.0.0.1:5432listen_addressespg_hba.conf 都不用碰。

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

它按顺序做这些事,任一步失败都会打印出修复它的确切命令:

步骤做什么
1. 预检docker、Compose 插件、compose 文件是否齐备
2. 收集配置交互询问:邮件域、MX 主机名、管理员地址、数据库地址、API 端口(默认 127.0.0.1:18080
3. 写 .env生成随机数据库口令与 FERROMA_JWT_SECRET,权限 600 —— 这是唯一的配置文件
4. 建角色与数据库依次尝试 sudo -u postgres、宿主机上 PostgreSQL 容器里的 psql--pg-password 指定的超级用户;都不行就打印可直接粘贴的 SQL
5. 构建镜像本地 docker build(首次 10–30 分钟)。加 --image wesukilaye/ferroma:0.1.7 则改为直接拉取发布镜像,跳过构建
6. 建表在容器里执行 ferroma database init(数据库不存在时一并创建)
7. 安装证书把证书按 uid 10001 装进 ./tls 供 465/993 使用,并检查 SAN 是否覆盖 MX 主机名
8. 启动docker compose up -d,最多等 3 分钟健康检查,超时则打印日志
9. 首次初始化创建域与管理员账号(口令只打印一次)、生成 DKIM 密钥并打印待发布的 TXT 记录
10. 汇总反向代理片段、仍缺失的 DNS 记录,以及日常命令

之后你只会用到这几个子命令:

./scripts/deploy.sh                 # 首次部署,或重新应用 .env 并重启
./scripts/deploy.sh status          # 容器 / 健康 / 数据库
./scripts/deploy.sh logs            # 跟随 Ferroma 日志
./scripts/deploy.sh upgrade         # 重建镜像、重启、等健康检查
./scripts/deploy.sh dkim --enable   # TXT 记录发布之后再打开签名
./scripts/deploy.sh certs           # 装入续期后的证书并重启
./scripts/deploy.sh doctor          # 在容器里跑 ferroma doctor
./scripts/deploy.sh down [--volumes]

无人值守(cloud-init、CI)时,每个提问都有对应的旗标:

./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

没有证书时脚本会关掉 TLS 并明确告警:那时 587 上没有 STARTTLS,邮件客户端无法认证,只能用来把数据库和 API 先跑起来。

7. 路径 C · 纯 Docker,不用 Compose

Compose 只是把下面这些接线写成了声明。不想引入 Compose 时,就手工做同样的事:一个私有网络、一个数据库容器、一次建库迁移、一次启动。

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

# 1. 两个容器共用的一条私有网络
docker network create ferroma

# 2. 数据库 —— 永远不发布到宿主
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. 建表并迁移(幂等,重复执行是安全的)
docker run --rm --network ferroma -e DATABASE_URL="$DATABASE_URL" \
  -v ferroma-data:/var/lib/ferroma wesukilaye/ferroma:0.1.7 database init

# 4. 启动服务
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

465993 在默认配置里是 0(关闭)。拿到证书之后再带上 TLS 重新起容器:

# ./tls 里有了 fullchain.pem 与 privkey.pem 之后,带着隐式 TLS 监听
# 和挂载好的证书,重新创建这个容器。
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
!

这条路少了 Compose 给你的三样东西:restart: always 之外的崩溃恢复编排、资源上限、以及有界日志。生产环境请用 --log-opt max-size=20m --log-opt max-file=10 之类的手段自行补上,否则 json-file 日志会把磁盘吃满。

8. 真正要设的环境变量

变量名是双下划线形式:FERROMA__API__PORT 对应 ferroma.toml 里的 [api] port。除了下面这些,其余全部有可用默认值。

变量必填作用
DATABASE_URLPostgreSQL 连接串。路径 A/C 必填;路径 B 由 deploy.sh 写入 .env
POSTGRES_PASSWORD路径 A只有 docker-compose.prod.yml 读它,用来起数据库容器
FERROMA_VERSION路径 A要拉取的发布 tag(如 0.1.7)。可复现部署请钉死版本,不要用 latest
FERROMA_DATA_DIRMaildir、附件与 DKIM 私钥的位置。容器内默认 /var/lib/ferroma
FERROMA_JWT_SECRET不设则进程重启会让所有会话失效;镜像会在数据卷里生成一次并复用
FERROMA_TLS_ENABLED / FERROMA_TLS_CERT / FERROMA_TLS_KEY由本进程终止 SMTP/IMAP 的 TLS。证书文件不存在时会明确拒绝启动,而不是起完再失败
FERROMA__SMTP__SMTPS_PORT / FERROMA__IMAP__IMAPS_PORT隐式 TLS 监听(465/993)。默认 0,不打开则发布出去的端口没人监听
FERROMA__API__SECURE_COOKIES反向代理上 HTTPS 之后设为 true;明文 HTTP 下开着会让第一次登录失败
FERROMA__API__TRUST_PROXY_HEADERS在反向代理后面时设为 true,否则日志与限流拿到的是代理的地址
FERROMA__QUEUE__RELAY_HOST 及同组的 _PORT / _TLS / _USERNAME / _PASSWORD / _FROM_DOMAINS出站中继(smarthost)。服务商不给设 PTR 时用它,见第 4 节
FERROMA_LOG_LEVEL / FERROMA_LOG_FORMAT默认 info / text;容器里通常用 json

9. 首次启动与设置向导

服务器第一次启动时,配置里还缺邮件域、主机名、公网地址和 API 监听 —— 这些由引导界面收集,存进数据库,服务下次启动时采用。这就是生产 compose 文件里没有 FERROMA_HOSTNAME 的原因:留着不设,向导的答案才是权威。

  1. 打开引导界面

    设置完成之前它就在根路径:http://<host>:8080。控制台本身始终在 /admin/。数据库不可达时服务不会退出,而是留在同一个控制台上,让你先把数据库接上。

  2. 填写四件事

    邮件域、MX 主机名、公网地址(https://mail.example.com)、API 监听地址与端口。

  3. 建域与管理员

    向导直接建好;也可以在容器里用命令行做同样的事:

    docker exec ferroma ferroma domain create example.com
    docker exec ferroma ferroma user create you@example.com --admin
  4. 生成 DKIM 密钥并发布 TXT

    签发之前,先把公钥发到 DNS 上。

    docker exec ferroma ferroma dkim generate --domain example.com
    docker exec ferroma ferroma dkim show --domain example.com
  5. 端到端自检

    从外网往本域地址发一封信,再确认它落进了 Maildir。

10. 日常命令

# 这台主机上只有这一个容器需要看
docker logs -f --tail=200 ferroma

# 进容器拿一个 shell,身份是 ferroma 服务用户
docker exec -it ferroma sh

# 预检:serve 需要什么,以及哪一步会出问题
docker exec ferroma ferroma doctor

# 实际绑定了哪些端口
docker exec ferroma ferroma config show

Compose 部署则把 docker 换成 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        # 保留数据卷
# docker compose -f docker-compose.prod.yml down -v   # 连同所有邮件与用户一起删除

11. 升级与回滚

升级就是换一个 tag 再重建容器;迁移在启动时自动执行。回滚就是把 tag 换回去。数据卷与数据库都不动。

# .env
FERROMA_VERSION=0.1.7

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

# 回滚:把上一个 tag 写回去,再重复一次
docker compose -f docker-compose.prod.yml up -d
!

迁移只向前。回滚镜像之前先看 CHANGELOG.md 里那一版是否带 schema 变更,并先做一次数据库转储。

12. 备份

部署里没有任何备份组件,这是有意的:用主机自己的工具去做。要备份的是两半,而且必须成对恢复 —— 只恢复数据库会丢邮件,只恢复 Maildir 会丢元数据。

要备份的东西在哪里怎么备
关系数据PostgreSQL 的 ferroma 库(路径 A 是 ferroma-postgres-data 卷)pg_dump -Fc,或数据库层的快照
邮件与附件ferroma-datamail/attachments/卷级快照,或 restic / borg / rsync
DKIM 私钥同一个卷的 dkim/同上。丢了就要重新签发并重新发布 TXT
证书./tls可重新签发,不必备份

完整命令与恢复顺序见 部署参考 §8

13. 排障速查

症状先跑这个
收不到任何外部邮件dig +short MX example.com;确认 25 入站没有被厂商封禁
发出的邮件进垃圾箱 / 被拒dig +short TXT example.comdig +short TXT default._domainkey.example.com;确认 PTR 与 MX 主机名一致
服务商不给设 PTR别硬发:让出站走中继,见上面「服务商不给 PTR」一节。SPF 记得 include 中继服务商的域
邮件客户端连不上 587容器里 ferroma config show;STARTTLS 需要证书,没有证书就只能明文
465/993 连不上隐式 TLS 监听默认关闭。设 FERROMA__SMTP__SMTPS_PORT=465FERROMA__IMAP__IMAPS_PORT=993
登录后立刻掉线反向代理已上 HTTPS 时设 FERROMA__API__SECURE_COOKIES=true
容器起来但 / 是 404检查前端目录的环境变量是否被覆盖(镜像里已指向 /usr/share/ferroma
一切都慢ferroma config check --dns-domain example.com:解析器不响应会按超时全额计费,且在 SMTP 应答之前
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. 深水区在哪里

这一页只负责把服务跑起来。下面这些是同一件事的完整版本:

Ferroma · AGPL-3.0-only · 源码仓库 · 由 docs/ 生成