Docker 搭建私人邮箱系统

前言

有一台 VPS 和一个域名,就想给自己搭个邮箱。这里使用 Docker Mailserver 收发邮件,先配置客户端使用。网页端可选,后面再用 Docker 部署 Roundcube 和 Nginx。账号只由管理员创建,不开放注册。

这次使用的环境:

  • Ubuntu 20.04.6 LTS,内存约 2.4 GiB,Swap 约 1.2 GiB
  • Docker Mailserver v16.0.1
  • Roundcube + SQLite
  • Cloudflare DNS,Snap 版 Certbot

邮箱域名是 f0rget.me,邮件服务器使用 mail.f0rget.me,网页入口使用 webmail.f0rget.me。下面记录配置和部署过程。

Docker Mailserver

Docker Mailserver,下面简称 DMS,将 Postfix、Dovecot 等邮件组件整合到一个容器中。Postfix 负责接收、排队和转发邮件,Dovecot 负责认证、投递和 IMAP 访问,配置通过环境变量和本地文件管理。项目介绍

这里选择 DMS,主要是账号数量不多,用命令行管理就够了。邮件和配置都挂载到宿主机目录,后续备份和迁移也方便,不用自己从头组合这些服务。

考虑到服务器内存不大,配置中关闭了 ClamAV 和 Amavis,使用 Rspamd 处理反垃圾。ClamAV 关闭后没有它提供的病毒扫描,具体能承受多少邮箱和并发还要看使用情况。

Roundcube

Roundcube 是一个开源网页邮件客户端,可以在浏览器中读信、写信、上传附件和管理联系人。它通过 IMAP 读取 DMS 中的邮件,通过 SMTP 发信。项目介绍

网页端只需要基本收发功能,因此选了 Roundcube。数据库用 SQLite,省去单独部署数据库容器,联系人和偏好等数据保存在本地。镜像说明

登录用的是 DMS 中已有的邮箱账号,密码也由 DMS 管理。Roundcube 保存自己的用户设置,不负责创建邮箱;停掉它以后,仍然可以用 Thunderbird、Gmail 等客户端软件连接 DMS 收发邮件。

部署架构

mail-architecture.png

完整部署包含以下三个目录。只使用邮件客户端时,部署 DMS 即可:

  1. /opt/docker-mailserver:容器名 docker-mailserver,使用自己的默认 bridge 网络,发布邮件端口。
  2. /opt/roundcube:容器名 roundcube,加入外部 bridge 网络 webmail-net。
  3. /opt/webmail-proxy:容器名 webmail-nginx,也加入 webmail-net,发布 80 和 443。

Nginx 和 Roundcube 加入同一个 webmail-net 网络,上游直接写 http://roundcube:80,由 Docker 解析容器名称。Roundcube 则连接 mail.f0rget.me:993/587,DMS 使用自己的默认 bridge 网络即可。Docker 网络说明

Roundcube 不向宿主机发布端口,Nginx 通过 webmail-net 访问它的容器内 80 端口。

收发流程

收信: 对方查询域名的 MX,连接邮件服务器的 25 端口。Postfix 接收邮件后交给 Dovecot 投递,保存在 /var/mail。

读信: 浏览器访问 Nginx 的 443 端口,Nginx 转发到 Roundcube,Roundcube 再通过 993 端口读取邮箱。

发信: Roundcube 通过 587 端口认证并提交邮件,Rspamd 按配置签名,再由 Postfix 连接收件方的 25 端口投递。客户端显示发送成功后,还可以在投递日志中查看对方是否接受。

域名与端口

名称 用途
f0rget.me 邮箱地址右边的域名,也是 MX、SPF、DMARC 等记录所属的域
mail.f0rget.me SMTP / IMAP 主机名、邮件证书名称、PTR 对应的主机名
webmail.f0rget.me 浏览器网页邮箱入口、网页证书名称
[email protected] 实际创建的邮箱账号,登录时使用完整地址

下面的 DNS 示例用 203.0.113.10 占位,部署时替换成自己的公网 IP。

邮件协议

SMTP 用于发送和转交邮件。客户端把邮件提交给自己的服务器,服务器再转发给收件方,两段连接都使用 SMTP,但用途和端口不同。这里客户端使用 465 或 587,并验证邮箱账号;服务器之间使用 25,允许外部服务器向本域的有效收件人投递,不要求对方持有本机账号,也不代表允许它任意转发第三方邮件。

IMAP 用于访问服务器上的邮箱。邮件、文件夹和已读状态以服务器为中心,电脑、手机和网页端可以同步操作,因此这里启用 IMAP。它负责读取和管理邮件,不负责发信,客户端仍要另外配置 SMTP。IMAP 协议

