A serverless Docker registry with Bunny.net
Using Bunny.net storage zones and edge rules to build a Docker container registry without running any servers
I host all of my Docker images using my personal Gitea instance. Rather than expose my home network on the internet, I wanted to figure out a way to distribute them without having to worry about managing yet other server. I already use Bunny.net for some things, and thought I could try to figure out how to make that work.
Bunny.net has edge compute similar to AWS Lambda@Edge, but it also comes with a per-request cost. But why use those when edge-rules exist, so I wanted to see whether they were enough. The OCI distribution API turns out to be mostly static file lookups (at least for public images since you don't need to consider authentication). A CDN that can rewrite URLs and set response headers can pretend to be a registry.
Registry endpoints
The OCI distribution spec boils down to a few GET endpoints:
GET /v2/returns 200 OK as an auth check (conventionally with a body of{}, though the spec doesn't mandate a specific body). If you return 200, clients will assume auth isn't required.GET /v2/<name>/manifests/<reference>returns a manifest (by tag or digest)GET /v2/<name>/blobs/<digest>returns layers and configs
Manifests need a little extra handling. Edge compute could distinguish an index from a manifest and set headers accordingly. With plain edge rules, I instead store platform-specific manifests and multi-arch indexes in separate directories, so the CDN can set Content-Type based on the path alone. If a client requests a manifest by digest, the CDN looks for it in the manifest directory first. If it isn't there, it must be an index, so the CDN issues a redirect to the index path. The client then gets the correct Content-Type without the CDN inspecting file contents.
The responses also need specific headers: the correct Content-Type and Content-Length for each artifact, and Docker-Content-Digest on manifests and blobs. I also set Docker-Distribution-API-Version: registry/2.0 on all /v2/ responses. This is a legacy Docker header that the OCI spec considers optional, but some older clients still expect it. If you serve the right files with the right headers at these paths, Docker doesn't know the difference.
Storage layout
The file structure in the Bunny storage zone mirrors the API paths, with some extras to help the edge rules dispatch correctly:
/v2/
_root # static {} for the base endpoint
_blobs/sha256:<hex> # shared blob store (deduplicated)
<image>/
manifests/
sha256:<hex> # platform-specific manifests
tags/<tag> # image index JSON (multi-arch)
indexes/sha256:<hex> # image indexes (by digest)
Blobs live in a shared _blobs/ directory, deduplicated across images.
The edge rules
Bunny.net's edge rules are enough to make the CDN look like a Docker registry. They handle path rewrites, response headers, and the 404-to-redirect trick for manifest vs index dispatch.
Base endpoint
- Set the
Docker-Distribution-API-Version: registry/2.0header on all/v2/*requests. - Rewrite
/v2/(exact match) to serve the_rootfile, which contains{}. - Set
Content-Type: application/jsonon that base response.
Tag resolution
When a client requests /v2/<name>/manifests/<tag> (where the tag is a human-readable name like latest, not a sha256 digest), rule 4 rewrites the path to /v2/<name>/manifests/tags/<tag>. This uses Bunny's triggerMatchAll to match /v2/*/manifests/* while excluding paths that already contain sha256: or tags/.
Rule 5 sets Content-Type: application/vnd.oci.image.index.v1+json on responses from /v2/*/manifests/tags/*. In my setup, tag lookups always return an image index (the multi-arch manifest), since I always build multi-arch with buildx. If you only built single-platform images, a tag could point directly to a platform manifest instead.
Blob requests
Rule 6 rewrites blob requests from /v2/<name>/blobs/sha256:* to /v2/_blobs/sha256:*, pointing at the shared store. Bunny's path segment variables (%{Path.3}) extract the sha256:<hex> segment to build the rewritten path.
Manifest vs index dispatch
When a client requests a manifest by digest (/v2/<name>/manifests/sha256:*), the CDN looks in /manifests/ first. If the digest belongs to a platform manifest (the config and layers for a single architecture, like linux/amd64), the file exists there and the CDN serves it directly. Rule 8 sets Content-Type: application/vnd.oci.image.manifest.v1+json on these paths.
If the digest refers to an image index (the document that lists which platform manifest to use for each architecture), it won't exist in /manifests/. It is stored only in /indexes/, so the request 404s. Rule 7 catches that 404 and issues a 302 redirect to /v2/<name>/indexes/sha256:*. This has to be a real redirect, not a URL override, so the client makes a fresh request. That way the response header rules evaluate against the /indexes/ path. Rule 9 then sets Content-Type: application/vnd.oci.image.index.v1+json correctly.
Digest headers
Rules 10 and 11 extract the sha256 digest from the URL path (using the %{Path.3} path segment variable mentioned above) and set it as the Docker-Content-Digest header. One covers manifest paths, the other covers index paths after redirect. Strictly speaking, the OCI spec also requires Docker-Content-Digest on blob responses, but in practice the clients I've tested (docker, crane, skopeo) don't depend on it for blobs, so I haven't added a rule for that.
Publishing images
Publishing goes through a script because the files have to land in the exact directory structure above. I build images with docker buildx in CI, targeting multiple architectures and exporting in OCI format. The script transforms the OCI layout into the storage zone structure, then uploads everything using the Bunny.net storage zone API. After the upload, it purges the tag manifest URL so docker pull immediately sees the new version.
publish-oci-image <image-name> <tag> <oci-layout-dir-or-tar>
extract OCI input into a staging directory if it's a tar file
read index.json to find the top-level index digest
copy the index blob to:
/v2/<name>/manifests/tags/<tag> (tag lookup)
/v2/<name>/indexes/<digest> (digest lookup)
for each platform manifest listed in the index:
copy the manifest blob to /v2/<name>/manifests/<digest>
for each blob (config + layers) referenced by that manifest:
copy to /v2/_blobs/<digest> if not already there
upload the directory to the Bunny storage zone as it matches the structure from above
purge the CDN cache for /v2/<name>/manifests/<tag>
The staging directory mirrors the final storage layout exactly, so the upload is a straightforward sync. Even if two platform manifests reference the same layer, the script only copies it once. The cache purge at the end targets just the tag URL, because that's the only path where staleness matters (blob digests are content-addressed, so they never change).
Garbage collection
With no registry server managing state, storage can balloon quickly, so I have a separate GC command for cleanup. It walks each image's tags, resolves every referenced blob, manifest, and index, then checks for unreferenced files in _blobs/. If it finds any, it deletes them using the Bunny API. Walking the whole graph is a bit slow, but that is fine for my use case. I publish a handful of images, and each one only ever has the latest tag.
Pulling images
docker pull tklk.dev/abc:latest works. I've verified it with docker, crane, and skopeo. Each one sees a real registry because the headers and paths are correct.
Limitations
There's no docker push. Images go through the publish script, which means CI is the only path to publishing. Images are public only, so this approach only works for content you're happy to expose without authentication. I also haven't implemented tag listing (/v2/<name>/tags/list) or the Docker-specific catalogue endpoint (/v2/_catalog, which isn't part of the OCI distribution spec). I know what images I have because I put them there.
If you need private images, a push API, tag listing, or fine-grained access control, you probably want something like Gitea. I use this only for public personal images that already publish through CI. It runs on a single pull zone with no server behind it.