Skip to content

Cloudflare Workers 部署 Docker Hub 代理指南

Mxmilu666/cloudflare-dockerhub-proxy 是一个单文件 Cloudflare Worker。它用两个自定义域名分别代理 Docker Hub Registry 与 Bearer Token 服务,改写 WWW-Authenticate 的认证地址,并继续转发镜像层重定向。它适合为个人或小团队的匿名公共镜像拉取提供自管入口,不是持久化镜像仓库,也不能替代 Harbor、Distribution pull-through cache 或合规网络出口。

本文固定到提交 51405e6eab6a37ec5bedf725c8a3a0da520772b9。该提交仍是 2026-09-17 查询到的仓库 HEAD,许可证为 Apache-2.0。原始 worker.js 与文中的完整加固版本都通过 JavaScript 语法检查;加固版本还通过 Wrangler 4.133.0 本地 dry-run。本文没有登录 Cloudflare、创建 Worker、修改 DNS、发起真实 Docker 拉取或远程部署,验证等级为 static

项目概览

信息内容
项目Mxmilu666/cloudflare-dockerhub-proxy
固定提交51405e6eab6a37ec5bedf725c8a3a0da520772b9
许可证Apache-2.0
上游 Registryregistry-1.docker.io
上游鉴权auth.docker.io
Cloudflare 资源一个 Worker、两个 Custom Domains
数据绑定无 KV、D1、R2、Queue 或 Durable Object
支持范围匿名公共 Docker Hub 镜像拉取

仓库只包含 README、许可证和 worker.js,不是带 package.json 与 Wrangler 配置的完整工程。部署时需要自行建立最小 Wrangler 工程,并保留许可证和来源归属。

适用边界

适合以下情况:

  • 已有托管在 Cloudflare 的域名,可以分配两个未使用的子域名。
  • 主要拉取公开 Docker Hub 镜像,并能自行监控请求量、错误和滥用。
  • 接受代理仍受 Docker Hub 上游限制、Cloudflare 套餐和本地网络质量影响。
  • 能在生产使用前完成 Registry、Token、Manifest、Blob 和 Docker 客户端的真实验收。

以下情况不建议使用:

  • 需要上游中断后仍可用的持久缓存或离线仓库。
  • 需要代理私有 Docker Hub 仓库。凭证转发与访问控制不在本文范围内。
  • 要运营匿名公共镜像站。公开代理容易被扫描、滥用并消耗账户额度。
  • 需要对吞吐、可用性或中国大陆访问速度作固定承诺。

工作原理

Mermaid 流程图
查看源码
sequenceDiagram
    participant D as Docker 或 BuildKit
    participant R as docker.example.com Worker
    participant A as auth-docker.example.com Worker
    participant H as Docker Hub
    D->>R: GET /v2/.../manifests/...
    R->>H: 请求 Registry
    H-->>R: 401 + WWW-Authenticate
    R-->>D: realm 改写到自有 auth 域名
    D->>A: 请求 Bearer Token
    A->>H: 转发 service 与 scope
    H-->>A: Token 响应
    A-->>D: Token 响应
    D->>R: 携带 Token 请求 Manifest 或 Blob
    R->>H: 转发 Registry 请求
    H-->>R: Manifest 或 Blob 重定向
    R-->>D: 流式返回响应

两个域名必须指向同一个 Worker,并与源码中的 BASE_DOMAINAUTH_DOMAIN 完全一致。原始实现会为 Docker 官方镜像补全 library/,并在跟随 Blob 重定向时删除 Authorization,避免把 Docker Bearer Token 发送给对象存储域名。

源码风险审阅

项目固定提交行为部署要求
请求方法与路径原样转发客户端方法和路径只允许 GETHEAD;Registry 仅代理 /v2/,鉴权域名仅代理 /token
根路径返回包含真实域名和仓库链接的 HTML改成普通健康响应,减少服务信息暴露
访问控制无客户端身份验证至少使用 WAF、来源 IP 或速率限制控制滥用
请求头与 Cookie初始请求复制全部客户端头,重定向分支仍保留 Cookie采用请求头白名单;Registry 请求按需保留 Authorization,Blob 重定向必须删除 Authorization 与 Cookie
私有镜像未设计凭证隔离只验收匿名公共镜像,不保存 Docker Hub 凭证
缓存没有持久缓存语义不宣称等同于 Registry pull-through cache

