文章介绍用闲置云服务器自建私人DoH的完整方案:nginx按SNI分流并终结TLS,mosdns负责缓存与主备兜底,unbound从根递归,查询内容不经过商业DNS。适合有域名和服务器、重视查询隐私的人,整套内存占用低于200MB,实测单次查询约70毫秒。
公共 DoH 服务解决了「查询不被中间人看到」,但没解决「谁看到了你的查询」。223.5.5.5 也好、1.1.1.1 也好,你的每一次域名解析都完整落在别人的日志里——访问什么网站、几点访问、哪个 App 在后台偷偷解析,一目了然。想彻底拿回来,只能自己搭一台。
这篇是把一台云服务器改成私人 DoH 的完整过程:nginx 在 443 做 SNI 分流、8443 终止 TLS、mosdns 负责缓存和分流、unbound 从根自己递归。全程不用任何第三方 DNS 作为主上游,只有主上游挂了才降级到公共 DoH 兜底。如果你只是想给自己的网络换一个更快的加密 DNS,站点里 DoH/DoT 实测:13 家公共加密 DNS 延迟对比 已经量过了,不用自己搭;只有在意「查询不出门」才值得动手。
这篇适合谁
有一台闲置云服务器(1 核 1G 也能跑)、有自己的域名、愿意花半小时配 nginx 的人。整套东西跑起来占内存不到 200MB,单次查询实测 70ms 往返。
目录导览
一、为什么自己搭 DoH
先把「加密」和「隐私」分开看。DoH 只保证你到 DNS 服务器这一段是加密的,中间人看不到、运营商改不了。但服务器那一端仍然完整知道你在查什么。所以选公共 DoH,等于把「谁都能看」换成「某一家能看」——比明文好,但不彻底。
自己搭之后,链路变成这样:你 → 你的服务器(只记连接元数据,不记域名)→ 自己的递归器 → 根/权威服务器。中间没有任何商业 DNS 参与,查询内容不出你这台机器。
| 维度 | 公共 DoH | 自建 DoH |
|---|---|---|
| 链路加密 | 有 | 有 |
| 查询内容给谁看 | 服务商 | 只有自己的机器 |
| 污染 / 劫持 | 取决于服务商 | 自己递归,不依赖上游 |
| 延迟 | 就近节点,通常更低 | 取决于服务器位置,冷查询 100ms 起 |
| 维护成本 | 零 | 一台机器 + 一张证书 |
二、四层链路,各管一段
别看组件多,每个只干一件事,职责划清楚之后调试很快:
- nginx stream(443):拿到 TLS 握手里的 SNI,判断这是不是自己的域名,然后转给对应后端。这一层不做加解密,只做分流。
- nginx http(8443):真正终止 TLS,只放行一个随机路径,其余一律 404。
- mosdns(8053):缓存 + 主备兜底 + 提供
http_server这个 DoH 入口。 - unbound(5354):真正的递归器,从根开始自己走,不转发给任何人。