POP3 主要用于将邮箱中的邮件下载到客户端,下载后可以保留服务器副本,也可以按客户端设置删除。它不提供 IMAP 那样的文件夹和状态同步,这次不使用,所以设置 ENABLE_POP3=0,也不发布 110、995。POP3 协议

邮件端口

端口 协议与用途 本次配置
TCP 25 SMTP 服务器间投递,可协商 STARTTLS DMS 发布 25;还要允许连接对方服务器的目的端口 25
TCP 465 SMTP Submission,连接开始就建立 TLS,再认证发信 DMS 发布 465,可供客户端使用
TCP 587 SMTP Submission,先连接 SMTP,再通过 STARTTLS 升级为 TLS DMS 发布 587,Roundcube 使用它
TCP 993 IMAP over TLS,连接开始即 TLS DMS 发布 993,客户端和 Roundcube 读取邮箱
TCP 143 IMAP,可通过 STARTTLS 升级 本次不发布,使用 993
TCP 110 POP3,可通过 STLS 升级 本次关闭 POP3,不发布
TCP 995 POP3 over TLS 本次关闭 POP3,不发布
TCP 4190 ManageSieve,用于管理服务端邮件过滤规则 本次不发布

465 和 993 的“SSL/TLS”设置表示隐式 TLS,也就是连接开始就进行 TLS 握手。587 使用 STARTTLS,先发送 SMTP 问候和能力协商,再升级到加密连接,账号密码在 TLS 建立后提交。客户端的端口和加密选项要对应,不能把 587 配成连接开始即 TLS。TLS 与端口约定

25 的入站用于收信,出站用于把邮件投递给其他域。这里“出站 25”指连接对方的目的端口,源端口由系统分配。即使客户端能连接 587 提交邮件,如果 VPS 商家阻断出站 25,Postfix 仍无法直接向外部 MX 投递。服务器间的 25 可以协商 STARTTLS,但不保证每一跳都加密。DMS 端口说明

网页与管理端口

端口 用途 本次配置
TCP 80 浏览器 HTTP 入口,跳转 HTTPS 由 Nginx 发布,可选网页端使用
TCP 443 浏览器 HTTPS 入口 由 Nginx 发布,可选网页端使用
TCP 22 SSH 管理服务器 保留实际管理端口
容器内 TCP 80 Roundcube 的 HTTP 服务 仅由 Nginx 通过 webmail-net 访问,不映射到宿主机

当前宿主机监听的是 22、25、465、587、993、80 和 443,没有 8080 映射。Rspamd、Redis 等内部服务也不需要发布到公网。

服务器还要能够出站访问 DNS 和 HTTPS,用于解析域名、拉取镜像和申请证书;不需要因此开放入站 53。可以查看当前环境和监听情况:

docker version
docker compose version
free -h
df -h /opt
ss -lntup
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}\t{{.Status}}'

在 VPS 安全组和宿主机防火墙中放行对应端口,并保留 SSH 管理入口。Docker 发布端口会生成转发规则,实际可达性从另一台机器测试。

基础 DNS

先在 Cloudflare 配好 A、MX 和 SPF。DKIM 公钥要等 DMS 生成密钥后再填写,DMARC 也放到后面一起配置。下面的 203.0.113.10 替换成自己的公网 IP。

类型 名称 内容 用途
A mail 203.0.113.10 邮件主机的公网 IPv4
MX @ mail.f0rget.me,优先级 10 将发给 @f0rget.me 的邮件交给该主机
TXT @ v=spf1 mx -all 授权 MX 对应的地址代表本域发信

mail 的 A 记录使用 仅 DNS / 灰云。普通 Cloudflare HTTP 代理不能转发 SMTP 和 IMAP。根域名已有网站时,A 记录保持原来的值即可;没有可用 IPv6 时,不添加 mail 的 AAAA。

SPF 检查实际发信 IP 是否被信封发件人域授权。这里 mx 授权 MX 主机对应的地址,-all 表示其他来源不获授权,所以 A、MX 和 SPF 一起设置。以后增加第三方发信服务时,合并到同一条 SPF 记录中。

PTR 反向解析

A 记录将域名解析到 IP,PTR 则将 IP 反向解析成主机名。这里设置成:

公网 IP --PTR--> mail.f0rget.me --A--> 同一个公网 IP
SMTP HELO/EHLO 主机名:mail.f0rget.me

