OCI Distribution Spec: Writing Our Own Docker Registry¶
Written by:
Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect
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. TheGET /v2/check returning200(with theDocker-Distribution-API-Versionheader) is exactly how a client tells an OCI registry apart from some random HTTP server. - Routing via
LastIndexon/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-Versionheader everywhere andvnd.docker.*media types sitting next tovnd.oci.*. - The HTTP
202 Acceptedstatus in the upload protocol doesn't mean "success", it means "keep going". The registry returns aLocationwith a session URL, and the client streams data there. The final201 Createdonly arrives after aPUTwith the digest. - Errors in OCI aren't arbitrary text — they're an enumerated set of codes (
BLOB_UNKNOWN,MANIFEST_UNKNOWN,NAME_UNKNOWN). Clients likedocker pullparse 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 nginxis 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:latestcan 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 (
PATCHwith a session URL) — without it you can't push large layers, since the whole blob is held in memory. - Implement pagination for
_catalogandtags/list(theLinkheader, thenandlastparameters) — on a real registry the repository list won't fit in a single response. - Move routing off the hand-rolled
LastIndexonto 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¶
- OCI Distribution Spec — registry API reference
- Distribution Spec docs — rendered spec
- OCI Image Spec — the "what's inside a manifest" half that complements this transport spec
- distribution/distribution — reference implementation (Docker registry)
- ORAS & OCI Artifacts — pushing Helm charts, SBOMs, and WASM over the same
/v2/API - Distribution Spec conformance suite — the tests a registry must pass to claim OCI compliance
- HTTP Range requests — how a pull resumes a half-downloaded layer
- Registry HTTP API v2 — historical Docker docs
Source code for the series: github.com/igorgorovoy/sheep-shepherd-meadow
Previous: Event System