为什么 443 不能直接跑 DoH?因为同一台机器上 443 往往已经被 xray、面板、反代占着。nginx 的 stream 模块可以在不解密的情况下按 SNI 把连接分给不同后端,这是唯一能「一个 443 端口同时服务多个 HTTPS 站点」的做法。
顺带解释一个常见疑问:unbound 做不了 DoH 服务端。它从 1.12 起能对外提供 DoT,但没有 HTTP 层,所以 DoH 必须由前面的 nginx(或 dnsdist、caddy)来终结 TLS 和 HTTP/2,unbound 只负责递归这一件事。这也是为什么链路里必须多一个 nginx。
三、准备:机器、域名、证书
1. 机器与系统
实测环境是一台 2 核 4G 的腾讯云轻量,Debian 13,跑完 nginx + mosdns + unbound 内存占用不到 200MB,1 核 1G 同样够用。装基础组件:
apt update
apt install -y nginx unbound curl dnsutils openssl
# mosdns 用官方二进制,别用源里的旧版本
cd /tmp
wget https://github.com/IrineSistiana/mosdns/releases/latest/download/mosdns-linux-amd64.zip
unzip mosdns-linux-amd64.zip -d /usr/local/bin/
chmod +x /usr/local/bin/mosdns
/usr/local/bin/mosdns version
2. 证书:DNS-01 签发,不占 80 端口
DoH 的证书用 acme.sh 走 DNS-01 验证,好处是完全不依赖 80 端口,也能签泛域名。以阿里云 DNS 为例:
curl https://get.acme.sh | sh -s email=you@example.com
export Ali_Key="你的AccessKeyId"
export Ali_Secret="你的AccessKeySecret"
~/.acme.sh/acme.sh --issue --dns dns_ali -d doh.example.com
# 装到 nginx 目录,并让续期后自动 reload
~/.acme.sh/acme.sh --install-cert -d doh.example.com
--key-file /etc/nginx/ssl/doh.example.com.key
--fullchain-file /etc/nginx/ssl/doh.example.com.fullchain.cer
--reloadcmd "systemctl reload nginx"
acme.sh 会自动装一条 cron 做续期检查。默认是每天一次,想更勤可以改成每 6 小时,Let’s Encrypt 有 30 天宽限期,怎么都够:
crontab -l | grep acme
# 46 3,9,15,21 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null
AccessKey 别用主账号
acme.sh 的 DNS-01 需要能改 DNS 记录的密钥,一定建 RAM 子账号、只授 DNS 解析权限。这个密钥会明文存在 ~/.acme.sh/account.conf 里,服务器被拿下就等于域名被拿下。
四、先让 unbound 跑通递归
从下往上装,每一层单独验证,出问题好定位。unbound 只监听回环,绝不对外开 53:
cat > /etc/unbound/unbound.conf.d/doh.conf <<'EOF'
server:
interface: 127.0.0.1
port: 5354
do-ip4: yes
do-ip6: yes
do-udp: yes
do-tcp: yes
num-threads: 2
# 缓存
msg-cache-size: 64m
rrset-cache-size: 128m
cache-min-ttl: 600
cache-max-ttl: 86400
# 关键两项:过期先秒回旧值,后台再刷新
serve-expired: yes
serve-expired-client-timeout: 1800
prefetch: yes
prefetch-key: yes
qname-minimisation: yes
verbosity: 1
log-queries: no
EOF
systemctl restart unbound
dig @127.0.0.1 -p 5354 www.baidu.com +short
能返回 A 记录就说明递归通了。cache-min-ttl: 600 和 serve-expired-client-timeout: 1800 这两行是后面性能调优的核心——前者把 TTL 抬到 10 分钟起步,后者让过期条目先秒回旧值、后台再刷新,把「TTL 到期就得干等」变成「0.1ms 拿到旧值」。
五、mosdns:缓存、兜底和 DoH 入口
mosdns 在这套里干三件事:第一层缓存、主备兜底、把 DoH 入口暴露出来。配置文件 /etc/mosdns/config.yaml:
log:
level: warn
file: ""
plugins:
# 第一层缓存:6.5 万条,过期后还能兜底 1 天
- tag: cache
type: cache
args:
size: 65536
lazy_cache_ttl: 86400
# 主上游:本机递归,不经过任何第三方
- tag: forward_primary
type: forward
args:
concurrent: 1
upstreams:
- addr: "127.0.0.1:5354"
# 备用:只在主上游超时/失败时才启用
- tag: forward_backup
type: forward
args:
concurrent: 1
upstreams:
- addr: "https://dns.alidns.com/dns-query"
bootstrap: 223.5.5.5
enable_pipeline: true
- tag: forward_cn
type: fallback
args:
primary: forward_primary
secondary: forward_backup
threshold: 1500
- tag: main
type: sequence
args:
- exec: $cache
- exec: $forward_cn
# DoH 入口
- tag: http_server
type: http_server
args:
listen: "127.0.0.1:8053"
entries:
- path: /dns-query-请换成你的随机串
exec: main
两个坑,都踩过
① http_server 里 exec 后面跟的是裸 tag(main),不加 $。$ 是 sequence 插件里引用插件的语法,混用会直接启动失败。
② always_standby 默认 false 才对。设成 true 会让备用上游每次都收到查询,等于把隐私又送出去一半,只剩下「主上游先返回」这点好处。
要不要再做国内外分流
这台机器的定位是纯递归:国内国外都交给 unbound 自己走,不依赖任何上游,所以配置里没有 geosite 判断。如果你希望国内走递归、国外走代理,就在 main 的 sequence 里加一条 matches: qname $geosite_cn 拆成两条链路——代价是要维护 geosite 数据,而且国外域名就离开这台机器了。
# 启动服务
cat > /etc/systemd/system/mosdns.service <<'EOF'
[Unit]
Description=mosdns
After=network.target
[Service]
ExecStart=/usr/local/bin/mosdns start -c /etc/mosdns/config.yaml -d /etc/mosdns
Restart=always
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload && systemctl enable --now mosdns
ss -lntp | grep 8053
本机验证 DoH 后端(还没到 nginx 那层):
curl -s -o /dev/null -w "%{http_code}n"
-X POST -H "Content-Type: application/dns-message"
--data-binary @query.bin
http://127.0.0.1:8053/dns-query-你的随机串
六、nginx 两层:443 分流与 TLS 终结
这是整套配置里最容易出错的一段,因为它要同时满足两件事:不抢占已有的 443、又能让 DoH 走标准 HTTPS。

