This repository contains a container registry implementation in Workers that uses R2.
It supports all pushing and pulling workflows. It also supports Username/Password and public key JWT based authentication.
You have to install all the dependencies with pnpm (other package managers may work, but only pnpm is supported.)
$ pnpm installAfter installation, there are a few steps to actually deploy the registry into production:
- Create your own wrangler config file based on the example files in this repo.
Cloudflare recommends wrangler.jsonc for new projects, but wrangler.toml is also supported. Pick whichever you prefer:
# JSONC (recommended)
$ cp wrangler.example.jsonc wrangler.jsonc
# or TOML
$ cp wrangler.example.toml wrangler.toml- Setup the R2 Bucket for this registry
$ npx wrangler --env production r2 bucket create r2-registryAdd this to your wrangler config file:
# wrangler.toml
r2_buckets = [
{ binding = "REGISTRY", bucket_name = "r2-registry" }
]- Deploy your image registry
$ npx wrangler deploy --env productionYour registry should be up and running. It will refuse any requests if you don't setup credentials.
Set the USERNAME and PASSWORD as secrets with npx wrangler secret put USERNAME --env production and npx wrangler secret put PASSWORD --env production.
You can add a base64 encoded JWT public key to verify passwords (or token) that are signed by the private key.
npx wrangler secret put JWT_REGISTRY_TOKENS_PUBLIC_KEY --env production
Tokens are bound to a single registry. Every token must carry an aud claim naming
the registry it is for, and a request is rejected with 401 unless aud matches the
host it arrived on. createToken() sets this from its registryUrl argument. Only
host and port are compared, so https://registry.example, http://registry.example
and registry.example are equivalent, but registry.example:8787 is a different
registry from registry.example.
Give each deployment its own key pair. This registry has no per-account or
per-repository scoping: any token that verifies grants the full extent of its
capabilities over the whole registry. Deployments sharing a
JWT_REGISTRY_TOKENS_PUBLIC_KEY therefore form one trust domain, and the aud check
is all that separates them.
You can use this registry with Docker to push and pull images.
Example using docker push and docker pull:
export REGISTRY_URL=your-url-here
# Replace $PASSWORD and $USERNAME with the actual credentials
echo $PASSWORD | docker login --username $USERNAME --password-stdin $REGISTRY_URL
docker pull ubuntu:latest
docker tag ubuntu:latest $REGISTRY_URL/ubuntu:latest
docker push $REGISTRY_URL/ubuntu:latest
# Check that pulls work
docker rmi ubuntu:latest $REGISTRY_URL/ubuntu:latest
docker pull $REGISTRY_URL/ubuntu:latestSet ANONYMOUS_PULL_REPOSITORIES to a comma or space separated list of repository names that can be pulled without
credentials. * matches any characters, including /, so public/* allows every repository under public/ and *
allows all of them. Requests without an Authorization header can then read the manifests, blobs, tags and referrers
of those repositories. Everything else still needs credentials: pushes, deletes, uploads, /v2/_catalog and garbage
collection. /v2/ keeps answering 401 so that clients still log in before they push, requests with wrong
credentials are still refused, and anonymous requests never use the pull fallback below.
Set IMMUTABLE_TAG_PATTERN under [env.production.vars] to a JavaScript regular expression that must match the
entire protected tag. For example, this protects strict vX.Y.Z releases while leaving latest mutable:
IMMUTABLE_TAG_PATTERN = 'v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)'Protected tags are created with an atomic conditional R2 write. Retrying the same manifest digest is idempotent;
attempting to assign a different digest returns 409 with the OCI DENIED error code. Protected tags cannot be
deleted directly. While the policy is enabled, the API rejects every
delete-by-digest request because alias discovery and digest deletion cannot be made atomic across R2 keys. Delete an
unprotected tag by name and let untagged garbage collection remove its content. Direct blob deletion is also disabled
because deleting a referenced layer or config would make a protected release unpullable. An invalid expression fails
manifest writes before any manifest object is stored.
The policy is enforced at the Worker API boundary. To preserve the invariant, restrict direct R2 write access and route registry writes through this Worker.
Set DISABLE_DELETE = "true" to make the registry append-only. Deleting manifests (by tag or by digest), deleting
blobs and garbage collection (POST /v2/<name>/gc) then answer 405 Method Not Allowed with the OCI UNSUPPORTED
error code. Pushing, and moving a tag that is not protected by IMMUTABLE_TAG_PATTERN, keep working. Cancelling an
upload in progress is not affected, because it only removes temporary upload state.
Blobs, manifests stored under their digest and referrer entries are written once and never overwritten: a push of
content that already exists leaves the stored object alone. The registry therefore works on an R2 bucket whose
content keys are protected by bucket locks or other
retention rules. Those keys are <repository>/blobs/<digest>, <repository>/manifests/sha256:<hex> and
<repository>/_referrers/<subject digest>/<referrer digest>. Tags (<repository>/manifests/<tag>) and upload state
are rewritten and deleted, so keep them outside such rules, and set DISABLE_DELETE so that deletes fail cleanly
instead of hitting the lock.
You can configure the R2 registry to fallback to another registry if it doesn't exist in your R2 bucket. It will download from the registry and copy it into the R2 bucket. In the next pull it will be able to pull it directly from R2.
This is very useful for migrating from one registry to serverless-registry.
It supports both Basic and Bearer authentications as explained in the registry spec.
In your wrangler config file:
// wrangler.jsonc
"env": {
"production": {
"vars": {
"REGISTRIES_JSON": "[{ \"registry\": \"https://url-to-other-registry\", \"password_env\": \"REGISTRY_TOKEN\", \"username\": \"username-to-use\" }]"
}
}
}# wrangler.toml
[env.production.vars]
REGISTRIES_JSON = "[{ \"registry\": \"https://url-to-other-registry\", \"password_env\": \"REGISTRY_TOKEN\", \"username\": \"username-to-use\" }]"Set as a secret the registry token of the registry you want to setup pull fallback in.
For example gcr:
cat ./registry-service-credentials.json | base64 | npx wrangler secret put REGISTRY_TOKEN --env production
Github for example uses a simple token that you can copy.
echo $GITHUB_TOKEN | npx wrangler secret put REGISTRY_TOKEN --env production
The trick is always looking for how you would login in Docker for the target registry and setup the credentials.
Never put a registry password/token inside your wrangler config file, please always use wrangler secrets put
You can also use docker.io with anonymous authentication:
// wrangler.jsonc
"REGISTRIES_JSON": "[{ \"registry\": \"https://index.docker.io/\" }]"# wrangler.toml
REGISTRIES_JSON = "[{ \"registry\": \"https://index.docker.io/\" }]"You can also set your docker.io credentials in the configuration to not have any rate-limiting.
The package can be a dependency of another Worker that does its own routing and hands registry requests to the registry. Wrangler bundles the TypeScript sources directly, so there is no build step. Pin a commit:
// package.json of your Worker
"dependencies": {
"r2-registry": "github:cloudflare/serverless-registry#<commit>"
}import registry, { type RegistryEnv } from "r2-registry";
interface Env extends RegistryEnv {
// your own bindings
}
export default {
async fetch(request, env, ctx) {
const { pathname } = new URL(request.url);
if (pathname === "/v2" || pathname.startsWith("/v2/")) {
return registry.fetch(request, env, ctx);
}
return new Response("Not Found", { status: 404 });
},
} satisfies ExportedHandler<Env>;registry.fetch(request, env, ctx) takes the same bindings and variables as a standalone deployment (RegistryEnv):
an R2 bucket bound as REGISTRY, and the authentication variables described above. The Worker needs the
nodejs_compat compatibility flag. The registry only answers paths under /v2/, and it uses the request URL for
authentication challenges and upload locations, so pass the request through with its path unchanged.
Right now there is some limitations with this container registry.
- Pushing with docker is limited to images that have layers of maximum size 500MB. Refer to maximum request body sizes in your Workers plan.
- To circumvent that limitation, you can either manually interact with the R2 bucket to upload the layer or take a
peek at the
./pushfolder for some inspiration on how can you push big layers. - If you use
npx wrangler devand push to the R2 registry with docker, the R2 registry will have to buffer the request on the Worker.
The project is licensed under the Apache License.
See CONTRIBUTING.md for contributing to the project.