Documentation

Push a blob chunk by chunk

docker push and crane push upload a blob for you in one call, so you never see the steps underneath. This tutorial runs those steps by hand with curl against a hosted index, so the chunked-upload state machine the distribution spec defines becomes something you can watch: you start a session, append the blob one chunk at a time, deliberately send a chunk out of order to trigger a 416 and recover from it, cancel a session, and finish a real upload with a digest check. Then, on the same /v2/ surface, you proxy an upstream that content-addresses with sha512 and watch peryx accept it. It takes about twenty minutes and builds on getting started.

Configure a hosted index

An upload session belongs to a hosted index, and writing to it needs the index's upload_token. Save this as peryx.toml:

# peryx.toml
[[index]]
name = "images"
route = "images"
ecosystem = "oci"
hosted = true
upload_token = "demo-secret"

Start peryx and leave it running; use a second terminal for the rest:

peryx serve --config peryx.toml   # listening on 127.0.0.1:4433

Every request below sends -u _:demo-secret: peryx ignores the username and takes the token as the Basic-auth password.

Build a blob to upload

Make a small blob in three pieces so there is something to chunk, and record its sha256 for the commit at the end:

printf 'chunk-one-'   > part-a   # 10 bytes
printf 'chunk-two-'   > part-b   # 10 bytes
printf 'chunk-three'  > part-c   # 11 bytes
cat part-a part-b part-c > blob.bin
sha256sum blob.bin               # <hex>  blob.bin  -> 31 bytes total

Keep the hex from sha256sum; you push it as sha256:<hex> on the PUT.

Start a session

A bare POST to the uploads endpoint opens a session and answers 202 with the coordinates you drive the rest of the upload with. Capture the Location path it returns:

loc=$(curl -sS -u _:demo-secret -X POST -D - -o /dev/null \
  http://127.0.0.1:4433/v2/images/blob-demo/blobs/uploads/ \
  | tr -d '\r' | awk 'tolower($1) == "location:" { print $2 }')
echo "$loc"   # /v2/images/blob-demo/blobs/uploads/<session>

The response also carries Docker-Upload-UUID: <session> and Range: 0-0; the Range is the byte span received so far, empty at the start. <session> lives in memory on this peryx process, so it does not survive a restart.

Append the first two chunks

Each PATCH sends one chunk with a Content-Range: <start>-<end> that begins exactly where the last chunk ended. Append part-a at bytes 0-9, then part-b at 10-19:

curl -sS -i -u _:demo-secret -X PATCH \
  -H 'Content-Type: application/octet-stream' \
  -H 'Content-Range: 0-9' \
  --data-binary @part-a "http://127.0.0.1:4433$loc"
# 202 Accepted, Range: 0-9

curl -sS -i -u _:demo-secret -X PATCH \
  -H 'Content-Type: application/octet-stream' \
  -H 'Content-Range: 10-19' \
  --data-binary @part-b "http://127.0.0.1:4433$loc"
# 202 Accepted, Range: 0-19

Every 202 echoes the updated Range: 0-<end>, so Range: 0-19 means 20 bytes have landed and the next chunk must start at byte 20.

Trigger a 416 and recover

Now send the third chunk with the wrong Content-Range, as if you had lost track and skipped ahead to byte 30. peryx rejects the gap with 416 and keeps the 20 bytes already staged:

curl -sS -i -u _:demo-secret -X PATCH \
  -H 'Content-Type: application/octet-stream' \
  -H 'Content-Range: 30-40' \
  --data-binary @part-c "http://127.0.0.1:4433$loc"
# 416 Range Not Satisfiable
# Location: /v2/images/blob-demo/blobs/uploads/<session>
# Docker-Upload-UUID: <session>
# Range: 0-19

The 416 carries the session's Location and Docker-Upload-UUID alongside Range: 0-19, so you resume from those coordinates instead of restarting the whole upload. Range: 0-19 says byte 20 is the next one peryx expects; resend the chunk there:

curl -sS -i -u _:demo-secret -X PATCH \
  -H 'Content-Type: application/octet-stream' \
  -H 'Content-Range: 20-30' \
  --data-binary @part-c "http://127.0.0.1:4433$loc"
# 202 Accepted, Range: 0-30

Finish with a digest check

PUT closes the session under the digest you recorded. peryx appends any body on the PUT (none here), verifies the assembled bytes against <digest>, and commits the blob:

curl -sS -i -u _:demo-secret -X PUT \
  "http://127.0.0.1:4433$loc?digest=sha256:$(sha256sum blob.bin | cut -d' ' -f1)"