前置条件

  • 一个可创建 Workers 与 Custom Domains 的 Cloudflare 账户。
  • 一个已接入 Cloudflare 且状态正常的域名。
  • 两个未被 DNS 或其他 Worker 占用的 hostname,例如 docker.example.comauth-docker.example.com
  • 本地 Node.js 22 或 24、npm 和 Git。
  • 部署身份具备 Workers Scripts 与相关域名配置权限。
  • 已阅读当前 Workers limitsWorkers pricing,确认请求、CPU、子请求和费用边界。

Custom Domain 会由 Cloudflare 管理 DNS 与证书。不要预先保留冲突的 CNAME;绑定前先确认 hostname 没有承载其他业务。

部署路径

1. 固定源码与许可证

bash
git clone https://github.com/Mxmilu666/cloudflare-dockerhub-proxy.git
cd cloudflare-dockerhub-proxy
git checkout 51405e6eab6a37ec5bedf725c8a3a0da520772b9
git rev-parse HEAD

输出必须是固定提交。随后检查以下文件:

bash
git status --short
sed -n '1,220p' worker.js
sed -n '1,40p' LICENSE
node --check worker.js

部署和再分发修改版时保留 Apache-2.0 LICENSE,并记录上游仓库与固定提交。不要直接从未知博客复制变体替换已审阅源码。

2. 建立 Wrangler 工程

仓库没有 npm 工程文件,先在仓库目录初始化并安装 Wrangler 4:

bash
npm init -y
npm pkg set license=Apache-2.0
npm install --save-dev wrangler@4
npm pkg get license

npm init -y 默认写入 ISC,必须改成与上游一致的 Apache-2.0。保留生成的 package-lock.json,让后续部署可以复现实际安装版本。创建 wrangler.jsonc

jsonc
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "docker-hub-proxy",
  "main": "worker.js",
  "compatibility_date": "2026-09-17",
  "workers_dev": false,
  "observability": {
    "enabled": true,
    "head_sampling_rate": 0.1
  },
  "routes": [
    {
      "pattern": "docker.example.com",
      "custom_domain": true
    },
    {
      "pattern": "auth-docker.example.com",
      "custom_domain": true
    }
  ]
}

把两个示例 hostname 换成自己的域名。workers_dev: false 避免额外公开一个未纳入访问控制的 workers.dev 入口。

3. 配置域名并做只读加固

不要在原文件中分散插入片段。将 worker.js 完整替换为下面的加固版本,再替换两个 hostname:

javascript
// Modified from Mxmilu666/cloudflare-dockerhub-proxy at
// 51405e6eab6a37ec5bedf725c8a3a0da520772b9.
// Changes: restrict methods, paths and forwarded headers; remove cookies;
// validate blob redirects; replace the public landing page.
// Licensed under Apache-2.0. Retain the repository LICENSE file.

const BASE_DOMAIN = "docker.example.com";
const AUTH_DOMAIN = "auth-docker.example.com";
const UPSTREAM_REGISTRY = "https://registry-1.docker.io";
const UPSTREAM_AUTH = "https://auth.docker.io";

const ALLOWED_METHODS = new Set(["GET", "HEAD"]);
const FORWARDED_HEADERS = new Set([
  "accept",
  "accept-encoding",
  "accept-language",
  "cache-control",
  "if-match",
  "if-modified-since",
  "if-none-match",
  "if-unmodified-since",
  "range",
  "user-agent",
]);

function requestHeaders(request, includeAuthorization = false) {
  const headers = new Headers();
  for (const [name, value] of request.headers) {
    const lowerName = name.toLowerCase();
    if (
      FORWARDED_HEADERS.has(lowerName) ||
      (includeAuthorization && lowerName === "authorization")
    ) {
      headers.set(name, value);
    }
  }
  return headers;
}

function normalizeRegistryPath(pathname) {
  return pathname.replace(
    /^\/v2\/([^/]+)\/(manifests|blobs|tags|referrers)\//,
    (match, name, api) =>
      name === "library" ? match : `/v2/library/${name}/${api}/`,
  );
}

function normalizeAuthSearch(url) {
  const scopes = url.searchParams.getAll("scope").map((scope) => {
    const parts = scope.split(":");
    if (
      parts.length === 3 &&
      parts[0] === "repository" &&
      !parts[1].includes("/")
    ) {
      parts[1] = `library/${parts[1]}`;
    }
    return parts.join(":");
  });
  url.searchParams.delete("scope");
  for (const scope of scopes) url.searchParams.append("scope", scope);
}

function clientResponse(response, rewriteRealm = false) {
  const headers = new Headers(response.headers);
  headers.delete("Set-Cookie");
  if (rewriteRealm && headers.has("WWW-Authenticate")) {
    headers.set(
      "WWW-Authenticate",
      headers
        .get("WWW-Authenticate")
        .replace(UPSTREAM_AUTH, `https://${AUTH_DOMAIN}`),
    );
  }
  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers,
  });
}