1. stream 层:按 SNI 分流
/etc/nginx/nginx.conf 里确保有 stream 段的 include,然后建 /etc/nginx/stream.conf:
stream {
# 按 SNI 决定后端:自己的域名进 DoH,其余照旧
map $ssl_preread_server_name $backend {
default 127.0.0.1:8443; # 换成你原来的后端(xray / 站点)
doh.example.com 127.0.0.1:8443; # DoH 走这里
}
log_format stream_wl '$remote_addr [$time_local] sni=$ssl_preread_server_name => $backend';
access_log /var/log/nginx/stream443.log stream_wl;
server {
listen 443;
listen [::]:443;
ssl_preread on; # 不解密,只读 SNI
proxy_protocol on; # 把真实客户端 IP 传给后端
proxy_pass $backend;
proxy_connect_timeout 10s;
proxy_timeout 3600s;
}
}
proxy_protocol on 这一行决定了后面能不能拿到真实 IP。开了它,后端 listen 必须写 proxy_protocol,否则 nginx 会解析失败、连接直接 400。
2. http 层:TLS 终结 + 只放行一个路径
路径里的随机串用 openssl rand -hex 8 生成一个就行,十六位十六进制,撞不上,也念不出来。每次改完记得同步到 mosdns 的 http_server 和客户端配置里——三处必须完全一致,差一个字符就是 404。
/etc/nginx/conf.d/doh.conf,完整可用:
server {
listen 127.0.0.1:8443 ssl proxy_protocol;
listen [::1]:8443 ssl proxy_protocol;
server_name doh.example.com;
ssl_certificate /etc/nginx/ssl/doh.example.com.fullchain.cer;
ssl_certificate_key /etc/nginx/ssl/doh.example.com.key;
ssl_protocols TLSv1.3;
add_header Strict-Transport-Security "max-age=31536000" always;
http2 on;
access_log /var/log/nginx/doh.log doh_min;
error_log /var/log/nginx/doh_error.log;
# DoH 请求体极小:GET 无体,POST 最多几百字节
client_max_body_size 8k;
location = /dns-query-你的随机串 {
limit_req zone=doh burst=40 nodelay;
limit_conn dohconn 20;
proxy_pass http://127.0.0.1:8053;
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_request_buffering off;
proxy_buffering off;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
location / {
return 404;
}
}
几个刻意的选择:
location =精确匹配 + 随机串,扫描器枚举/dns-query只会拿到 404ssl_protocols TLSv1.3只留一个版本,握手快、没有降级空间proxy_request_buffering off:DoH 是小包交互,别让 nginx 攒够再转location /直接 404,不返回任何指纹信息
七、限流与隐私:日志别写查询内容
/etc/nginx/conf.d/00-doh-limit.conf,文件名用 00- 保证先加载,limit 的 zone 才能被引用:
limit_req_zone $binary_remote_addr zone=doh:10m rate=30r/s;
limit_conn_zone $binary_remote_addr zone=dohconn:10m;
# 不含 $request —— 避免把 ?dns= 里的查询内容写盘
log_format doh_min "$time_iso8601 $remote_addr $status $body_bytes_sent $request_time";
这个日志格式是全套里最容易被忽略、又最要紧的一行。默认的 combined 格式会记 $request,而 DoH 的 GET 请求把整个 DNS 查询 base64 编码后放在 ?dns= 里——等于把「谁查了什么域名」逐条写进磁盘,还保留 14 天。改成只记 5 个字段之后:
| 记什么 | 能推断出什么 |
|---|---|
| 时间、客户端 IP、状态码、字节数、耗时 | 谁在什么时候用过、用了多久、有没有报错 |
| 不记 request / 不记 body | 查了哪个域名,日志里查不到 |
真实的一行长这样,只有 5 段:
2026-10-30T23:25:46+08:00 203.0.113.10 200 223 0.000
再配上 mosdns log.level: warn(正常查询不输出)和 unbound log-queries: no,三层都不落域名。真要排查某个域名,临时开 log-queries: yes,查完记得关。
八、客户端怎么接进来
各家对「自定义 DoH」的支持差别很大,这一节别照抄,按平台看:
| 平台 | 能不能用 | 怎么做 |
|---|---|---|
| 路由器 / 软路由(mosdns、AdGuard Home、iStoreOS) | 最好用 | 上游直接填 https://doh.example.com/dns-query-随机串,全屋设备自动生效 |
| Windows 11 | 可以,需命令行注册模板 | netsh dns add encryption server=1.1.1.1 dohtemplate=https://doh.example.com/dns-query-随机串 autoupgrade=yes,然后在网络设置里选「加密(仅 DoH)」 |
| iOS / iPadOS | 要描述文件或 App | 系统不开放手填 URL,用 .mobileconfig 描述文件或 DNSecure 这类客户端 |
| Android | 系统级只支持 DoT | 「私人 DNS」填的是域名不是 URL;DoH 只能走 App 或浏览器自带安全 DNS |
| Chrome / Edge | 只认内置列表 | 自定义 DoH 一般填不进去,别在这上面浪费时间 |
最省事的做法是只配路由器一层:家里所有设备(含手机连 Wi-Fi)都自动走你的 DoH,出门再用手机关掉或换回公共服务。这样只需要维护一处配置。
九、验证与排障
1. 用 curl 发一个真实查询
DoH 的报文是二进制,先造一个最小的 A 记录查询(www.baidu.com),再 POST 上去:
printf 'x00x00x01x00x00x01x00x00x00x00x00x00x03wwwx05baidux03comx00x00x01x00x01' > /tmp/q.bin
curl -s -o /tmp/r.bin -w "HTTP %{http_code} %{size_download}B %{time_total}sn"
-X POST -H "Content-Type: application/dns-message"
--data-binary @/tmp/q.bin
"https://doh.example.com/dns-query-你的随机串"
xxd /tmp/r.bin | head -3
返回 HTTP 200、一百来字节、能看到 www.baidu.com 的 CNAME 和 A 记录,就说明整条链路通了。实测一次往返 70ms(含 TLS 握手复用)。
2. 常见错误码对照
| 现象 | 原因 | 怎么改 |
|---|---|---|
| 404 | 路径不匹配,或 SNI 没命中 server_name | 核对 location = 的路径;curl -v 看 SNI 是不是 doh.example.com |
| 400 Bad Request | stream 开了 proxy_protocol on,8443 的 listen 没写 proxy_protocol |
两边必须成对 |
| 502 | mosdns 没起,或 8053 不通 | ss -lntp | grep 8053,看 journalctl |
| 证书告警 | 证书域名与 SNI 不一致 | 证书必须签在 DoH 那个域名上,不是主域名 |
| 日志里客户端 IP 全是 127.0.0.1 | 没配 set_real_ip_from / real_ip_header proxy_protocol |
补上真实 IP 还原 |
| nginx 起不来,报 address in use | stream 和 http 同时抢 443 | 443 只能给 stream,http 挪到 8443 |
十、性能实测:为什么快要先命中缓存
上线之后最容易被质疑的是「为什么比阿里云慢」。先看数据,同一台机器上不同场景差了三个数量级:

几个结论:
- 命中缓存时比公共 DNS 还快:本地缓存 0.087ms,阿里 13.57ms,差两个数量级,因为不用出网。
- 全新域名的冷迭代并不慢:delegation 已缓存的情况下 4~60ms,和公共 DNS 的 40~88ms 持平甚至更快。
- 慢的是长尾:某个权威服务器响应慢,或者某个 NS 不应答要等超时,才会出现几百毫秒。unbound 自报的中位数 38ms、平均 185ms,就是长尾拉高的典型形态。
- 刚启动时最慢:进程刚起、缓存全空,第一个查询要从根开始走,111~1240ms 都正常。这也是为什么要做预热。
所以「感觉慢」的调优顺序应该是:先把 serve-expired-client-timeout 打开(过期先秒回),再把 cache-min-ttl 抬到 600,最后才考虑预热清单。反过来做没有意义——TTL 只有 300~600 秒,凌晨预热一次,早上八点早就过期了。
十一、装完之后
到这一步你有一台只属于自己的加密 DNS:查询内容不出机器、路径不可枚举、限流挡住滥用、日志不落域名。剩下几件事值得接着做:
最后一个提醒:自建 DoH 的隐私收益,完全建立在「日志真的不记」这件事上。配置写对了不等于日志就干净——nginx、mosdns、unbound、journald 四处都得看一遍,系统日志轮转默认只留两周,也不代表内容安全。真要较真,就把日志目录整个挂到 tmpfs 上,重启即清。
- 文章名称:自建 DoH 服务器保护隐私:mosdns + unbound 从零到上线
- 文章永久网址:https://www.997wan.com/self-host-doh-server-mosdns-unbound/
- 本网站的文章部分内容可能来源于网络,仅供大家学习与参考,如有侵权,请联系站长 997笔记 进行删除处理。
- 本站一切资源不代表本站立场,并不代表本站赞同其观点和对其真实性负责。
- 本站一律禁止以任何方式发布或转载任何违法的相关信息,访客发现请向站长举报
- 本站资源大多存储在云盘,如发现链接失效,请联系我们我们会第一时间更新。
- 如无特别声明本文即为原创文章仅代表个人观点,版权归《997笔记》所有,欢迎转载,转载请保留原文链接。