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 |
| 上游 Registry | registry-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 仓库。凭证转发与访问控制不在本文范围内。
- 要运营匿名公共镜像站。公开代理容易被扫描、滥用并消耗账户额度。
- 需要对吞吐、可用性或中国大陆访问速度作固定承诺。
工作原理
正在准备渲染...
查看源码
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_DOMAIN、AUTH_DOMAIN 完全一致。原始实现会为 Docker 官方镜像补全 library/,并在跟随 Blob 重定向时删除 Authorization,避免把 Docker Bearer Token 发送给对象存储域名。
源码风险审阅
| 项目 | 固定提交行为 | 部署要求 |
|---|---|---|
| 请求方法与路径 | 原样转发客户端方法和路径 | 只允许 GET、HEAD;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.com与auth-docker.example.com。 - 本地 Node.js 22 或 24、npm 和 Git。
- 部署身份具备 Workers Scripts 与相关域名配置权限。
- 已阅读当前 Workers limits 与 Workers pricing,确认请求、CPU、子请求和费用边界。
Custom Domain 会由 Cloudflare 管理 DNS 与证书。不要预先保留冲突的 CNAME;绑定前先确认 hostname 没有承载其他业务。
部署路径
1. 固定源码与许可证
git clone https://github.com/Mxmilu666/cloudflare-dockerhub-proxy.git
cd cloudflare-dockerhub-proxy
git checkout 51405e6eab6a37ec5bedf725c8a3a0da520772b9
git rev-parse HEAD输出必须是固定提交。随后检查以下文件:
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:
npm init -y
npm pkg set license=Apache-2.0
npm install --save-dev wrangler@4
npm pkg get licensenpm init -y 默认写入 ISC,必须改成与上游一致的 Apache-2.0。保留生成的 package-lock.json,让后续部署可以复现实际安装版本。创建 wrangler.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:
// 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 静态检查:
node --check worker.js
npx wrangler deploy --dry-run--dry-run 只检查本地打包和配置,不证明 Cloudflare 权限、域名、Docker Hub 链路或真实拉取可用。
4. 登录并部署
npx wrangler login
npx wrangler whoami
npx wrangler deploy
npx wrangler deployments listwhoami 显示的账户必须与目标域名所在账户一致。部署会创建或更新 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:
| hostname | Worker 分支 | 上游 |
|---|---|---|
docker.example.com | Registry 请求、challenge 改写、Blob 转发 | registry-1.docker.io |
auth-docker.example.com | Token 请求与 scope 补全 | auth.docker.io |
不要把 Docker Hub 用户名、密码或 Access Token 写入 Worker 变量。本文只支持匿名公共镜像;需要私有仓库时应重新设计客户端认证、代理访问控制、日志脱敏和凭证隔离。
在 Cloudflare WAF 或账户允许的安全产品中,为两个 hostname 配置来源限制和速率限制。标准 Docker CLI 不适合浏览器交互式 Access 登录,因此不要直接套用需要网页跳转的认证流程。
验收
1. Registry challenge
curl -sSI https://docker.example.com/v2/预期是 Registry V2 的 401 challenge,且 www-authenticate 中的 realm 指向自有鉴权域名:
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
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 需要在验收时确认仍存在:
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 重定向链路。若返回 200、401 或 5xx,先保留响应头和 Worker 日志,再检查请求头白名单、重定向目标和上游状态。
最后使用解析出的不可变 digest 完成真实拉取,并记录镜像 ID 与 RepoDigests:
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-releasedocker 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. 安全与可观察性
验收期间可以在另一个终端查看实时日志:
npx wrangler tail docker-hub-proxy --format prettyReal-time logs 用于临时排障,可能采样且不会形成长期日志库。wrangler.jsonc 中的 observability.enabled 开启持久 Workers Logs,head_sampling_rate: 0.1 表示采集约 10% 的调用;上线前应根据流量、隐私、保留周期和费用决定实际采样率。平台调用日志可能包含方法、URL、仓库名和 scope,不要再用 console.log 记录 Authorization、Token 响应体、Cookie 或完整请求头。
同时检查 Dashboard 的 Observability → Logs 与 Metrics:
401只出现在正常 Registry challenge 阶段。- 没有记录
Authorization、Token 响应体或 Cookie。 - Manifest 和 Blob 请求没有异常
5xx、CPU 超限或子请求错误。 - 来源、频率和传输规模符合个人或小团队使用预期。
- 非
GET、HEAD请求返回405,Registry 域名的非/v2/路径和鉴权域名的非/token路径返回404。
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两个命令应分别输出 405 和 404。告警至少覆盖 5xx、上游或代理 429、异常请求量、CPU 超限和子请求错误。
一个 docker pull 会产生多次 Registry、Token、Manifest、Config 和 Layer 请求。不要把一次拉取等同于一次 Worker 请求,公开服务前应依据当前套餐重新评估额度和费用。
常见故障
| 现象 | 可能原因 | 处理 |
|---|---|---|
/v2/ 返回欢迎页或 200 | hostname 落到错误 Worker 或根路径逻辑 | 核对请求路径、Custom Domain 和部署版本 |
realm 仍指向 auth.docker.io | challenge 未改写 | 核对 BASE_DOMAIN、AUTH_DOMAIN 和 Worker 日志 |
| Token 请求超时 | auth 域名未绑定或证书未生效 | 检查第二个 Custom Domain 与 DNS 冲突 |
Manifest 返回 401 | scope、library/ 补全或 Token 转发异常 | 对照固定提交的 auth 分支和 Registry 分支 |
| Manifest 成功但 Layer 失败 | Blob 重定向、Range 或上游链路异常 | 检查重定向分支,不把 Authorization 发送到对象存储 |
直接拉取成功,docker pull alpine 仍直连 | Docker daemon 没有使用 mirror | 转到 Docker canonical 教程检查 docker info |
docker pull 成功,Buildx 仍超时 | 独立 BuildKit builder 未配置 mirror | 为 docker-container driver 配置 buildkitd.toml |
返回 429 | Docker Hub 或边缘代理限流 | 降低频率,不使用代理规避上游规则 |
| 请求量异常增加 | hostname 被扫描或公开滥用 | 收紧 WAF、来源限制和速率限制,必要时撤下域名 |
回滚边界
先从客户端移除该代理,再回滚 Cloudflare,避免 Docker 持续请求正在下线的入口:
按 Docker canonical 教程恢复 Docker daemon 和 BuildKit 的原配置。
运行
docker info并直接拉取一个已确认镜像,证明客户端不再依赖代理。Worker 代码回归时,列出部署并回滚到验收时记录的 Version ID:
bashKNOWN_GOOD_VERSION_ID='<known-good-version-id>' npx wrangler deployments list npx wrangler rollback "$KNOWN_GOOD_VERSION_ID"回滚后重新执行 Registry challenge、Token、Manifest、Range 和 digest 拉取验收,不能只看到命令成功就结束。
需要立即停止流量时,移除两个 Custom Domains 或将它们切到维护响应。
确认所有客户端和 CI 已切换后,再决定是否删除 Worker。
删除 Worker 和 Custom Domain 是独立动作。代码回滚不能替代域名回滚,删除域名也不会恢复 Docker 客户端配置。Cloudflare 为 Custom Domain 生成的 Advanced Certificate 不会随域名自动删除;下线后要在 SSL/TLS → Edge Certificates 单独核对并移除不再使用的证书。若已经公开过代理 hostname,还应继续观察旧地址的请求和 DNS 收敛情况。
上线清单
- [ ] 源码固定到
51405e6eab6a37ec5bedf725c8a3a0da520772b9。 - [ ]
package.json、LICENSE和修改文件均保留 Apache-2.0 与显著修改说明。 - [ ] 只允许
GET、HEAD,并限制 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。