export default {
  async fetch(request) {
    if (!ALLOWED_METHODS.has(request.method)) {
      return new Response("Method Not Allowed", {
        status: 405,
        headers: { Allow: "GET, HEAD" },
      });
    }

    const url = new URL(request.url);
    if (url.hostname === BASE_DOMAIN && url.pathname === "/") {
      return new Response(
        request.method === "HEAD" ? null : "Docker Hub proxy",
        { status: 200 },
      );
    }

    let target;
    let registryRequest = false;

    if (url.hostname === BASE_DOMAIN) {
      if (!url.pathname.startsWith("/v2/")) {
        return new Response("Not Found", { status: 404 });
      }
      target = new URL(UPSTREAM_REGISTRY);
      target.pathname = normalizeRegistryPath(url.pathname);
      target.search = url.search;
      registryRequest = true;
    } else if (url.hostname === AUTH_DOMAIN) {
      if (url.pathname !== "/token") {
        return new Response("Not Found", { status: 404 });
      }
      normalizeAuthSearch(url);
      target = new URL(UPSTREAM_AUTH);
      target.pathname = "/token";
      target.search = url.search;
    } else {
      return new Response("Not Found", { status: 404 });
    }

    let response = await fetch(target, {
      method: request.method,
      headers: requestHeaders(request, registryRequest),
      redirect: "manual",
    });

    if (
      registryRequest &&
      response.status >= 300 &&
      response.status < 400
    ) {
      const location = response.headers.get("Location");
      if (!location) return new Response("Invalid upstream redirect", { status: 502 });

      const redirectUrl = new URL(location, target);
      if (redirectUrl.protocol !== "https:") {
        return new Response("Unsafe upstream redirect", { status: 502 });
      }

      response = await fetch(redirectUrl, {
        method: request.method,
        headers: requestHeaders(request),
        redirect: "follow",
      });
      return clientResponse(response);
    }

    return clientResponse(response, registryRequest);
  },
};

这个版本不会把客户端 Cookie 转发给 Registry、Auth 或 Blob 对象存储;只有 Registry 请求可以转发 Docker Bearer Authorization。Blob 重定向仅保留 Range、条件请求和内容协商所需的白名单请求头,并拒绝非 HTTPS 重定向。OPTIONS 不属于匿名拉取必需方法,因此不再转发。

Apache-2.0 要求再分发修改后的源码时保留许可证,并在修改文件中显著说明改动。上面的文件头承担修改说明;仅在自己的账户中部署服务与向他人分发修改源码是不同场景,但两种场景都应保留来源和许可证记录。

重新运行语法和 Wrangler 静态检查:

bash
node --check worker.js
npx wrangler deploy --dry-run

--dry-run 只检查本地打包和配置,不证明 Cloudflare 权限、域名、Docker Hub 链路或真实拉取可用。

4. 登录并部署

bash
npx wrangler login
npx wrangler whoami
npx wrangler deploy
npx wrangler deployments list

whoami 显示的账户必须与目标域名所在账户一致。部署会创建或更新 Worker,并按 routes 配置两个 Custom Domains。保存本次部署的 Version ID;只有完成协议和 Docker 拉取验收后,才能把它标记为已知正常版本。随后在 Dashboard 的 Workers & Pages → docker-hub-proxy → Settings → Domains & Routes 检查:

  • 两个条目都是 Custom Domain,不是普通 Zone route。
  • 证书状态正常,没有 DNS 冲突。
  • 两个 hostname 与 Worker 常量逐字一致。
  • 没有意外启用 workers.dev 入口。

配置与绑定

本项目没有 KV、D1、R2、Queues、Durable Objects 或 Secrets 绑定。唯一的 Cloudflare 配置是 Worker 本身与两个 Custom Domains:

hostnameWorker 分支上游
docker.example.comRegistry 请求、challenge 改写、Blob 转发registry-1.docker.io
auth-docker.example.comToken 请求与 scope 补全auth.docker.io

不要把 Docker Hub 用户名、密码或 Access Token 写入 Worker 变量。本文只支持匿名公共镜像;需要私有仓库时应重新设计客户端认证、代理访问控制、日志脱敏和凭证隔离。

在 Cloudflare WAF 或账户允许的安全产品中,为两个 hostname 配置来源限制和速率限制。标准 Docker CLI 不适合浏览器交互式 Access 登录,因此不要直接套用需要网页跳转的认证流程。

验收

1. Registry challenge

bash
curl -sSI https://docker.example.com/v2/