这样正向和反向解析就能对应起来。PTR 通常在 VPS 商家后台设置,或者提交工单,由 IP 所属方处理。收件服务器会用它检查发信 IP 与主机名是否一致,Google 的发信指南也有这项要求。发信要求

申请如下:

chrome_ymQVdXUBBd.webp

客服回应:

chrome_3lPQ9c1Nd4.webp

PTR 配好后,仍然要配置 SPF、DKIM 和 DMARC。它们检查的内容不同,投递结果还会受到 IP 信誉等因素影响。

解析检查

dig +short A mail.f0rget.me
dig +short MX f0rget.me
dig +short TXT f0rget.me
# 替换成自己的公网 IP
dig +short -x 203.0.113.10

此时 MX 应指向 mail.f0rget.me,SPF 应能查到 v=spf1 mx -all,PTR 与邮件主机名对应即可。这里还不检查尚未配置的 DKIM 和 DMARC。

chrome_yBf0b3qlcY.webp

部署准备

以下命令在 VPS 的 root shell 中执行。已有部署先备份,账号、DKIM 密钥和数据库继续沿用。目录安排如下:

/opt/docker-mailserver/
├── compose.yaml
├── mailserver.env
├── secrets/cloudflare.ini
└── docker-data/dms/
    ├── mail-data/       → /var/mail
    ├── mail-state/      → /var/mail-state
    ├── mail-logs/       → /var/log/mail
    └── config/          → /tmp/docker-mailserver
/opt/roundcube/
├── compose.yaml
├── db/                 → /var/roundcube/db
└── www/                → /var/www/html
/opt/webmail-proxy/
├── compose.yaml
└── nginx/conf.d/webmail.conf
/etc/letsencrypt/
├── live/
├── archive/
├── renewal/
└── renewal-hooks/deploy/
install -d /opt/docker-mailserver/docker-data/dms/{mail-data,mail-state,mail-logs,config}
install -d -m 700 /opt/docker-mailserver/secrets
install -d /opt/roundcube/{www,db}
install -d /opt/webmail-proxy/nginx/conf.d

Docker 和 Compose 已安装的话可以跳过安装。新服务器按 Docker Ubuntu 安装说明安装后,确认 docker compose version 有输出。下面记录的是这次使用的 Ubuntu 20.04 环境,新装系统时另行确认当前支持情况。

镜像版本

Compose 中使用了 latest,这次 DMS 的启动日志显示 v16.0.1,Roundcube 显示 1.7.4。这个标签之后还会更新,如果迁移时想使用相同镜像,可以在原服务器记录 digest:

docker inspect --format '{{.Name}} requested={{.Config.Image}} image-id={{.Image}}' \
  docker-mailserver roundcube
docker image inspect ghcr.io/docker-mailserver/docker-mailserver:latest \
  --format '{{json .RepoDigests}}'
docker image inspect roundcube/roundcubemail:latest \
  --format '{{json .RepoDigests}}'

Nginx 也可以用相同方法记录。恢复时将 image 写成对应的 仓库@sha256:...;旧镜像无法拉取时,也可以通过 docker image save 和 docker image load 保存、导入,注意新旧机器的 CPU 架构。

证书申请

先申请 mail.f0rget.me 的邮件证书,供 SMTP 和 IMAP 使用。网页端的证书在部署网页时再申请。

证书使用 Let’s Encrypt,由宿主机上的 Certbot 管理。验证方式选 DNS-01:Certbot 调用 Cloudflare API,临时写入 _acme-challenge TXT 记录,验证域名后签发证书。这个过程不占用服务器的 80 端口。Cloudflare 插件说明

DMS 的 SSL_TYPE=letsencrypt 用来读取已有证书,申请和续期仍然交给宿主机 Certbot,再把证书目录挂载到容器中。DMS TLS 配置

安装 Certbot

这里使用 Snap 版 Certbot,安装时的版本是 5.8.0。已有 Certbot 的机器先确认安装来源,避免和系统包中的版本混用:

command -v certbot
certbot --version
snap list certbot
# 新机没有 Snap 时,先通过系统包管理安装 snapd
# apt-get update
# apt-get install -y snapd
snap install --classic certbot
snap set certbot trust-plugin-with-root=ok
snap install certbot-dns-cloudflare
snap connect certbot:plugin certbot-dns-cloudflare
/snap/bin/certbot plugins

后面统一使用 /snap/bin/certbot。安装后查看插件列表,能看到 dns-cloudflare 即可

Cloudflare API Token

在 Cloudflare 创建 API Token,只授权所需域名区域的 DNS 编辑权限,区域读取权限,创建后记录 token

chrome_0HxNMAlk0x.webp

然后保存凭据文件:

install -m 600 /dev/null /opt/docker-mailserver/secrets/cloudflare.ini
nano /opt/docker-mailserver/secrets/cloudflare.ini

文件内容:

dns_cloudflare_api_token = 在此填写实际Token
chown root:root /opt/docker-mailserver/secrets/cloudflare.ini
chmod 600 /opt/docker-mailserver/secrets/cloudflare.ini
stat -c '%U:%G %a %n' /opt/docker-mailserver/secrets/cloudflare.ini

这个文件包含 API Token,保留为 root 所有、权限 600。

申请证书

/snap/bin/certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /opt/docker-mailserver/secrets/cloudflare.ini \
  --dns-cloudflare-propagation-seconds 30 \
  --cert-name mail.f0rget.me -d mail.f0rget.me

/snap/bin/certbot certificates
ls -l /etc/letsencrypt/live/mail.f0rget.me/

按提示填写通知邮箱并接受服务条款。首次搭建可以先填一个已有的外部邮箱。这里将 DNS 等待时间设为 30 秒,申请成功后查看邮件证书的路径和有效期。

live 下的 fullchain.pem、privkey.pem 通常是指向 archive 的符号链接,所以挂载时使用整个 /etc/letsencrypt:ro,备份也保存整个目录。Certbot 说明

WindowsTerminal_INg7F7bBu0

Docker Mailserver 部署

mailserver.env

保存为 /opt/docker-mailserver/mailserver.env:

OVERRIDE_HOSTNAME=mail.f0rget.me

ACCOUNT_PROVISIONER=FILE

ENABLE_IMAP=1
ENABLE_POP3=0

ENABLE_CLAMAV=0
ENABLE_RSPAMD=1
ENABLE_OPENDKIM=0
ENABLE_OPENDMARC=1
ENABLE_POLICYD_SPF=0

ENABLE_FAIL2BAN=1

ENABLE_AMAVIS=0

SSL_TYPE=letsencrypt

[email protected]

SPOOF_PROTECTION=1

DMS_VMAIL_UID=5000
DMS_VMAIL_GID=5000

LOG_LEVEL=info

参数说明如下:

变量 说明
OVERRIDE_HOSTNAME=mail.f0rget.me 显式指定邮件主机 FQDN,与 Compose hostname、证书和 PTR 对齐;不等于邮箱只能用 @mail.f0rget.me
ACCOUNT_PROVISIONER=FILE 通过持久化文件提供账号和别名,适合管理员创建账号的方式
ENABLE_IMAP=1 开启 IMAP,网页和桌面客户端读取邮箱需要它
ENABLE_POP3=0 不使用 POP3,本文不开放 POP3 端口
ENABLE_CLAMAV=0 关闭 ClamAV 病毒扫描,减轻资源负担;Rspamd 反垃圾不等于 ClamAV 病毒扫描
ENABLE_RSPAMD=1 开启 Rspamd,参与反垃圾与邮件身份检查等处理
ENABLE_OPENDKIM=0 关闭独立 OpenDKIM 进程;本次实际由 Rspamd 签名,不表示没有 DKIM
ENABLE_OPENDMARC=1 保留实际启用的 OpenDMARC;它与 Rspamd 的 DMARC 功能重叠,详见下文
ENABLE_POLICYD_SPF=0 关闭独立 policyd-spf;不意味着删除域名 SPF TXT,也不意味着 Rspamd 不检查 SPF
ENABLE_FAIL2BAN=1 开启 Fail2Ban,Compose 还需要 NET_ADMIN;需要验证封禁规则是否实际生效
ENABLE_AMAVIS=0 关闭 Amavis,沿用本次 Rspamd 路线
SSL_TYPE=letsencrypt 使用宿主机已有的 Let’s Encrypt 证书,需要挂载到容器可见路径
[email protected] 指定管理员地址,不会自动建立可登录账号或别名,需要单独配置收件去向
SPOOF_PROTECTION=1 限制已登录用户使用未经授权的发件地址;合法别名也要按账号关系配置
DMS_VMAIL_UID=5000 邮件存储的数值 UID;保持实际配置值 5000
DMS_VMAIL_GID=5000 邮件存储的数值 GID;保持实际配置值 5000
LOG_LEVEL=info DMS 启动与配置脚本输出一般信息;不等于所有内部服务都采用相同日志详细度

参数说明参考 环境变量文档和 v16.0.1 配置样本。没有写出的变量沿用镜像默认值或已有自定义配置。

Compose 配置

在 /opt/docker-mailserver 下创建 compose.yaml,内容如下:

services:

  mailserver:
    image: ghcr.io/docker-mailserver/docker-mailserver:latest

    container_name: docker-mailserver

    hostname: mail.f0rget.me

    env_file:
      - mailserver.env

    ports:
      - "25:25"
      - "465:465"
      - "587:587"
      - "993:993"

    volumes:
      - ./docker-data/dms/mail-data/:/var/mail/
      - ./docker-data/dms/mail-state/:/var/mail-state/
      - ./docker-data/dms/mail-logs/:/var/log/mail/
      - ./docker-data/dms/config/:/tmp/docker-mailserver/
      - /etc/letsencrypt:/etc/letsencrypt:ro

      - /etc/localtime:/etc/localtime:ro

    cap_add:
      - NET_ADMIN

    restart: unless-stopped

启动与首个账号

下面通过绝对路径指定 Compose 文件,从 /root 或其他目录执行都可以:

docker compose -f /opt/docker-mailserver/compose.yaml config -q
docker compose -f /opt/docker-mailserver/compose.yaml up -d

首次启动时 Dovecot 会等待创建邮箱,接着在容器中添加账号:

docker exec -it docker-mailserver setup email add [email protected]

按提示输入并确认密码,已有账号则跳过添加。首次无账号的等待有超时限制,应接着完成创建,然后查看启动日志和账号列表:

docker logs --tail 100 docker-mailserver
docker exec -it docker-mailserver setup email list

setup 是 DMS 容器内的管理命令,前面的 docker exec 用于进入指定容器执行。账号管理说明

WindowsTerminal_iMzugfruDi.webp

DKIM 与 DMARC

DKIM

首次部署时在容器中生成密钥:

docker exec -it docker-mailserver setup config dkim

已有部署或迁移时保留原密钥,跳过重新生成。命令会输出要添加的 TXT 名称和公钥。本次域为 f0rget.me,selector 为 mail,在 Cloudflare 中填写:

类型 名称 内容
TXT mail._domainkey 命令输出的完整 v=DKIM1; k=rsa; p=… 公钥记录

这里的省略号只是说明格式,不能原样填入 DNS。复制本机命令输出的完整内容;名称补全后应是 mail._domainkey.f0rget.me。私钥保留在服务器中,不上传到 DNS。

这里关闭 OpenDKIM 后仍然有 DKIM 签名,因为签名由 Rspamd 完成。上面的命令执行后,输出来自 rspamd-dkim,本次生成的文件是:

/tmp/docker-mailserver/rspamd/dkim/rsa-2048-mail-f0rget.me.private.txt
/tmp/docker-mailserver/rspamd/override.d/dkim_signing.conf

这是容器路径,对应宿主机:

/opt/docker-mailserver/docker-data/dms/config/rspamd/dkim/
/opt/docker-mailserver/docker-data/dms/config/rspamd/override.d/dkim_signing.conf

备份时带上 config/rspamd 即可保存这部分配置和密钥。测试邮件在 Gmail 的邮件头中显示过 dkim=pass,selector 是 mail。

配置中还保留了 ENABLE_OPENDMARC=1,因此会同时运行 OpenDMARC 和 Rspamd。启动日志对此有一条提示,官方建议使用 Rspamd 时关闭对应的旧服务。Rspamd 配置说明

这里保留实际配置 ENABLE_OPENDMARC=1。如后续改为仅使用 Rspamd,可单独调整为 0,再验证收发和 DMARC 检查。

添加后查询并检查配置:

dig +short TXT mail._domainkey.f0rget.me
docker exec docker-mailserver cat /tmp/docker-mailserver/rspamd/override.d/dkim_signing.conf
docker exec docker-mailserver rspamadm configtest

公钥较长时,dig 可能将同一条 TXT 显示成相邻的两段字符串,这是正常的。本次配置中的签名域为 f0rget.me,selector 为 mail,与 DNS 名称对应。

DMARC

DMARC 不生成私钥,它是一条 DNS 策略记录。配置好 SPF 和 DKIM 后,再添加本次使用的记录:

类型 名称 内容
TXT _dmarc v=DMARC1; p=none; rua=mailto:[email protected]

p=none 用于观察,不要求接收方因 DMARC 失败而隔离或拒收;rua 指定汇总报告地址,这里使用已经创建的邮箱。

DMARC 检查用户看见的 From: 域:SPF 或 DKIM 至少一项通过,并且相应验证域与 From 域对齐,才满足 DMARC 验证。因此 SPF 通过也不一定意味着可见发件地址已经验证。机制说明

dig +short TXT _dmarc.f0rget.me

客户端与收发测试

账号管理

