余味YUWEI
云原生ARTICLE / 2026

用 Caddy 代理加速 Docker Hub 与 GHCR

记录如何用 Caddy 接管镜像仓库的认证与下载跳转,为 Docker Hub 和 GHCR 搭一套仅供自己使用的拉取代理。

2026.08.26 阅读约 12 分钟 Caddy · Docker · GHCR · 镜像加速

国内服务器拉取 Docker Hub 和 GHCR 镜像时,偶尔会卡在认证或 Layer 下载。公共镜像站能解一时之急,但地址是否长期可用、镜像是否齐全,都不太受自己控制。

如果手里正好有一台出海链路稳定的服务器,可以用 Caddy 做一层反向代理。配置不复杂,不过只代理 registry-1.docker.io 或 ghcr.io 还不够:仓库会把认证请求和镜像层下载分流到其他域名,这两个跳转也要一起接回来。

这里记录我正在使用的配置。它只是转发,不会把镜像缓存在磁盘上,所以更准确地说是“拉取代理”,不是 Harbor 那样的镜像仓库。

拉取镜像时发生了什么

以 Docker Hub 为例,客户端第一次请求 /v2/ 时通常会收到 401 Unauthorized。这不是故障,而是 Registry 告诉客户端去哪里取 Bearer Token。拿到 Token 后,客户端再请求 Manifest 和 Blob;大文件下载还可能收到一次 307 Temporary Redirect,随后转去 CDN。

一条完整的拉取链路大致是:

Docker Client
  ├─ Registry API ──> registry-1.docker.io / ghcr.io
  ├─ Token         ──> auth.docker.io / ghcr.io/token
  └─ Image Layer   ──> Docker CDN / pkg-containers.githubusercontent.com

因此,Caddy 要做三件事:

  1. 把 Registry API 转发到上游仓库;
  2. 改写 Www-Authenticate,让客户端通过代理获取 Token;
  3. 改写 Location,避免客户端下载 Layer 时绕回原始 CDN。

准备工作

我为两个仓库分别准备了一个域名:

  • docker.example.com:代理 Docker Hub;
  • ghcr.example.com:代理 GitHub Container Registry。

两个域名都直接解析到 Caddy 所在服务器,并放行 80、443 端口。Caddy 会自动申请和续期 HTTPS 证书。

配置里还加了一层来源 IP 限制。镜像流量不小,公开代理很容易被扫到并耗尽带宽,因此需要把 1.2.3.4 换成实际使用端的公网 IP。多个地址可以写在同一行,用空格分隔。

如果域名前面还有 Cloudflare 一类的 CDN,remote_ip 看到的通常是 CDN 节点地址,而不是客户端地址。这种场景需要额外配置可信代理;如果只是自用,域名保持“仅 DNS”会省事很多。

Docker Hub 代理

docker.example.com {
    encode gzip
    log

    @blocked not remote_ip 1.2.3.4
    respond @blocked "Forbidden" 403

    @token path /token
    handle @token {
        reverse_proxy https://auth.docker.io {
            header_up Host auth.docker.io
        }
    }

    handle_path /docker-cdn/* {
        reverse_proxy https://production.cloudfront.docker.com {
            header_up Host production.cloudfront.docker.com
        }
    }

    handle {
        reverse_proxy https://registry-1.docker.io {
            header_up Host registry-1.docker.io
            header_down Www-Authenticate "https://auth\.docker\.io/token" "https://docker.example.com/token"
            header_down Location "^https://production\.cloudfront\.docker\.com/(.*)$" "https://docker.example.com/docker-cdn/$1"
        }
    }
}

/token 单独转发给 auth.docker.io。/docker-cdn/ 使用 handle_path,Caddy 转发前会去掉这段前缀,还原成 CDN 原本的路径。查询参数也会保留,其中包含的临时签名不能丢。

header_down 后两行是整个配置的关键:第一行替换认证地址,第二行替换 Layer 的下载地址。这里的查找参数是正则表达式,所以域名中的 . 要写成 \.。

Docker Hub 使用的下载域名并不是永远不变。如果实际响应中的 Location 不是 production.cloudfront.docker.com,Layer 仍会从原地址下载。遇到这种情况,可以先用 docker pull 的报错或调试日志确认新域名,再按同样方式增加一组 handle_path 和 header_down Location。

GHCR 代理

GHCR 的处理方式相同,只是 Token 仍由 ghcr.io/token 提供,镜像层则会跳转到 pkg-containers.githubusercontent.com。

ghcr.example.com {
    encode gzip
    log

    @blocked not remote_ip 1.2.3.4
    respond @blocked "Forbidden" 403

    handle_path /pkg/* {
        reverse_proxy https://pkg-containers.githubusercontent.com {
            header_up Host pkg-containers.githubusercontent.com
        }
    }

    handle {
        reverse_proxy https://ghcr.io {
            header_up Host ghcr.io
            header_down Www-Authenticate "https://ghcr\.io/token" "https://ghcr.example.com/token"
            header_down Location "https://pkg-containers\.githubusercontent\.com" "https://ghcr.example.com/pkg"
        }
    }
}

这里没有为 /token 再写一个独立的 handle,因为兜底规则本来就会把它转发到 ghcr.io。/pkg/ 同样只是在代理侧临时加的前缀,真正请求上游时会被去掉。

配置保存后,先检查语法,再平滑加载:

caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
sudo systemctl reload caddy

如果 Caddy 跑在容器里,就在容器内执行对应的 validate 和 reload 命令。

客户端怎么使用

Docker Hub

Docker 原生支持为 Docker Hub 设置 Registry Mirror。修改 /etc/docker/daemon.json,如果文件里已有其他选项,将 registry-mirrors 合并进去,不要直接覆盖整个文件。

{
  "registry-mirrors": [
    "https://docker.example.com"
  ]
}

重启 Docker:

sudo systemctl restart docker
docker pull alpine:latest

也可以不改守护进程配置,直接把代理域名写进镜像名。Docker Hub 的官方镜像需要补上 library:

docker pull docker.example.com/library/alpine:latest

GHCR

Docker 的 registry-mirrors 只针对 Docker Hub,不能用同一项接管 GHCR。拉取 GHCR 镜像时,需要把镜像地址中的域名换成自己的代理域名:

# 原地址
docker pull ghcr.io/OWNER/IMAGE:TAG

# 通过代理
docker pull ghcr.example.com/OWNER/IMAGE:TAG

公开镜像可以匿名拉取。私有镜像则先登录代理域名,Token 至少需要 read:packages 权限:

echo "$CR_PAT" | docker login ghcr.example.com -u USERNAME --password-stdin
docker pull ghcr.example.com/OWNER/PRIVATE_IMAGE:TAG

由于凭据会经过这台代理,服务器必须由自己控制,Caddy 的配置和日志也不要交给不可信的人。

验证与排错

先检查两个 Registry 入口:

curl -I https://docker.example.com/v2/
curl -I https://ghcr.example.com/v2/

正常情况下会返回 401 Unauthorized,同时响应头中的认证地址应该已经变成自己的域名。例如:

Www-Authenticate: Bearer realm="https://docker.example.com/token",...

接着实际拉取一个本机没有的镜像,并在服务端观察访问日志:

sudo journalctl -u caddy -f

如果 Registry 和 Token 都成功,最后仍卡在 Layer 下载,优先查看上游返回的 Location。大多数问题都出在下载域名发生了变化,而配置里的替换规则没有命中。

几点限制

  • 这套配置按“只拉取”设计,没有处理镜像推送过程中的上传地址,不建议拿它做 Push 入口;
  • Caddy 只是转发数据,不会缓存 Layer,重复拉取仍会占用代理服务器的出口流量;
  • Docker Hub 的限流仍然存在,匿名拉取额度会落在代理服务器的出口 IP 上;
  • IP 白名单最好保留,防止他人盗用和滥用

参考资料

  1. Caddy reverse_proxy 文档
  2. Docker Hub Registry API
  3. Docker Hub Mirror 配置
  4. GitHub Container Registry 使用文档