预期是 Registry V2 的 401 challenge,且 www-authenticate 中的 realm 指向自有鉴权域名:

text
HTTP/2 401
docker-distribution-api-version: registry/2.0
www-authenticate: Bearer realm="https://auth-docker.example.com/token",service="registry.docker.io"

若 realm 仍为 auth.docker.io,说明 challenge 没有经过目标 Worker 或改写逻辑未生效。

2. 匿名 Token

bash
curl -fsS \
  'https://auth-docker.example.com/token?service=registry.docker.io&scope=repository:library/alpine:pull' \
  | jq -e '.token | length > 0'

只检查结果是否为 true,不要记录或公开完整 Token。

3. Manifest 与 Blob

先取得临时 Token,解析 linux/amd64 对应的不可变 Manifest digest,再选择一个 Layer 做 Range 请求。示例 tag 需要在验收时确认仍存在:

bash
TOKEN="$(curl -fsS \
  'https://auth-docker.example.com/token?service=registry.docker.io&scope=repository:library/alpine:pull' \
  | jq -r '.token')"

INDEX_JSON="$(mktemp)"
MANIFEST_JSON="$(mktemp)"
BLOB_HEADERS="$(mktemp)"

curl -fsS \
  -H "Authorization: Bearer ${TOKEN}" \
  -H 'Accept: application/vnd.oci.image.index.v1+json' \
  https://docker.example.com/v2/library/alpine/manifests/latest \
  > "$INDEX_JSON"

MANIFEST_DIGEST="$(jq -r \
  '.manifests[] | select(.platform.os == "linux" and .platform.architecture == "amd64") | .digest' \
  "$INDEX_JSON" | head -n 1)"

curl -fsS \
  -H "Authorization: Bearer ${TOKEN}" \
  -H 'Accept: application/vnd.oci.image.manifest.v1+json' \
  "https://docker.example.com/v2/library/alpine/manifests/${MANIFEST_DIGEST}" \
  > "$MANIFEST_JSON"

LAYER_DIGEST="$(jq -r '.layers[0].digest' "$MANIFEST_JSON")"

curl -fsS -D "$BLOB_HEADERS" -o /dev/null \
  -H "Authorization: Bearer ${TOKEN}" \
  -H 'Range: bytes=0-1023' \
  "https://docker.example.com/v2/library/alpine/blobs/${LAYER_DIGEST}"

grep -E '^HTTP/.* 206 ' "$BLOB_HEADERS"

unset TOKEN
rm -f "$INDEX_JSON" "$MANIFEST_JSON" "$BLOB_HEADERS"

206 Partial Content 证明 Range 请求穿过 Registry 和 Blob 重定向链路。若返回 2004015xx,先保留响应头和 Worker 日志,再检查请求头白名单、重定向目标和上游状态。

最后使用解析出的不可变 digest 完成真实拉取,并记录镜像 ID 与 RepoDigests:

bash
docker pull "docker.example.com/library/alpine@${MANIFEST_DIGEST}"
docker image inspect \
  "docker.example.com/library/alpine@${MANIFEST_DIGEST}" \
  --format '{{.Id}} {{json .RepoDigests}}'
docker run --rm \
  "docker.example.com/library/alpine@${MANIFEST_DIGEST}" \
  cat /etc/alpine-release

docker pull 成功且 docker image inspect 返回镜像 ID 和代理域名下的 RepoDigest,才算完成 Manifest、Config 与全部 Blob 的拉取验收。不要把 Bearer Token、完整响应头或私有仓库名称写入共享日志。

直接指定代理域名会把镜像记录为自定义 Registry 名称。需要继续使用 docker pull alpine 或 Dockerfile 的 FROM alpine 时,按现有 Docker 镜像加速配置指南 配置 Docker daemon 与独立 BuildKit builder。客户端配置、备份、重启和回滚由该 canonical 教程负责,本文不重复展开。

4. 安全与可观察性

验收期间可以在另一个终端查看实时日志:

bash
npx wrangler tail docker-hub-proxy --format pretty

Real-time logs 用于临时排障,可能采样且不会形成长期日志库。wrangler.jsonc 中的 observability.enabled 开启持久 Workers Logs,head_sampling_rate: 0.1 表示采集约 10% 的调用;上线前应根据流量、隐私、保留周期和费用决定实际采样率。平台调用日志可能包含方法、URL、仓库名和 scope,不要再用 console.log 记录 Authorization、Token 响应体、Cookie 或完整请求头。