# 增加新账号,交互输入密码
docker exec -it docker-mailserver setup email add [email protected]
# 修改已有账号密码
docker exec -it docker-mailserver setup email update [email protected]
# 查看账号
docker exec -it docker-mailserver setup email list
# 查看别名
docker exec -it docker-mailserver setup alias list
# 给管理员地址配置收件去向:若已经存在,先检查,不重复添加
docker exec -it docker-mailserver setup alias add [email protected] [email protected]

账号保存在持久化配置目录中的 postfix-accounts.cf,别名在 postfix-virtual.cf。上面的别名将发给 [email protected] 的邮件交给 [email protected],已有别名时跳过添加。

客户端设置

项目 设置
登录用户名 [email protected],完整邮箱地址
接收服务器 mail.f0rget.me
接收端口 993,SSL/TLS
发送服务器 mail.f0rget.me
发送端口 587,STARTTLS;或 465,SSL/TLS
SMTP 验证 开启,使用邮箱账号密码

我这里以 Gmail 为例子

接收配置:

image-20261016225348051.png

发送配置:

image-20261016225428896.png

收发测试

在 Thunderbird/Gmail 等客户端中配置上述账号。从外部邮箱给 [email protected] 发一封信,确认可以收到,再从客户端回复,检查外部邮箱是否收到。

接着查看接收方原始邮件头中的 Authentication-Results,确认 SPF、DKIM 和 DMARC 的结果。这里还没有部署网页端,收发测试直接通过邮件客户端完成。

docker exec docker-mailserver postqueue -p
docker logs --since 30m docker-mailserver 2>&1 \
  | grep -Ei 'status=(sent|deferred|bounced)|sasl|authentication failed|warning|error' \
  | tail -100
docker exec docker-mailserver setup fail2ban status
docker exec docker-mailserver postconf smtpd_relay_restrictions

客户端通过邮箱账号认证发信,保持现有中继限制即可。下面就是测试通过的

image-20261016231309701.png

网页邮箱(可选)

客户端能够正常收发后,邮件服务就可以使用了。需要网页入口时,再部署 Roundcube 和 Nginx。

网页域名与证书

在 Cloudflare 添加网页入口,下面仍使用公网 IP 占位:

类型 名称 内容
A webmail 203.0.113.10

这里按仅 DNS 配置,然后申请网页证书:

/snap/bin/certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /opt/docker-mailserver/secrets/cloudflare.ini \
  --dns-cloudflare-propagation-seconds 30 \
  --cert-name webmail.f0rget.me -d webmail.f0rget.me
/snap/bin/certbot certificates

Roundcube

创建共享网络一次即可:

docker network inspect webmail-net >/dev/null 2>&1 || docker network create webmail-net
docker network inspect webmail-net --format '{{.Name}} {{.Driver}}'

在 /opt/roundcube 下创建 compose.yaml,并将 Roundcube 加入 webmail-net:

services:
  roundcube:
    image: roundcube/roundcubemail:latest
    container_name: roundcube
    restart: unless-stopped
    environment:
      ROUNDCUBEMAIL_DB_TYPE: sqlite
      ROUNDCUBEMAIL_DB_DIR: /var/roundcube/db
      ROUNDCUBEMAIL_DEFAULT_HOST: ssl://mail.f0rget.me
      ROUNDCUBEMAIL_DEFAULT_PORT: 993
      ROUNDCUBEMAIL_SMTP_SERVER: tls://mail.f0rget.me
      ROUNDCUBEMAIL_SMTP_PORT: 587
      ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE: 20M
    volumes:
      - ./www:/var/www/html
      - ./db:/var/roundcube/db
    networks:
      - webmail-net

networks:
  webmail-net:
    external: true

ssl:// 表示连接开始就使用 TLS,tls:// 表示 STARTTLS,因此这里分别搭配 993 和 587。20M 只设置网页上传限制,邮件能发送多大还要看 Postfix 和收件方的上限,附件编码后也会变大。参数说明

docker compose -f /opt/roundcube/compose.yaml config -q
docker compose -f /opt/roundcube/compose.yaml up -d
docker logs --tail 80 roundcube

启动时会在 www 写入应用文件,在 db 创建 SQLite 数据库。容器没有 ports 映射,网页等 Nginx 配置完成后再访问。后续迁移将这两个目录一起带走。

Nginx 部署

使用 nginx:stable-alpine 镜像,配置目录挂载为 ./nginx/conf.d。

Compose 配置

保存 /opt/webmail-proxy/compose.yaml:

