Container registry
Diffinery hosts container images at diffinery.com, next to the code that builds
them. It speaks the standard OCI distribution API, so docker, podman,
buildx, crane, oras, skopeo and the docker/* GitHub Actions work
without changes. Permissions follow ghcr.io: images belong to an owner, are
private by default, and can be linked to a repository whose collaborators then
share access to them.
Image names
diffinery.com/<owner>/<name>[:<tag>|@sha256:<digest>]
<owner> is a user or organization. <name> may contain slashes
(diffinery.com/acme/api/worker). Names are lowercase; docker requires that
anyway. Blobs and manifests are stored in R2, and identical layers are stored
once across all packages.
Signing in
Use any username and a Diffinery token as the password. Create a token under
Settings → Tokens: the read scope pulls, write pushes, and admin also
deletes. A token limited to some repositories only reaches packages linked to
those repositories.
echo "$DIFFINERY_TOKEN" | docker login diffinery.com -u me --password-stdin
docker push diffinery.com/acme/api:1.4.0
docker pull diffinery.com/acme/api:1.4.0
In workflows
The job's GITHUB_TOKEN signs in, exactly as with ghcr.io. Its packages
permission decides what it can do:
permissions | GITHUB_TOKEN can |
|---|---|
none set, or write-all | push and pull |
packages: write | push and pull |
packages: read, or read-all | pull |
a map without packages | nothing |
With write, a workflow pushes to packages linked to its own repository, creates new packages under its repository's owner (linked to the repository), and deletes versions of its repository's packages. With read, it pulls its own repository's packages and any other package of the same owner that it can see.
jobs:
image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: diffinery.com
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: diffinery.com/${{ github.repository }}:${{ github.sha }}
github.repository keeps the owner's capitals; use a lowercase name if the
owner or repository has any (${GITHUB_REPOSITORY,,} in a shell step).
Plain docker login / docker build / docker push in run: steps works the
same way. A later job pulls with docker pull, as a job container:, or as a
services: image, after signing in with credentials::
container:
image: diffinery.com/acme/api:${{ github.sha }}
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
Moving from ghcr.io
Replace ghcr.io with diffinery.com in image names and in registry: for
docker/login-action. Nothing else changes: the same permissions: packages: write, the same GITHUB_TOKEN, the same org.opencontainers.image.source
label. To copy existing images across, use a tool that copies all platforms
(this works for layers under 100 MB; for bigger ones, docker pull, docker tag and docker push in a hosted workflow, see Size limits):
crane auth login diffinery.com -u me -p "$DIFFINERY_TOKEN"
crane copy ghcr.io/acme/api:1.4.0 diffinery.com/acme/api:1.4.0
Repositories and visibility
A package linked to a repository shares the repository's roles: readers pull, writers push, admins delete versions and change settings. The owner (a user, or an organization's owners and admins) always administers its packages, and organization members can push to packages that aren't linked to a repository.
A package is linked when it is created:
- by a workflow, to the workflow's repository;
- by a person pushing
<owner>/<repo>or<owner>/<repo>/<anything>while able to write to that repository; - by the
org.opencontainers.image.sourcelabel or annotation on the image, when it names a repository of the same owner (on Diffinery or on github.com) that the pusher can write to. An unlinked package is linked the first time an image with the label is pushed.
The link can also be changed on the package's settings page or through the API. Packages are private by default. Internal lets every signed-in account pull; public lets anyone pull without signing in.
Pages:
https://diffinery.com/<owner>/-/packages: an owner's packages.https://diffinery.com/<owner>/-/packages/container/<name>: tags, versions, pull commands and settings.https://diffinery.com/<owner>/<repo>/packages: the packages linked to a repository (the Packages tab).
API
The REST API follows GitHub's packages API under /api/v3, so existing
scripts work after changing the base URL. <type> is always container;
<name> is URL-encoded when it contains slashes (api%2Fworker).
| Request | Does |
|---|---|
GET /user/packages?package_type=container | your packages |
GET /users/<user>/packages, GET /orgs/<org>/packages | an owner's packages you can see (?visibility= filters) |
GET …/packages/<type>/<name> | one package |
GET …/packages/<type>/<name>/versions | its versions, newest first, with their tags in metadata.container.tags |
GET …/packages/<type>/<name>/versions/<id> | one version |
DELETE …/packages/<type>/<name>/versions/<id> | delete a version and its tags |
DELETE …/packages/<type>/<name> | delete the package |
PATCH …/packages/<type>/<name> | Diffinery only: {"visibility": "public"} or {"repository": "api"} (null unlinks) |
GET /repos/<owner>/<repo>/packages | Diffinery only: packages linked to a repository |
The registry itself is at /v2/: blob uploads (monolithic, chunked and
streamed), cross-repository mounts, range requests, image manifests and
indexes (OCI and Docker v2), tag listing with pagination, DELETE of
manifests and tags, the referrers API (signatures and SBOMs attached with
subject), and /v2/_catalog (the repositories you can pull).
Size limits
Cloudflare limits one request to 100 MB, and docker push sends each layer
in a single request. On Diffinery's hosted runners this doesn't matter: the
runner's Docker daemon (and BuildKit builders created with
docker buildx create or docker/setup-buildx-action) reach the registry
through a local relay that splits uploads into smaller chunks. Layers of any
size push from workflows that use docker or BuildKit. Other clients run
directly in a step (crane, skopeo) don't go through the relay.
From a laptop or a self-hosted runner, a layer over 100 MB fails to push with
413 or a reset connection, because the common clients (docker, buildx,
crane, skopeo) send a layer in one request. The registry accepts chunked
uploads (PATCH with Content-Range) of any total size, so a client that
chunks works. Otherwise push from a hosted workflow, or keep layers under
100 MB by putting large dependencies in their own RUN/COPY steps. Pulls
have no size limit.
Cleanup
Unfinished uploads are discarded after a day. Deleting a version removes its tags; layers no other image uses are removed from storage by a scheduled job.