同时检查 Dashboard 的 Observability → LogsMetrics

  • 401 只出现在正常 Registry challenge 阶段。
  • 没有记录 Authorization、Token 响应体或 Cookie。
  • Manifest 和 Blob 请求没有异常 5xx、CPU 超限或子请求错误。
  • 来源、频率和传输规模符合个人或小团队使用预期。
  • GETHEAD 请求返回 405,Registry 域名的非 /v2/ 路径和鉴权域名的非 /token 路径返回 404
bash
curl -sS -o /dev/null -w '%{http_code}\n' \
  -X POST https://docker.example.com/v2/
curl -sS -o /dev/null -w '%{http_code}\n' \
  https://auth-docker.example.com/not-token

两个命令应分别输出 405404。告警至少覆盖 5xx、上游或代理 429、异常请求量、CPU 超限和子请求错误。

一个 docker pull 会产生多次 Registry、Token、Manifest、Config 和 Layer 请求。不要把一次拉取等同于一次 Worker 请求,公开服务前应依据当前套餐重新评估额度和费用。

常见故障

现象可能原因处理
/v2/ 返回欢迎页或 200hostname 落到错误 Worker 或根路径逻辑核对请求路径、Custom Domain 和部署版本
realm 仍指向 auth.docker.iochallenge 未改写核对 BASE_DOMAINAUTH_DOMAIN 和 Worker 日志
Token 请求超时auth 域名未绑定或证书未生效检查第二个 Custom Domain 与 DNS 冲突
Manifest 返回 401scope、library/ 补全或 Token 转发异常对照固定提交的 auth 分支和 Registry 分支
Manifest 成功但 Layer 失败Blob 重定向、Range 或上游链路异常检查重定向分支,不把 Authorization 发送到对象存储
直接拉取成功,docker pull alpine 仍直连Docker daemon 没有使用 mirror转到 Docker canonical 教程检查 docker info
docker pull 成功,Buildx 仍超时独立 BuildKit builder 未配置 mirrordocker-container driver 配置 buildkitd.toml
返回 429Docker Hub 或边缘代理限流降低频率,不使用代理规避上游规则
请求量异常增加hostname 被扫描或公开滥用收紧 WAF、来源限制和速率限制,必要时撤下域名

回滚边界

先从客户端移除该代理,再回滚 Cloudflare,避免 Docker 持续请求正在下线的入口:

  1. 按 Docker canonical 教程恢复 Docker daemon 和 BuildKit 的原配置。

  2. 运行 docker info 并直接拉取一个已确认镜像,证明客户端不再依赖代理。

  3. Worker 代码回归时,列出部署并回滚到验收时记录的 Version ID:

    bash
    KNOWN_GOOD_VERSION_ID='<known-good-version-id>'
    npx wrangler deployments list
    npx wrangler rollback "$KNOWN_GOOD_VERSION_ID"
  4. 回滚后重新执行 Registry challenge、Token、Manifest、Range 和 digest 拉取验收,不能只看到命令成功就结束。

  5. 需要立即停止流量时,移除两个 Custom Domains 或将它们切到维护响应。

  6. 确认所有客户端和 CI 已切换后,再决定是否删除 Worker。

删除 Worker 和 Custom Domain 是独立动作。代码回滚不能替代域名回滚,删除域名也不会恢复 Docker 客户端配置。Cloudflare 为 Custom Domain 生成的 Advanced Certificate 不会随域名自动删除;下线后要在 SSL/TLS → Edge Certificates 单独核对并移除不再使用的证书。若已经公开过代理 hostname,还应继续观察旧地址的请求和 DNS 收敛情况。

上线清单

  • [ ] 源码固定到 51405e6eab6a37ec5bedf725c8a3a0da520772b9
  • [ ] package.jsonLICENSE 和修改文件均保留 Apache-2.0 与显著修改说明。
  • [ ] 只允许 GETHEAD,并限制 Registry /v2/ 与 Auth /token 路径。
  • [ ] 两个域名与源码、Wrangler 配置完全一致。
  • [ ] workers.dev 未形成绕过访问控制的额外入口。
  • [ ] WAF、来源限制或速率限制已配置。
  • [ ] Registry challenge 的 realm 指向自有 auth 域名。
  • [ ] 匿名 Token、Manifest、Blob 与真实 Docker 拉取均通过。
  • [ ] Docker daemon 与 BuildKit 已分别验收。
  • [ ] 日志不包含凭证、Token、Cookie 或私有镜像信息。
  • [ ] 已记录已知正常 Version ID、客户端原配置、Worker 回滚和域名下线步骤。
  • [ ] 删除 Custom Domain 后已单独核对 Advanced Certificate。

参考资料

全部公开文章由同一个站点构建和发布。