自建邮箱全攻略:LanQin Email + Docker + Cloudflare Tunnel(Web 走隧道,邮件直连)
为什么写这篇
想自建一个邮箱,告别大厂邮箱的广告和限速。选了 LanQin Email:Go + React 全栈,单 Docker 容器集成 API、Web、Nginx、Postfix、Dovecot、Rspamd,默认 SQLite,开箱即用。
但我的服务器情况有点特殊:80/443 端口已经被宿主机上的 nginx 占了,LanQin 官方 compose 默认映射 80/443,直接 up 会报端口冲突。于是方案定为:
- Web 页面:走 Cloudflare Tunnel(cloudflared 跑在同一个 compose 里),不占用宿主机 80/443
- 邮件端口:25 / 465 / 587 / 993 / 995 直接从 VPS 公网映射出去
用户浏览器 ──HTTPS──▶ Cloudflare 边缘 ──隧道──▶ cloudflared ──HTTP──▶ lanqin-email:80(容器内 Nginx→API)
↕
邮件客户端 ──直连──▶ VPS 公网 IP: 25 / 465 / 587 / 993 / 995(容器内 Postfix / Dovecot / API)
一个关键架构决策:必须用两个主机名
这是全文最重要的一个坑,先讲清楚:
mail.example.com→ 走 Cloudflare Tunnel,只承载 Web 页面mx.example.com→ DNS-only 的 A 记录指向 VPS 公网 IP,承载所有邮件协议 + MX 记录
为什么不能共用一个? Tunnel 建好后,mail.example.com 会变成一条指向 cfargotunnel.com 的 CNAME,解析到 Cloudflare 边缘节点。HTTP 流量没问题,但 25 端口打过去落到的是 CF 边缘——它不收 SMTP,收件会直接失败。所以邮件必须用独立的直连主机名。
对应到配置里:
LANQIN_PUBLIC_HOSTNAME=mx.example.com # 邮件主机名(Postfix HELO、DNS 展示用)
LANQIN_PUBLIC_BASE_URL=https://mail.example.com # Web 访问地址(走隧道)
前提
- 一台 VPS,Docker + Compose v2 已装,有 root 权限
- 一个域名,已托管在 Cloudflare(本文用
example.com占位,请替换成你自己的) - 云控制台安全组放行入方向 TCP:25、465、587、993、995(Web 走隧道,80/443 不用从公网放行)
- 确认云厂商没封 25 端口出方向:
timeout 5 bash -c '</dev/tcp/gmail-smtp-in.l.google.com/25' && echo OK
第 1 步:拉代码、建配置文件
cd ~
git clone https://github.com/LanQin996/LanQin-Email.git
cd ~/LanQin-Email/deploy
cp .env.example .env
chmod 600 .env
第 2 步:改 .env
nano ~/LanQin-Email/deploy/.env
至少改这 5 行(example.com 全换成你的真实域名):
LANQIN_PUBLIC_HOSTNAME=mx.example.com
LANQIN_PUBLIC_BASE_URL=https://mail.example.com
LANQIN_ADMIN_EMAIL=[email protected]
LANQIN_ADMIN_PASSWORD=换成强密码
LANQIN_TRUSTED_PROXY_COUNT=1
注意 LANQIN_TRUSTED_PROXY_COUNT=1:这是上游文档定的值(compose 自带一层 Nginx 反代即设为 1)。Cloudflare 边缘会在 X-Forwarded-For 里追加客户端 IP,cloudflared 只是原样透传、不再追加,所以 API 看到的仍然是“客户端 IP, cloudflared 容器 IP”,count=1 正好取到真实 IP。设成 2 会取过头,后台审计日志里看到的全是内网 IP。
第 3 步:改 docker-compose.yml
nano ~/LanQin-Email/deploy/docker-compose.yml
① ports:Web 只留 8081 给本机健康检查用(443 映射删掉,80/443 本来就被占了),邮件 5 端口直接映射:
ports:
- "8081:80"
- "25:25"
- "465:465"
- "587:587"
- "993:993"
- "995:995"
② volumes / healthcheck / restart 必须缩进在 lanqin-email 服务下面,别挂到 cloudflared 下面——我实测踩过这个坑:挂错的话 lanqin-email 没挂载数据卷(SQLite 和邮件重建就丢),healthcheck 还会在 cloudflared 容器里永远失败。
③ 在 services: 下追加 cloudflared 服务(Token 稍后从 CF 控制台复制):
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared-lanqin
command: tunnel --no-autoupdate run --token 你的TunnelToken粘这里
restart: unless-stopped
depends_on:
- lanqin-email
注意 --token 后面带一个空格再跟 Token。我当时复制时把空格一起删掉了,变成 run --eyJh...,cloudflared 把它当未知参数,直接打印 help 退出,查了半天。
第 4 步:在 Cloudflare 建隧道
- 进
one.dash.cloudflare.com→ Networks → Tunnels → Create a tunnel → Cloudflared,起个名(如lanqin-email),复制 Token,填进第 3 步 compose 文件的command行(替换掉中文占位符) - 隧道配置页 → Public hostnames → Add public hostname:Subdomain 填
mail,Domain 选你的域名;Service Type 选HTTP,URL 填lanqin-email:80(同一 compose 网络内直接用服务名解析) - Save。Cloudflare 会自动建好
mail.example.com的 CNAME,不用手动加 DNS - 给邮箱单独建一条隧道,别复用别的项目的 Token
第 5 步:启动
cd ~/LanQin-Email/deploy
docker compose pull
docker compose up -d
docker compose logs -f lanqin-email # 看主容器启动,api/dovecot/rspamd/postfix/nginx 都 RUNNING 后 Ctrl+C
docker compose logs -f cloudflared # 看到 "Registered tunnel connection" = 隧道通了
docker compose ps # lanqin-email 状态变 healthy(约 1~2 分钟)
pull 报 unauthorized 的话:去 GitHub 把作者 lanqin996 的 Package 可见性设为 Public,或先 docker login ghcr.io 再 pull。
第 6 步:验证
ss -tlnp | grep -E ':(25|465|587|993|995) ' # 5 个邮件端口都有 LISTEN = 正常
curl -s http://127.0.0.1:8081/readyz # 返回 ok:true = 各组件正常
浏览器打开 https://mail.example.com,看到 LanQin 登录页 = 隧道 + Web 全通。
第 7 步:给邮件端口配真实证书
465 / 587 / 993 / 995 不走隧道,容器必须自己有公网客户端信任的证书,否则第三方客户端会报证书是 localhost。注意 Cloudflare Origin Certificate 不行——邮件客户端不认它。80 端口被占着,用 DNS-01 方式申请 Let’s Encrypt:
apt update && apt install -y certbot python3-certbot-dns-cloudflare
mkdir -p /root/.secrets && chmod 700 /root/.secrets
去 Cloudflare 后台 → 右上角头像 → My Profile → API Tokens → Create Token,用 Edit zone DNS 模板,Zone Resources 选你的域名,生成 Token(只显示一次,复制好):
nano /root/.secrets/cloudflare.ini
dns_cloudflare_api_token = 粘你的Token
chmod 600 /root/.secrets/cloudflare.ini
certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
-d mail.example.com -d mx.example.com \
--non-interactive --agree-tos -m [email protected]
证书会落在 /etc/letsencrypt/live/mail.example.com/。两个域名都要加进证书:客户端连的是 mx.example.com,只含 mail.example.com 会报域名不匹配。
挂进容器。docker-compose.yml 的 volumes 段取消注释并改成(容器内路径必须是 /certs,和 .env 里对应;直接取消原注释会挂到 /etc/letsencrypt,容器内就没有 /certs,API 会回退到 snakeoil 自签证书):
- /etc/letsencrypt:/certs:ro
.env 加四行(四个一起配,只配证书不加后两行、465/587 的提交监听可能起不来):
LANQIN_TLS_CERT_FILE=/certs/live/mail.example.com/fullchain.pem
LANQIN_TLS_KEY_FILE=/certs/live/mail.example.com/privkey.pem
LANQIN_SUBMISSION_ADDR=:587
LANQIN_SUBMISSION_TLS_ADDR=:465
cd ~/LanQin-Email/deploy
docker compose up -d --force-recreate
sleep 30
docker compose exec lanqin-email ls /certs/live/mail.example.com/
echo | openssl s_client -connect 127.0.0.1:465 2>/dev/null | openssl x509 -noout -subject -issuer
subject=CN = mail.example.com、issuer 是 Let’s Encrypt,且日志里不再出现 snakeoil / submission disabled = 证书生效。
自动续期(证书 90 天过期,certbot 自带续期定时器;加个 hook 让续期后重启容器):
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
nano /etc/letsencrypt/renewal-hooks/deploy/restart-lanqin.sh
#!/bin/bash
cd /root/LanQin-Email/deploy && docker compose restart lanqin-email
chmod +x /etc/letsencrypt/renewal-hooks/deploy/restart-lanqin.sh
第 8 步:后台加域名、配 DNS
https://mail.example.com/admin?section=domains
- 点添加域名,输入
example.com - 点进域名,先点 DKIM 生成密钥
- 点 DNS 按钮,面板会列出每条记录的确切值。去 Cloudflare 的 DNS 页照抄(
mx那条 A 记录必须是灰云 DNS-only,别开橙云):
A 记录:mx.example.com → 你的 VPS 公网 IP(灰云)
MX 记录:名称 example.com,值 mx.example.com,优先级 10(优先级填到“优先级”框,别把数字写进主机名框)
SPF:名称 example.com,TXT 值 v=spf1 mx -all
HELO SPF:名称 mx.example.com,TXT 值 v=spf1 a -all
DKIM:名称类似 lanqin._domainkey.example.com(以面板为准),TXT 值 v=DKIM1; k=rsa; p=…(完整粘贴)
DMARC:名称 _dmarc.example.com,TXT 值粘面板给的
- 等 3~5 分钟 DNS 生效,点面板里的检测,全绿再往下
有条件的话,去 VPS 商家后台把公网 IP 的 PTR 反向解析设为 mx.example.com,对发信信誉加分很大(见第 10 步)。
第 9 步:建邮箱、实测收发
先理解 LanQin 的账号模型(我在这儿翻过源码才搞明白):登录只查 users 表,mailboxes 表里的邮箱不能单独登录。新建的邮箱都有个“归属用户”,谁是归属用户,谁就用自己的账号密码登录,进 Webmail 后左上角多邮箱切换到新邮箱。想让别人单独用:先在“用户管理”建用户(设密码),再把邮箱的归属用户改成他。
- 管理后台 → 邮箱账号 → 新建测试邮箱(如
[email protected],归属用户选你自己) - 后台 SMTP 标签页 → 测试发送,发到你的 Gmail/QQ(用内置 Postfix 的话中继用户名/密码留空)
- Webmail 切到
[email protected]给 Gmail 发一封,再从 Gmail 回一封,确认双向都通 - 第三方客户端(手机/Thunderbird):IMAP 服务器
mx.example.com:993(SSL),SMTPmx.example.com:465(SSL)或587(STARTTLS),用户名填完整邮箱地址,密码填创建邮箱时设的邮箱密码 - 手机登一次 Webmail,回后台看审计日志里的登录 IP:是真实公网 IP 说明
TRUSTED_PROXY_COUNT=1生效;是 172.x 开头说明配错了
第 10 步:测分
用测试邮箱给 mail-tester.com 显示的随机地址发一封,看报告。我的首次成绩 8.9/10,唯一扣分是 RDNS_NONE -1.274(PTR 没设);在 VPS 商家后台把 PTR 设为 mx.example.com 后复测 10/10。
另外去 mxtoolbox.com 查下你的 VPS IP 在不在黑名单里。
收尾:备份与日常命令
定期备份(data 是 SQLite 库,mail 是所有邮件,dkim 是签名私钥):
cd ~/LanQin-Email/deploy
tar -czf ~/lanqin-backup-$(date +%F).tgz data mail dkim .env docker-compose.yml
日常:
cd ~/LanQin-Email/deploy
docker compose logs -f lanqin-email # 看日志
docker compose pull && docker compose up -d # 更新版本
docker compose down # 停止
附:快速搬家(换 VPS)
整机迁移就四样东西:deploy/ 目录(SQLite 数据库 + Maildir 邮件 + 配置)、证书、cloudflared。挑个低峰时段操作,旧机 down 到新机 up 之间是停机窗口,对方发来的邮件会在队列里重试,丢不了。
1. 旧 VPS 打包
cd ~/LanQin-Email/deploy && docker compose down
cd ~ && tar -czf lanqin-migrate.tgz LanQin-Email/ /etc/letsencrypt
scp lanqin-migrate.tgz root@新VPS_IP:~/2. 新 VPS 恢复
tar -xzf ~/lanqin-migrate.tgz -C /
cd ~/LanQin-Email/deploy && docker compose up -d证书是打包带过去的,省得重申请;certbot 续期 hook 记得在新机上配好。
3. 收尾
- cloudflared 在新机上用同一个 token启动(
mail.example.com→lanqin-email:80) - DNS 把
mx.example.com的 A 记录改成新 IP(保持灰云/DNS-only) - 新厂商后台把新 IP 的 PTR 设成
mx.example.com - 检查 SPF:如果写死了旧 IP(
ip4:192.0.2.1)就改成新的;DKIM/DMARC 不用动,密钥跟着数据库走
4. 验证
等 DNS 生效后,mail-tester 再跑一遍,确认 10/10。
踩坑总结
- 一个主机名不能既走隧道又做邮件:
mail.example.com被隧道 CNAME 占了,MX 必须指独立的mx.example.com(A 记录灰云直连) TRUSTED_PROXY_COUNT=1不是 2:cloudflared 透传 XFF 不追加,上游文档定值就是 1- 465/587 要四个变量一起配:
TLS_CERT_FILE+TLS_KEY_FILE+SUBMISSION_ADDR=:587+SUBMISSION_TLS_ADDR=:465,缺一不可 - compose 里 volumes 缩进:必须挂在
lanqin-email下,挂错到 cloudflared 会导致数据卷丢失 + healthcheck 永失败 - 证书挂载路径:取消注释时容器内路径改成
/certs,和.env的/certs/live/...对应 - cloudflared 的
--token后面有空格:误删会变成未知参数,容器打 help 退出 - MX 记录填法:Cloudflare 表单里“邮件服务器”只填主机名,优先级填到“优先级”框,别把
10写进主机名 - 邮箱不能单独登录:登录的是用户账号,邮箱靠归属用户 + 多邮箱切换使用