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 carries Docker-Upload-UUID: <session> and Range: 0-0; the Range is the byte span received so far,
empty at the start. peryx keeps <session> in memory on this process, and a restart discards it. After one hour without
a status check or PATCH attempt, peryx removes the session during the next process-wide maintenance pass. The pass
runs once per minute, so the staged file can remain for less than one minute beyond that deadline.
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-30Finish 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: 31Cancel 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 gonePull 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
- Work with registry behavior: the cancel, resume, and non-sha256 moves as recipes you reach for when a real push stalls or an upstream uses another algorithm.
- Registry behavior: the exact statuses, headers, and digest rules.
- HTTP endpoints: every
/v2/route peryx serves.