# 201 Created
# Location: /v2/images/blob-demo/blobs/sha256:<hex>
# Docker-Content-Digest: sha256:<hex>

A digest that does not match the uploaded bytes, or a missing digest query, is 400 DIGEST_INVALID and nothing is committed. Confirm the blob is now served:

curl -sS -I -u _:demo-secret \
  "http://127.0.0.1:4433/v2/images/blob-demo/blobs/sha256:$(sha256sum blob.bin | cut -d' ' -f1)"
# 200 OK, Content-Length: 31

Cancel instead of finishing

A session you decide to abandon does not have to wait to time out. Open one and DELETE it: peryx drops the session and its staged bytes and answers 204:

loc=$(curl -sS -u _:demo-secret -X POST -D - -o /dev/null \
  http://127.0.0.1:4433/v2/images/blob-demo/blobs/uploads/ \
  | tr -d '\r' | awk 'tolower($1) == "location:" { print $2 }')

curl -sS -i -u _:demo-secret -X DELETE "http://127.0.0.1:4433$loc"
# 204 No Content

curl -sS -i -u _:demo-secret -X DELETE "http://127.0.0.1:4433$loc"
# 404 Not Found (BLOB_UPLOAD_UNKNOWN): the session is already gone

Pull a manifest addressed by sha512

The upload side keys everything on sha256, but a manifest peryx reads through a proxy may be advertised in another algorithm. Most registries content-address with sha256, but the image-spec digest grammar allows others, and a registry may advertise its Docker-Content-Digest in sha512. This last part makes that case concrete: you run a tiny stand-in upstream that serves a manifest under a sha512 digest, proxy it through a cached peryx index, and watch peryx accept it, where it once returned 502.

A real registry keys on sha256, so to see the sha512 path you serve a manifest yourself. This stub answers the /v2/ version check and serves one manifest, advertising its sha512 digest in the header a client verifies. Save it as upstream.py:

import hashlib
from http.server import BaseHTTPRequestHandler, HTTPServer

MANIFEST = b'{"schemaVersion":2,"config":{}}'
MEDIA_TYPE = "application/vnd.oci.image.manifest.v1+json"
SHA512 = "sha512:" + hashlib.sha512(MANIFEST).hexdigest()


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/v2/":
            self.send_response(200)
            self.end_headers()
        elif self.path.startswith("/v2/demo/manifests/"):
            self.send_response(200)
            self.send_header("Content-Type", MEDIA_TYPE)
            self.send_header("Docker-Content-Digest", SHA512)
            self.end_headers()
            self.wfile.write(MANIFEST)
        else:
            self.send_response(404)
            self.end_headers()


HTTPServer(("127.0.0.1", 5000), Handler).serve_forever()

Run it and leave it going:

python3 upstream.py   # serving http://127.0.0.1:5000

Point a cached index at the stub. There is nothing to configure for the digest algorithm; it is the default behavior. Save this as sha512.toml and start a second peryx (stop the hosted one first, or give this one its own port):

# sha512.toml
[[index]]
name = "reg"
route = "reg"
ecosystem = "oci"
cached = "http://127.0.0.1:5000"
peryx serve --config sha512.toml   # listening on 127.0.0.1:4433

Pull the manifest through the reg route. The stub advertises sha512; peryx fetches the bytes, hashes them under its own sha256, and serves them:

curl -si http://127.0.0.1:4433/v2/reg/demo/manifests/latest

The response is 200 OK, and its Docker-Content-Digest is peryx's canonical sha256, not the sha512 the stub sent:

HTTP/1.1 200 OK
content-type: application/vnd.oci.image.manifest.v1+json
docker-content-digest: sha256:fc6b27d31f093fca2791259bc5f1f885b0616677300f02a729ff7a782d4325fc

That sha256 is the digest to pin an image by, and the one a client verifies the bytes against. Before peryx accepted a non-sha256 advertisement, this same pull compared the sha512 header to the computed sha256, read the inequality as a corrupted download, and returned 502 with nothing cached.

A client that already holds the upstream's sha512 digest can pull by it directly. peryx serves the bytes under the digest you asked for and echoes it back:

curl -si http://127.0.0.1:4433/v2/reg/demo/manifests/sha512:$(printf '%s' '{"schemaVersion":2,"config":{}}' | sha512sum | cut -d' ' -f1)

The docker-content-digest on that response is the sha512: value from the request, while the cache still keys the bytes on sha256 underneath. peryx verifies the sha256 it computes and trusts the algorithm it cannot recompute, so a registry that content-addresses with sha512 works through peryx without any special configuration.

Where next

On this page