services:
  nginx:
    image: nginx:stable-alpine
    container_name: webmail-nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - /etc/letsencrypt:/etc/letsencrypt:ro
    networks:
      - webmail-net

networks:
  webmail-net:
    external: true

反向代理配置

保存 /opt/webmail-proxy/nginx/conf.d/webmail.conf:

server {
    listen 80;
    server_name webmail.f0rget.me;

    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name webmail.f0rget.me;

    ssl_certificate /etc/letsencrypt/live/webmail.f0rget.me/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/webmail.f0rget.me/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;

    client_max_body_size 20M;

    location / {
        proxy_pass http://roundcube:80;
        proxy_http_version 1.1;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-Port 443;

        proxy_read_timeout 300s;
    }
}

保存后启动 Nginx,检查配置和网页访问:

docker compose -f /opt/webmail-proxy/compose.yaml config -q
docker compose -f /opt/webmail-proxy/compose.yaml up -d
docker exec webmail-nginx nginx -t
curl -I http://webmail.f0rget.me
curl -I https://webmail.f0rget.me

访问 HTTP 会跳转到 HTTPS,随后进入 Roundcube 登录页。这次测试 HTTPS 返回了 HTTP/1.1 200 OK,接下来使用邮箱账号登录,测试收发即可。

chrome_MBnqkQTCAv.webp

chrome_s990PNnGTg.webp

证书续期

certificate-lifecycle.png

Certbot 续期后会更新磁盘上的证书,还需要让 Nginx 和 DMS 重新加载。这里用 deploy hook,证书更新成功后自动执行相应命令。

这次两张证书都通过了 certbot renew --dry-run --run-deploy-hooks 测试。输出里出现过 Nginx 的 notice 和 Docker 的 Restarting/Started 信息,虽然写在 stderr,最终模拟续期仍然成功。

续期脚本

创建 /etc/letsencrypt/renewal-hooks/deploy/restart-mailserver.sh,内容按实际脚本保留:

#!/bin/sh
set -eu

# 只处理邮件服务器的证书
case " ${RENEWED_DOMAINS:-} " in
  *" mail.f0rget.me "*) ;;
  *) exit 0 ;;
esac

# 证书续期成功后,重启邮件服务以加载新证书
/usr/bin/docker compose \
  -f /opt/docker-mailserver/compose.yaml \
  restart mailserver

这个脚本根据 RENEWED_DOMAINS 判断续期证书是否包含 mail.f0rget.me,匹配后重启 DMS。

部署了网页端时,再创建 /etc/letsencrypt/renewal-hooks/deploy/reload-webmail-nginx.sh:

#!/bin/sh

if docker ps --format '{{.Names}}' | grep -qx webmail-nginx; then
    docker exec webmail-nginx nginx -s reload
fi

网页脚本不区分是哪张证书续期,只要 webmail-nginx 正在运行就 reload。所以邮件证书续期时,也可能看到 Nginx 的重载输出,这是当前脚本的行为。Certbot hook 说明

保存后赋予执行权限,并检查语法:

chmod 700 /etc/letsencrypt/renewal-hooks/deploy/restart-mailserver.sh
sh -n /etc/letsencrypt/renewal-hooks/deploy/restart-mailserver.sh
# 部署了网页端时再执行下面两条
chmod 700 /etc/letsencrypt/renewal-hooks/deploy/reload-webmail-nginx.sh
sh -n /etc/letsencrypt/renewal-hooks/deploy/reload-webmail-nginx.sh

续期测试

snap services certbot
/snap/bin/certbot renew --dry-run --run-deploy-hooks
docker inspect --format '{{.State.Status}}' docker-mailserver

只部署 DMS 时,续期邮件证书并执行邮件脚本即可。部署网页端后再检查:

docker exec webmail-nginx nginx -t
curl -I https://webmail.f0rget.me

snap services certbot 显示 enabled inactive timer-activated,表示等待定时触发,不需要再添加一份 cron。测试中的 --run-deploy-hooks 会实际执行脚本,因此会短暂重启 DMS。

WindowsTerminal_nTGRSMmMsF

证书检查

openssl s_client -connect mail.f0rget.me:993 \
  -servername mail.f0rget.me -verify_hostname mail.f0rget.me \
  -verify_return_error </dev/null

openssl s_client -starttls smtp -connect mail.f0rget.me:587 \
  -servername mail.f0rget.me -verify_hostname mail.f0rget.me \
  -verify_return_error </dev/null

openssl s_client -connect mail.f0rget.me:465 \
  -servername mail.f0rget.me -verify_hostname mail.f0rget.me \
  -verify_return_error </dev/null

