Skip to content

OCI Distribution Spec: Writing Our Own Docker Registry

OCI Distribution Spec: Writing Our Own Docker Registry

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


Docker Registry is an HTTP server with a specific set of endpoints. The OCI Distribution Spec defines which URLs a registry must support. Meadow — our registry — implements this specification in 370 lines.

Endpoints

mux.HandleFunc("/v2/", s.handler)

A single handler parses the URL and routes requests:

func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
    path := strings.TrimPrefix(r.URL.Path, "/v2/")

    // GET /v2/ - version check
    if path == "" || path == "/" {
        w.Header().Set("Docker-Distribution-API-Version",
            "registry/2.0")
        w.WriteHeader(http.StatusOK)
        return
    }

    // GET /v2/_catalog
    if path == "_catalog" {
        s.handleCatalog(w, r)
        return
    }

    // {name}/blobs/uploads - blob upload
    if i := strings.LastIndex(path, "/blobs/uploads"); i > 0 {
        repo := path[:i]
        s.handleBlobUpload(w, r, repo)
        return
    }
    // {name}/blobs/{digest} - blob operations
    if i := strings.LastIndex(path, "/blobs/"); i > 0 {
        repo := path[:i]
        digest := path[i+len("/blobs/"):]
        s.handleBlob(w, r, repo, digest)
        return
    }
    // {name}/manifests/{ref} - manifest operations
    if i := strings.LastIndex(path, "/manifests/"); i > 0 {
        repo := path[:i]
        ref := path[i+len("/manifests/"):]
        s.handleManifest(w, r, repo, ref)
        return
    }
}
graph TB
    subgraph "OCI Distribution Spec endpoints"
        V2["GET /v2/<br/>Version check"]
        CAT["GET /v2/_catalog<br/>List repositories"]
        TAGS["GET /v2/{name}/tags/list<br/>List tags"]
        BLOB_HEAD["HEAD /v2/{name}/blobs/{digest}<br/>Check blob exists"]
        BLOB_GET["GET /v2/{name}/blobs/{digest}<br/>Download blob"]
        BLOB_POST["POST /v2/{name}/blobs/uploads<br/>Start upload"]
        BLOB_PUT["PUT /v2/{name}/blobs/uploads?digest=<br/>Upload blob"]
        MAN_GET["GET /v2/{name}/manifests/{ref}<br/>Get manifest"]
        MAN_PUT["PUT /v2/{name}/manifests/{ref}<br/>Push manifest"]
    end

Blob upload

Monolithic upload — the client sends the entire blob in a single request:

func (s *Server) doBlobUpload(w http.ResponseWriter,
    r *http.Request, repo, expectedDigest string) {
    actualDigest, size, err := s.storage.PutBlob(r.Body)
    if err != nil {
        registryError(w, http.StatusInternalServerError,
            "BLOB_UPLOAD_INVALID", err.Error())
        return
    }

    if expectedDigest != "" &&
        actualDigest != expectedDigest {
        s.storage.DeleteBlob(actualDigest)
        registryError(w, http.StatusBadRequest,
            "DIGEST_INVALID",
            fmt.Sprintf("expected %s, got %s",
                expectedDigest, actualDigest))
        return
    }

    w.Header().Set("Docker-Content-Digest", actualDigest)
    w.Header().Set("Location",
        fmt.Sprintf("/v2/%s/blobs/%s", repo, actualDigest))
    w.WriteHeader(http.StatusCreated)
}

The digest is verified after writing. If it doesn't match, the blob gets deleted and an error is returned.

Manifest push/pull

func (s *Server) handleManifest(w http.ResponseWriter,
    r *http.Request, repo, ref string) {
    switch r.Method {
    case http.MethodGet, http.MethodHead:
        data, ct, _ := s.storage.GetManifest(repo, ref)
        w.Header().Set("Content-Type", ct)
        if r.Method == http.MethodGet {
            w.Write(data)
        }

    case http.MethodPut:
        body, _ := io.ReadAll(r.Body)
        ct := r.Header.Get("Content-Type")
        if ct == "" {
            ct = "application/vnd.oci.image.manifest.v1+json"
        }
        digest, _ := s.storage.PutManifest(repo, ref, body, ct)
        w.Header().Set("Docker-Content-Digest", digest)
        w.WriteHeader(http.StatusCreated)
    }
}

