Skip to content
 
 

Repository files navigation

Container Registry in Workers

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.

Deployment

You have to install all the dependencies with pnpm (other package managers may work, but only pnpm is supported.)

$ pnpm install

After installation, there are a few steps to actually deploy the registry into production:

  1. 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
  1. Setup the R2 Bucket for this registry
$ npx wrangler --env production r2 bucket create r2-registry

Add this to your wrangler config file:

// wrangler.jsonc
"r2_buckets": [
  { "binding": "REGISTRY", "bucket_name": "r2-registry" }
]
# wrangler.toml
r2_buckets = [
  { binding = "REGISTRY", bucket_name = "r2-registry" }
]
  1. Deploy your image registry
$ npx wrangler deploy --env production

Your registry should be up and running. It will refuse any requests if you don't setup credentials.

Adding username password based authentication

Set the USERNAME and PASSWORD as secrets with npx wrangler secret put USERNAME --env production and npx wrangler secret put PASSWORD --env production.

Adding JWT authentication with public key

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.

Using with Docker

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:latest

Allowing anonymous pulls

Set 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.

Protecting immutable release tags

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.

Disabling deletion

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.

Using buckets with retention rules

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.

Configuring Pull fallback

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.

Using the registry from another Worker

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.

Known limitations

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 ./push folder for some inspiration on how can you push big layers.
  • If you use npx wrangler dev and push to the R2 registry with docker, the R2 registry will have to buffer the request on the Worker.

License

The project is licensed under the Apache License.

Contribution

See CONTRIBUTING.md for contributing to the project.

About

A container registry backed by Workers and R2.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages