最近试着用了下 Caddy,自动申请证书确实非常方便,就是中文文档少了点。虽然官方文档写得很详细,社区里提问也基本有问必答,奈何都是英文,而 I english was very bad。所以这里记录一些使用过程中遇到的问题,以便以后忘了怎么用的时候,不必再跟 AI 大战三百回合。

安装

直接参考 Cyrus 佬的 docker-caddy 就行。如果需要其他模块,参照仓库内的 Dockerfile 修改一下,自己编译一个镜像。

伪静态

Typecho 是 PHP 驱动的博客程序,但可以启用伪静态以优化 URL,不过需要在 Web 服务器上处理一下。如果是 Nginx 的话,是这么写的:

try_files $uri $uri/ /index.html;

而 Caddy 可以直接通过 php_fastcgi 实现 Nginx 的 try_files 效果,不过根据官方文档对于 php_fastcgi 的解释,其实是这个指令自带了一些默认写法,包括 try_files。例如:

    @indexFiles file {
        try_files {path} {path}/index.php index.php
        try_policy first_exist_fallback
        split_path .php
    }

因此配置一个 Typecho 博客的时候,伪静态就可以直接这么写:

example.com {
    encode zstd gzip
    root * /srv/example.com
    file_server
    php_fastcgi php:9000
    header {
        -Server
        -X-Powered-By
    }
}

但是二级目录就行不通了,得手动写一下 try_files 指令。Nginx 是这么写的:

    location /blog/ {
        if (!-e $request_filename) {
            rewrite ^(.*)$ /blog/index.php$1 last;
        }
    }

Caddy 也差不多。假设你的一级域名博客 example.com 放在了 /srv/example.com 这个文件夹中,而这个文件夹里又有个 blog 文件夹打算放另一个博客,即:

example.com {
    encode zstd gzip
    root * /srv/example.com
    file_server
    php_fastcgi php:9000
    handle /blog/* {
        try_files {path} /blog/index.php?{query}
    }
}    

注意要保证 /blog/index.php?{query} 前面的 blog 和文件夹名称一致。

然后伪静态就能正常工作了。

日志

参考《一行代码快速配置 Caddy 站点日志——复用 Caddy 配置段》,可以实现使用 Caddy 的代码段(Snippet)和占位符,为每个站点更优雅地配置日志。

但有一个问题是, 对于习惯了 Nginx 简短的一行日志的我来说,Caddy 的 JSON 日志并不优雅:

{"level":"info","ts":1753359481.0373497,"logger":"http.log.access.log19","msg":"handled request","request":{"remote_ip":"3.4.5.6","remote_port":"51262","client_ip":"1.2.3.4","proto":"HTTP/2.0","method":"GET","host":"example.com","uri":"/vcb-s","headers":{"Cf-Ipcountry":["US"],"Cdn-Loop":["cloudflare; loops=1"],"Accept":["application/rss+xml, application/rdf+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.8, text/*;q=0.7, application/*;q=0.6"],"X-Forwarded-Proto":["https"],"Cf-Connecting-Ip":["1.2.3.4"],"If-None-Match":["\"1138a-MnD/hvrofjXPGRMPTdAYBESwO4U\""],"If-Modified-Since":["Thu, 24 Jul 2025 04:34:01 +0000"],"Cf-Ray":["96435b8e8b406fd7-IAD"],"X-Forwarded-For":["1.2.3.4"],"User-Agent":["RSStT/2.10.0 RSS Reader (+https://git.io/RSStT)"],"Accept-Encoding":["gzip, br"],"Cf-Visitor":["{\"scheme\":\"https\"}"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"h2","server_name":"example.com"}},"bytes_read":0,"user_id":"","duration":0.926949864,"size":0,"status":304,"resp_headers":{"Via":["1.1 Caddy"],"Access-Control-Allow-Methods":["GET"],"X-Content-Type-Options":["nosniff"],"Date":["Thu, 24 Jul 2025 12:18:01 GMT"],"Alt-Svc":["h3=\":443\"; ma=2592000"],"Access-Control-Allow-Origin":["example.com"],"Cache-Control":["public, max-age=300"],"Content-Type":["application/xml; charset=utf-8"],"Etag":["\"1138a-MnD/hvrofjXPGRMPTdAYBESwO4U\""],"X-Rsshub-Route":["/vcb-s"]}}

至少不是人能看的。

我只是写写博客玩,用不上这么详细的日志。一番搜索,发现 Nginx 的日志样式叫做 Common Log Format (通用日志格式),而 Caddy 有一个模块可以实现这种日志样式。可以在构建镜像的时候自己塞进去:

FROM caddy:builder AS builder

RUN apk add --no-cache tzdata

RUN xcaddy build \
    --with github.com/caddy-dns/cloudflare \
    # --with github.com/caddy-dns/dnspod \
    # --with github.com/caddy-dns/alidns \
    --with github.com/caddy-dns/tencentcloud \
    # Extended plugins
    --with github.com/mholt/caddy-dynamicdns \
    --with github.com/caddyserver/replace-response \
    # 加入日志模块
    --with github.com/caddyserver/transform-encoder

FROM caddy:latest

COPY --from=builder /usr/bin/caddy /usr/bin/caddy

然后在原日志代码段加上日志格式化配置:

(log) {
    log {
        output file /log/{args[0]}/access.log {
            roll_size 5MiB
            roll_local_time
            roll_keep 10
            roll_keep_for 2160h
        }
        format transform `{request>remote_ip} - {user_id} [{ts}] "{request>method} {request>uri} {request>proto}" {status} {size} "{request>headers>Referer>[0]}" "{request>headers>User-Agent>[0]}"` {
            time_format "2006-01-02 15:04:05"
            time_local
        }
    }
}

日志就会变成这样:

4.5.6.7 - - [2025-07-25 10:11:37] "GET / HTTP/2.0" 200 1901 "-" "Uptime-Kuma/2.0.0-beta.3"

舒服多了。

只是这个模块被官方遗弃了,官方觉得 JSON 日志好使。

CA

Caddy 默认的 CA(证书颁发机构)是 Let's Encrypt,但也支持使用其他 CA,比如 ZeroSSL。而我们还可以通过在 Caddyfile 中使用 acme_ca 指令来指定其他 CA,比如 Google。

参考《使用 acme.sh 申请 Google 的免费 SSL 证书》获取 EAB 密钥后,在 Caddyfile 顶部加入以下配置:

    acme_ca https://dv.acme-v02.api.pki.goog/directory
    acme_eab {
        key_id xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
        mac_key xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    }
    email your@mail.com

随后添加域名,重载 Caddy 配置,尝试申请证书。

如果 Caddy 内的 /data/caddy/acme/ 文件夹出现了名为 acme/dv.acme-v02.api.pki.goog-directory 的文件夹,且该文件夹内包含上面注册 ACME 账户时使用的邮箱,即表示账户注册成功。

随后要观察 /data/caddy/certificates/ 是否文件夹出现了 dv.acme-v02.api.pki.goog-directory 文件夹。若该文件夹内部有域名证书生成,则表示证书申请成功。

文件结构大概是像这样:

data
└── caddy
    ├── acme
    │   ├── acme-v02.api.letsencrypt.org-directory
    │   │   └── users
    │   │       └── your@mail.com
    │   ├── acme.zerossl.com-v2-dv90
    │   │   └── users
    │   │       └── your@mail.com
    │   └── dv.acme-v02.api.pki.goog-directory
    │       └── users
    │           └── your@mail.com
    ├── certificates
    │   ├── acme-v02.api.letsencrypt.org-directory
    │   │   └── example.com
    │   │       ├── example.com.crt
    │   │       ├── example.com.json
    │   │       └── example.com.key
    │   └── dv.acme-v02.api.pki.goog-directory
    │       └── example.com
    │           ├── example.com.crt
    │           ├── example.com.json
    │           └── example.com.key
    ├── instance.uuid
    ├── last_clean.json
    ├── locks
    └── ocsp

不过这个 EAB 密钥使用一次后便会自动失效,不使用也会在七天内失效。但使用 EAB 密钥注册的 ACME 帐户没有过期时间,对证书续期没有影响。

所以待 ACME 账户注册成功后,就可以注释或删除相关秘钥,仅保留 acme_ca 配置:

    acme_ca https://dv.acme-v02.api.pki.goog/directory
    email your@mail.com

但使用 Google CA 除了能表明证书来自谷歌之外,没有其他优势。追求新鲜玩玩就够了,平常还是用 Let's Encrypt 或 ZeroSSL 比较合适。

再说 https://dv.acme-v02.api.pki.goog/directory 也被墙了,国内机子根本没法申请。

缓存

Caddy 并不能像 Nginx 一样在反代网站的时候配置缓存,需要手动安装一些模块。比如 cache-handler,一个分布式 HTTP 缓存模块,构建镜像的时候加上它:

FROM caddy:builder AS builder

RUN xcaddy build \
    --with github.com/caddy-dns/cloudflare \
    # --with github.com/caddy-dns/dnspod \
    --with github.com/caddy-dns/alidns \
    --with github.com/caddy-dns/tencentcloud \
    # Extended plugins
    --with github.com/mholt/caddy-dynamicdns \
    --with github.com/caddyserver/replace-response \
    # 分布式 HTTP 缓存模块
    --with github.com/caddyserver/cache-handler

FROM caddy:latest

COPY --from=builder /usr/bin/caddy /usr/bin/caddy

使用倒是很简单:

{
    cache
}

example.com {
    cache
    reverse_proxy your-app:8080
}

具体用法还是得查查相关文档,我不太会用。

但我在琢磨缓存的时候搜到了另一个模块,叫做 cdp-cache。它与 cache-handler 的区别在于,这个模块更容易理解和配置。

一样先构建镜像:

FROM caddy:builder AS builder

RUN xcaddy build \
    --with github.com/caddy-dns/cloudflare \
    --with github.com/caddy-dns/tencentcloud \
    # Extended plugins
    --with github.com/mholt/caddy-dynamicdns \
    --with github.com/caddyserver/replace-response \
    # 第三方缓存模块
    --with github.com/sillygod/cdp-cache

FROM caddy:latest

COPY --from=builder /usr/bin/caddy /usr/bin/caddy

然后在网站配置中启用:

proxy.example.com {
    reverse_proxy https://example.com
    http_cache {
        cache_type file
        path /cache
        match_path /images
        default_max_age 24h
    }
}

配置项不少,具体得参考官方文档。

我玩不来,用了一两天就删掉了。

robots.txt

如果有些网站你并不想被爬虫抓取,就可以使用 robots.txt,立下君子协定:

User-agent: *
Disallow: /

这玩意本质上就是个 txt 文本,所以可以在 Web 服务器端直接配置,例如 Nginx:

    location = /robots.txt {
        default_type text/html;
        add_header Content-Type "text/plain; charset=UTF-8";
        return 200 "User-Agent: *\nDisallow: /";
    }

Caddy 也可以这么做,直接在 Caddyfile 中加入 robot_txt 配置块:

(robots_txt) {
    respond /robots.txt 200 {
        body "User-agent: *
Disallow: /"
    }
}

然后在网站配置文件中导入该块:

example.com {
    encode zstd gzip
    root * /srv/blog
    file_server
    import robot_txt
}

IPv6

现在越来越多的服务器厂商开始支持 IPv6,我的服务器也提供了开启 IPv6 的选项。但 IPv6 其实坑不少,至少我不太会用。就比如我一昧追新开启了 IPv6 后,发现博客后台获取不到正确的访客 IP 地址,全部都是内网地址。

我甚至以为是 Nginx 的问题,查了半天,才发觉可能是 IPv6 的锅。

总之,AI 是这么解释的:

此问题的根源在于 Docker 守护进程未启用 IPv6,导致容器本身不具备原生的 IPv6 网络栈和地址。当外部 IPv6 流量通过端口映射到达宿主机时,Docker 的网络代理层(通常是 docker-proxy 进程或 iptables 规则)必须执行跨协议转发。在此过程中,它会终止原始的 TCPv6 连接,然后以 Docker 网桥的 IPv4 地址(如 172.17.0.1)为源地址,向容器的私有 IPv4 地址发起一个全新的 TCPv4 连接。最终,容器内的 Nginx 服务只能感知到这个新连接的直接来源,即作为代理的 Docker 网关,从而丢失了真实的客户端 IPv6 源地址信息。

所以,如果你的服务器也启用了 IPv6,为了让容器能正常识别来自 IPv6 的访问,请开启 Docker 的 IPv6 支持,并创建一个支持 IPv6 的 Docker 网络。

编辑 Docker 的配置文件(通常位于 /etc/docker/daemon.json),加入或合并以下内容以开启 Docker 的 IPv6 支持:

{
  "ipv6": true,
  "fixed-cidr-v6": "fd00:dead:beef::/48"
}

操作命令:

sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json > /dev/null <<EOF
{
  "ipv6": true,
  "fixed-cidr-v6": "fd00:dead:beef::/48"
}
EOF

重启 Docker 服务以应用更改:

sudo systemctl restart docker

随后创建一个支持 IPv6 的 Docker 网络:

docker network create \
  --driver bridge \
  --ipv6 \
  --subnet fd00:3939::/64 \
  caddy

再把容器加入到这个新建的 caddy 网络即可。

文件访问

在 Nginx 中通常会加上这么个配置:

    location ~ /.well-known {
        allow all;
    }

    location ~ /\. {
        deny all;
    }

前者用于允许路径 /.well-known 下内容的访问1,后者则是禁止访问以 点号开头的隐藏文件(比如 .git.htaccess.env 等)。

Caddy 默认会对 /.well-known/acme-challenge 这类路径自动处理,而对于以点开头的隐藏文件,使用 @hiddenFiles 匹配所有路径以 / + 点号 开头的请求,再 respond 返回 HTTP 403 禁止访问即可。

@hiddenFiles {
    path_regexp hiddenFiles ^/\..*
}
respond @hiddenFiles 403

塞到网站配置中就能用了:

example.com {
    encode zstd gzip
    root * /srv/blog
    file_server

    @hiddenFiles {
        path_regexp hiddenFiles ^/\..*
    }
    respond @hiddenFiles 403
}

  1. 这是一个标准路径,通常用来放置一些公开的、需要被外部访问的文件或信息,比如 Let's Encrypt 证书验证文件(ACME Challenge)、安全政策文件(如 security.txt)、其他协议定义的公开信息文件。