OCI error format

func registryError(w http.ResponseWriter,
    status int, code, message string) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(map[string]any{
        "errors": []map[string]string{
            {"code": code, "message": message},
        },
    })
}

The OCI spec requires a specific error format with codes: BLOB_UNKNOWN, MANIFEST_UNKNOWN, DIGEST_INVALID.

Where this breaks

Meadow only supports monolithic blob upload. Chunked upload (for large layers) is not implemented. For layers > 1GB this means the entire blob must fit in memory during upload.

  • The /v2/ prefix isn't cosmetic. The GET /v2/ check returning 200 (with the Docker-Distribution-API-Version header) is exactly how a client tells an OCI registry apart from some random HTTP server.
  • Routing via LastIndex on /blobs/, /manifests/ works because a repository name can contain slashes (library/nginx) while /blobs/ can't. But if a tag ever contained /manifests/, the parser would break.

💡 Fun facts

  • The /v2/ path isn't a random version number. It's the line between the old Docker Registry v1 (where images were a chain of parent-ids, no content-addressing) and v2 (where everything is SHA256). v1 has been dead for ages, but /v2/ stuck around as the handshake forever.
  • The OCI Distribution Spec didn't appear out of nowhere — it was literally pulled out of the Docker Registry HTTP API v2 and standardized. That's why the code still has the Docker-Distribution-API-Version header everywhere and vnd.docker.* media types sitting next to vnd.oci.*.
  • The HTTP 202 Accepted status in the upload protocol doesn't mean "success", it means "keep going". The registry returns a Location with a session URL, and the client streams data there. The final 201 Created only arrives after a PUT with the digest.
  • Errors in OCI aren't arbitrary text — they're an enumerated set of codes (BLOB_UNKNOWN, MANIFEST_UNKNOWN, NAME_UNKNOWN). Clients like docker pull parse the code, not the message — so a "pretty" error with the wrong code will break the client.
  • The spec is deliberately transport-only: it standardizes how bytes move (blobs, manifests, digests over HTTP) but says nothing about what's inside a manifest — that's the separate OCI Image Spec. That clean split is exactly why the same /v2/ API now ships Helm charts, WASM modules, and SBOMs, not just container images — the "OCI artifacts" movement.
  • A single docker pull nginx is really three kinds of request under the hood: GET the manifest, GET the config blob, then GET each layer blob — all by digest. The registry never "knows" it's serving nginx; it just hands back content-addressed bytes.
  • Cross-repository blob mount is a neat spec trick: POST .../blobs/uploads/?mount=<digest>&from=<repo> lets a push that shares a layer with an existing image copy nothing — the registry just links the existing blob. Deduplication at the API level, not just on disk.
  • Tags are mutable, digests are not. nginx:latest can point to different manifests over time; nginx@sha256:... is frozen forever. That's the whole reason production deployments pin images by digest, not tag.

What I figured out while digging into this

What surprised me most was how much a registry is just a "dumb" key-value store over HTTP. I expected complex logic, but it turned out to be: blobs by digest, manifests by tag/digest, and a handful of status codes. All of Docker's "magic" actually lives in the client — the registry just hands back bytes. Once that clicked, 370 lines stopped looking suspiciously small.

What could be improved

  • Add chunked upload (PATCH with a session URL) — without it you can't push large layers, since the whole blob is held in memory.
  • Implement pagination for _catalog and tags/list (the Link header, the n and last parameters) — on a real registry the repository list won't fit in a single response.
  • Move routing off the hand-rolled LastIndex onto a proper regex router that validates the repository name against the grammar from the spec.

Try it yourself

# Start Meadow:
./meadow -addr :5000 -data-dir /tmp/meadow-data
# Test:
curl -s localhost:5000/v2/
curl -s localhost:5000/v2/_catalog | jq .

The registry works. Next up — Content-Addressable Storage: SHA256 as the key for blobs.

Resources

Source code for the series: github.com/igorgorovoy/sheep-shepherd-meadow

Previous: Event System