# 部署网页端后再检查 443
openssl s_client -connect webmail.f0rget.me:443 \
  -servername webmail.f0rget.me -verify_hostname webmail.f0rget.me \
  -verify_return_error </dev/null

查看输出中的证书域名、有效期和验证结果。这里的 TLS 用于连接加密,邮件正文的端到端加密需要另外配置。

备份与迁移

migration-map.png

备份目录

完整部署备份下面四个目录。只部署 DMS 时,保存 /opt/docker-mailserver 和 /etc/letsencrypt 即可,下面涉及 Roundcube、Nginx 的命令也跳过。

目录 内容
/opt/docker-mailserver Compose、env、邮件、账号、别名、队列、Rspamd 配置和 DKIM 私钥、Cloudflare 凭据
/opt/roundcube Compose、应用配置、SQLite 数据库、联系人和偏好
/opt/webmail-proxy Nginx Compose 和反向代理配置
/etc/letsencrypt 证书、私钥、ACME 账号、续期配置和脚本

如果另有挂载目录或 Docker 数据卷,也一并备份。/etc/letsencrypt/live 中的证书是符号链接,实际文件位于 archive,因此保存整个 /etc/letsencrypt。

打包备份

备份前暂时停止服务,避免复制过程中邮件队列或 SQLite 数据库仍在写入。下面操作会短暂停止收发和网页访问。

umask 077
BACKUP_FILE="/root/mail-stack-$(date +%F-%H%M%S).tar.gz"

# 暂停证书续期和容器
snap stop certbot.renew
docker compose -f /opt/webmail-proxy/compose.yaml stop
docker compose -f /opt/roundcube/compose.yaml stop
docker compose -f /opt/docker-mailserver/compose.yaml stop

# 保存四个目录,保留数值所有权及文件权限
tar --acls --xattrs --numeric-owner -czpf "$BACKUP_FILE" \
  -C / opt/docker-mailserver opt/roundcube opt/webmail-proxy etc/letsencrypt
sha256sum "$BACKUP_FILE" > "$BACKUP_FILE.sha256"

# 日常备份完成后启动服务
docker compose -f /opt/docker-mailserver/compose.yaml up -d
docker compose -f /opt/roundcube/compose.yaml up -d
docker compose -f /opt/webmail-proxy/compose.yaml up -d
snap start certbot.renew

打包成功后将备份传到安全位置,备份包含邮件、账号和私钥,离机保存时加密。打包失败也要恢复服务;如果接下来直接迁移,最后一次备份后旧机保持停止。

新服务器恢复

新机先安装 Docker、Compose、Snap Certbot 和 Cloudflare 插件,确认 TCP 25 可出入站、PTR 可设置。镜像尽量沿用旧机版本。

将备份包和校验文件传到新机。在尚未部署邮箱的新服务器上执行,文件名替换为实际名称:

cd /root
sha256sum -c mail-stack-YYYY-MM-DD-HHMMSS.tar.gz.sha256
tar --acls --xattrs --numeric-owner \
  -xzpf mail-stack-YYYY-MM-DD-HHMMSS.tar.gz -C /

docker network create webmail-net
docker compose -f /opt/docker-mailserver/compose.yaml up -d
docker compose -f /opt/roundcube/compose.yaml up -d
docker compose -f /opt/webmail-proxy/compose.yaml up -d

webmail-net 已存在时跳过创建。解包时保留原数值所有权,env 保留原值;不需要重新创建邮箱或生成 DKIM 密钥。证书域名不变且仍在有效期内时,可以继续使用。

DNS 切换

迁移前可提前降低 DNS TTL,等待原 TTL 过期。恢复完成后修改以下记录:

项目 操作
mail、webmail 的 A / AAAA 改为新服务器对应地址
PTR 在新 VPS 商家后台设置为 mail.f0rget.me
MX 主机名不变时保持原记录
SPF 当前 v=spf1 mx -all 随 MX 地址变化;若写了固定 IP,则更新 IP
DKIM、DMARC 保留原密钥、域名和报告地址时,无需更改

最后验证网页登录、旧邮件和联系人、外部双向收发,再测试自动续期:

/snap/bin/certbot renew --dry-run --run-deploy-hooks
snap services certbot

DNS 缓存更新期间,部分发件服务器仍可能连接旧 IP。旧机保持停止,等待对方重试,并保留数据一段时间。若新机已经收到新邮件,回滚前先处理新增邮件和队列,避免数据分散。

参考资料

版权声明:除特殊说明,博客文章均为 Shule 原创,依据 CC BY-SA 4.0 许可证进行授权,转载请附上出处链接及本声明。
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