部署指南
三种受支持的部署形态,选一种照着命令走完,再走一遍设置向导。下面每一条命令都对应仓库里的真实产物:Dockerfile、三个 docker-compose*.yml、.env.example 与 scripts/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/993 | docker-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:rodocker 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一个地址走完两步。没有声明数据库的实例不会退出——它照常绑定 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. 开工之前
| 前提 | 为什么 |
|---|---|
| 一个静态公网 IPv4 | MX 需要一个稳定地址,而且 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 不再出现在出站路径上。
接收完全不受影响。只有出站需要中继: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 取值:
| 取值 | 含义 |
|---|---|
starttls | 587:先明文连接,再升级到 TLS |
implicit | 465:连上就是 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:5432,listen_addresses 与 pg_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没有证书时脚本会关掉 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.7465 与 993 在默认配置里是 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_URL | 是 | PostgreSQL 连接串。路径 A/C 必填;路径 B 由 deploy.sh 写入 .env |
POSTGRES_PASSWORD | 路径 A | 只有 docker-compose.prod.yml 读它,用来起数据库容器 |
FERROMA_VERSION | 路径 A | 要拉取的发布 tag(如 0.1.7)。可复现部署请钉死版本,不要用 latest |
FERROMA_DATA_DIR | 否 | Maildir、附件与 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 的原因:留着不设,向导的答案才是权威。
- 打开引导界面
设置完成之前它就在根路径:
http://<host>:8080。控制台本身始终在/admin/。数据库不可达时服务不会退出,而是留在同一个控制台上,让你先把数据库接上。 - 填写四件事
邮件域、MX 主机名、公网地址(
https://mail.example.com)、API 监听地址与端口。 - 建域与管理员
向导直接建好;也可以在容器里用命令行做同样的事:
docker exec ferroma ferroma domain create example.com docker exec ferroma ferroma user create you@example.com --admin - 生成 DKIM 密钥并发布 TXT
签发之前,先把公钥发到 DNS 上。
docker exec ferroma ferroma dkim generate --domain example.com docker exec ferroma ferroma dkim show --domain example.com - 端到端自检
从外网往本域地址发一封信,再确认它落进了 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 showCompose 部署则把 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-data:mail/、attachments/ | 卷级快照,或 restic / borg / rsync |
| DKIM 私钥 | 同一个卷的 dkim/ 下 | 同上。丢了就要重新签发并重新发布 TXT |
| 证书 | ./tls | 可重新签发,不必备份 |
完整命令与恢复顺序见 部署参考 §8。
13. 排障速查
| 症状 | 先跑这个 |
|---|---|
| 收不到任何外部邮件 | dig +short MX example.com;确认 25 入站没有被厂商封禁 |
| 发出的邮件进垃圾箱 / 被拒 | dig +short TXT example.com 与 dig +short TXT default._domainkey.example.com;确认 PTR 与 MX 主机名一致 |
| 服务商不给设 PTR | 别硬发:让出站走中继,见上面「服务商不给 PTR」一节。SPF 记得 include 中继服务商的域 |
| 邮件客户端连不上 587 | 容器里 ferroma config show;STARTTLS 需要证书,没有证书就只能明文 |
| 465/993 连不上 | 隐式 TLS 监听默认关闭。设 FERROMA__SMTP__SMTPS_PORT=465 与 FERROMA__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.com14. 深水区在哪里
这一页只负责把服务跑起来。下面这些是同一件事的完整版本:
- 部署参考 §2 — DNS 记录:MX、A、PTR、SPF、MTA-STS、DMARC 的完整表格与 BIND 片段。
- 部署参考 §5 — 端口与 TLS:端口表、两种 TLS 终止方式、nginx 配置与 Let's Encrypt。
- 部署参考 §8 — 备份与恢复:命令,以及一份备份必须包含什么。
- 安全:威胁模型与每一项控制。
- CHANGELOG:升级之前先读它。