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, but rather than exposing my home network on the internet, I figured I could upload the images to Bunny.net too, and use the CDN there to serve them.

Bunny.net has edge compute (think AWS Lambda@Edge), but you end up paying a per-request cost, so I wanted to see if I could do it entirely with edge rules, which are cheaper. Luckily, 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.

What a Docker registry actually serves#

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

There's a wrinkle with manifests. Since I opted not to use edge compute, where I could easily distinguish an index from a manifest, and set headers accordingly, I had to find another way. The solution was to store platform-specific manifests and multi-arch indexes separately, so the CDN can set Content-Type based on path alone. If a client requests a manifest by digest, the CDN looks for it in the manifest directory first. If it's not there, it must be an index, so the CDN issues a redirect to the index path. This way the client gets the correct Content-Type without the CDN having to inspect 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#

Eleven edge rules let me make Bunny.net look like a Docker registry. They handle path rewrites, response headers, and the 404-to-redirect trick for manifest vs index dispatch.

Base endpoint#

Three rules handle the /v2/ base path:

  1. Set the Docker-Distribution-API-Version: registry/2.0 header on all /v2/* requests.
  2. Rewrite /v2/ (exact match) to serve the _root file, which contains {}.
  3. Set Content-Type: application/json on 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 only lives 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#

Unlike a regular Docker Registry, where you can use docker push, I have to upload files in a specific way since I need manifests and indexes to upload into the directory structure above. I build images with docker buildx in CI, targeting multiple architectures and exporting in OCI format. A publish script transforms the OCI layout into the storage zone structure, then uploads everything using the Bunny.net API for storagezones. After the upload, the script 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 to handle 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. This can be a bit slow since it has to walk the whole graph, but it's good enough for my use case since I only publish a few images and for each of those images, I only use 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. This setup works well for personal and public images where the push workflow goes through CI anyway, and it runs on a single pull zone with no servers behind it.