Global, Resilient, and Cheap: Circumvent Docker Hub Limitations with Your Own R2 Container Registry

Docker Hub has long been the backbone of container image distribution, trusted by millions of developers worldwide. However, with recent policy changes that impose strict rate limits on unauthenticated and free users, it’s time to rethink your container image strategy, especially if you’re managing automated deployments or large-scale infrastructures.
Enter Cloudflare R2—a globally distributed object storage solution that offers high availability, incredible resilience, and zero egress fees. By leveraging Cloudflare R2 to host your OCI-compliant Docker registry mirror, you can bypass Docker Hub’s pull restrictions and create a robust, cost-effective alternative for serving container images across the globe.
If you love ClickHouse®, Altinity can simplify your experience with our fully managed service in your cloud (BYOC) or our cloud and 24/7 expert support. Learn more.
Why Build an OCI Mirror on Cloudflare R2?
Docker Hub Policy Updates at a Glance
Docker Hub’s new policies have gone through some back-and-forth in the last few months, but at their current stage, they break down as follows:
- – Unauthenticated Users: Limited to 10 pulls per hour.
- – Free Authenticated Users: Limited to 100 pulls per hour.
- – Paid Subscribers: Unlimited pulls (subject to fair use).
- – Storage Charges: Storage costs are deferred, but new management tools are on the horizon.
While these changes keep Docker Hub central to cloud-native computing, they present serious challenges – automated systems and large-scale deployments might hit these rate limits, leading to stalled pipelines and downtime.
Why Cloudflare R2?
Cloudflare R2 offers several compelling benefits:
- Global Distribution: R2 is hosted across multiple regions to ensure low latency and continuous access.
- Speed: our local benchmarks find R2 pulls up to 3x faster than Docker Hub.
- No Egress Fees: Downloading data from R2 costs nothing, significantly reducing operational expenses. This is especially useful for companies that host public images, where you have no control over who downloads your images and how many times they do so.
- Resilience: Built to withstand regional failures, R2 ensures your container images remain accessible even during disruptions.
Since not everything that glitters is gold, there are some caveats that you should consider before pursuing this path:
- You won’t be able to list images or tags. In other words, there’s no discoverability, you need to know the exact image and tag to do a pull.
- That might change in the future if / when docker-sync implements this.
- You won’t be able to push images the usual way (e.g.
docker push …
).- This is what this whole article is about though, so keep reading!
Understanding the OCI Registry Layout
Before you set up your mirror, it’s important to understand the OCI (Open Container Initiative) registry layout. A compliant registry typically follows this folder structure:
v2/
└── <repository>/
├── manifests/
│ └── <tag or digest>
└── blobs/
└── sha256:<checksum>
manifests
: This directory contains the image manifest files. These JSON files describe the container image by listing metadata, configuration, and references to the image layers (blobs).blobs
: This directory houses the actual image layers and configuration files. To ensure data integrity, each file is renamed following a content-addressable scheme, typically as sha256:<checksum>.
Replicating this structure in your object storage allows container runtimes (like docker
or containerd
) to interact with your custom registry as if it were a standard Docker Hub endpoint, with a caveat: they can pull, but can’t push. So how to put images there?
Building the OCI Mirror on Cloudflare R2
Let’s first work on setting things up on the Cloudflare site.
1. Add a domain to Cloudflare
For this guide, you’ll need a domain hosted at Cloudflare.
Setting up your domain is out of the scope of this blog post, but if you don’t have one yet, you can follow the official docs to add an existing domain or even register a new one directly with Cloudflare.
2. Create your bucket
To create a new R2 bucket from the Cloudflare dashboard:
- Log in to the Cloudflare dashboard and select R2.
- Select Create bucket.
- Enter a name for the bucket and select Create bucket.
Write that name down. It will be referred to throughout the rest of this post as YOUR_R2_BUCKET
.
3. Link your bucket to your custom domain
To link your domain to the bucket:
- Go to R2 and select your bucket.
- On the bucket page, select Settings.
- Under Public access > Custom Domains, select Connect Domain.
- Enter the domain name you want to connect to and select Continue.
- Review the new record that will be added to the DNS table and select Connect Domain.
Your domain is now connected. The status takes a few minutes to change from Initializing to Active, and you may need to refresh to review the status update. If the status has not changed, select the … next to your bucket and select Retry connection.
You can find more information in the official docs.
4. Get your bucket credentials
In order to interact with R2, you’ll need an API token. To create an API token:
- In Account Home, select R2.
- Under Account details, select Manage R2 API tokens.
- Select Create API token.
- Select the R2 Token text to edit your API token name.
- Under Permissions, choose Object Read & Write.
- Under Specific Bucket(s), apply it to the bucket you created.
- Select Create API Token.
You can find more information in the official docs.
After clicking Create API Token, you’ll be provided with the credentials.
Be sure to write those down, as they won’t be shown again. We’ll be using the following:
- Access Key ID: this is like your username. It will be referred to throughout the rest of this post as
YOUR_R2_ACCESS_KEY_ID
. - Secret Access Key: this is like your password. It will be referred to throughout the rest of this post as
YOUR_R2_SECRET_ACCESS_KEY
. - Jurisdiction-specific endpoint: we just need a part of this value, namely the Account ID. There are many ways to find your Account ID, but since it’s already here, let’s grab it. It’s the first part of the URL.
5. Setup Transform Rules
The OCI distribution specification expects some paths to behave in specific ways.
In order to be compliant, you now need to set up two transform rules in the Cloudflare Dashboard:
V2 Ping Fix – URL Rewrite
This rule ensures that requests sent to /v2/ return a 200 OK status code instead of a redirect or error response.
V2 Ping Fix – Modify Response Header Rule
This rule adds the Docker-Distribution-API-Version header to responses for all paths that start with /v2/. Make sure to change the hostname to match your domain.
Content-Type Fix – Modify Response Header Rule
This rule removes the Content-Encoding
header for all paths that start with /v2/
and contain either /manifests/
or /blobs/
. The rule is too complex to build in the UI. The complete rule is:
(http.host eq "cr-enam.altinity.io" and (starts_with(http.request.uri.path, "/v2/") and (http.request.uri.path contains "/manifests/" or http.request.uri.path contains "/blobs/")))
. Make sure to change the hostname to match your domain.

Your Cloudflare R2 bucket is ready to go! Now we need to fill it with images.
Synchronizing images the manual way
This section goes into detail on how to structure your objects on R2 so they look like an OCI registry to an OCI client. If you want a faster, less error-prone way, jump straight to the next section, Automating the process with docker-sync.
1. Convert Your Docker Image to OCI Format
The first step is to convert your Docker image into an OCI-compliant format. This can be done using skopeo. For instance, to convert a Docker image from your local daemon:
skopeo copy --all "docker://docker.io/myimage:latest" "dir:/path/to/tmp"
This command extracts your image’s layers, manifest files, and configuration so that you can later rearrange them into the proper OCI structure.
$ skopeo copy --all "docker: //docker.io/alpine:latest" "dir:/tmp/images/"
Getting image list signatures
Copying 16 images generated from 16 images in list
Copying image sha256:1c4eef651f65e2f7daee7ee785882ac164b02b78fb74503052a26dc061c90474 (1/16)
Getting image source signatures
Copying blob f18232174bc9 [---------------------------------------------] 0.0b / 3.5MiB | 0.0 b/s
Copying blob f18232174bc9 done. |
Copying config aded1e1a5b done. |
Writing manifest to image destination
Copying image sha256:f5fb419236878e25e11358970412e1aa64413c412398739d747e1333d3e1f6d1 (2/16)
...
Writing manifest to image destination
Writing manifest list to image destination
Storing list signatures
2. Reorganize the OCI Directory Structure
After conversion, rearrange the files to mirror the OCI layout:
Create a Base Directory Structure
Create a v2/
directory in your workspace. Under this directory, create a subdirectory for your image (e.g., v2/myimage
), and within that, create two essential folders:
manifests/
: to store image manifests.blobs/
: to store all layer and configuration files.
Process the Files
- Manifests: Identify the manifest files (often named
manifest.json
) and copy or rename them intov2/myimage/manifests/
using the appropriate tag or digest as the new filename. - Blobs: For each layer or configuration file, calculate its SHA256 checksum and move it into
v2/myimage/blobs/
, renaming the file to the formatsha256:<checksum>
.
A simple helper script utilizing bash
, sha256sum
, and other filesystem utilities can automate this step. Your directory structure should look like this:
$ tree / tmp /images
/ tmp/images
└── v2
└── alpine
├── blobs
│ ├── sha256:09de0793c07346ac2912153f6569af631291a9874dc94167d534cefc9c2d9c14
│ ├── sha256: 184b14480d317057da092a0994ad6baf4b2df588108f43969f8fd56f021af2c6
│ ├── sha256:1960ae9fcc9fba89375bec92e8cbed41d5e4fab7e376ccad186084bbabf9db82
│ . . .
│ ├── sha256:2dbd13a29595c6492a46119969dcda7d2ac35daef926e45ab62c02adb12b5173
│ ├── sha256:51dd5201df48b2831f5894c4a9f615aaba37c5dfed453a0335018807d4b390bf
│ └── sha256: fea1779822bb485f4f88c7736e39baf15e981e3423e7583af4852db45e3c04bb
└── manifests
├── latest
├── sha256:09c8ec8bf0d43a250ba7fed2eb6f242935b2987be5ed921ee06c93008558f980
├── sha256:1c4eef651f65e2f7daee7ee785882ac164b02b78fb74503052a26dc061c90474
├── sha256:1de5eb4a9a6735adb46b2c9c88674c0cfba3444dd4ac2341b3babf1261700529
. . .
├── sha256: a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c
├── sha256: ae871ff1c416b2496ea95b81b00ae446468c9cca84760a9c3fc29282268cec29
└── sha256:fe0dcdd1f78341a54b6d08d0f45d91ae93eb212667d970ad15213a3168c410ee
5 directories, 57 files
3. Set Up Cloudflare R2 Access with rclone
Now that your OCI repository is structured, it’s time to upload it to Cloudflare R2. There are many ways to do that using any S3-compatible client, but we suggest you use rclone, a versatile command-line tool that supports many different storage backends, including R2.
Create an rclone Configuration
Generate a file named ~/.config/rclone/rclone.conf
with your R2 credentials. An example configuration might look like:
[r2-registry]
type = s3
provider = Cloudflare
access_key_id = YOUR_R2_ACCESS_KEY_ID
secret_access_key = YOUR_R2_SECRET_ACCESS_KEY
endpoint = https://YOUR_CLOUDFLARE_ACCOUNT_ID.r2.cloudflarestorage.com
Replace the placeholders with your actual Cloudflare R2 values.
4. Uploading to R2
With your configuration in place, upload your OCI-structured repository using rclone
:
Blobs Upload
Synchronize your blobs
directory to the R2 bucket path /v2/myimage/blobs
by ensuring that the files are publicly accessible (e.g., with public read settings).
Manifests Upload
Similarly, sync the manifests
directory. You may need to set the appropriate HTTP headers (e.g., Content-Type: application/vnd.docker.distribution.manifest.v2+json
) during this upload.
A sample command:
$ rclone copy -p --s3-acl public-read /tmp/images/v2 r2-registry:cr-enam/v2/
Transferred: 3.563 MiB / 27.256 MiB, 13%, 1.745 MiB/s, ETA 135
Checks: 5 / 5, 100%
Transferred: 2 / 52, 4%
Elapsed time: 3.5s
Transferring:
* alpine/blobs/sha256:09…c94167d534cefc9c2d9c14:100% /76.744Ki, 38.370Ki/s, 0s
* alpine/blobs/sha256:18…8f43969f8fd56f021af2c6:100% /3.409Mi, 1.704Mi/s, Os
* alpine/blobs/sha256:19…76ccad186084bbabf9db82:100% /74.868Ki, O/s, -
5. Testing and Validation
Finally, simulate a container image pull from your registry using the bucket’s public URL, according to the custom domain you set up in the previous step.
$ docker pull cr-enam.altinity.io/alpine:latest
latest: Pulling from alpine 6e771e15690e: Pull complete
Digest: sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c
Status: Downloaded newer image for alpine: latest
cr-enam.altinity.io/alpine:latest
The Smarter Way: Automating the process with docker-sync
For many organizations, manually executing each step can be time-consuming and error-prone. That’s where docker-sync comes in—a turnkey solution that automates the entire process.
What is docker-sync
?
docker-sync
is a streamlined, command-line utility designed to keep registries in sync.
In addition, it lets you automate the entire process of:
- Converting Docker images into OCI format.
- Reorganizing the file structure to meet OCI specifications.
- Uploading the files to your target registry storage, such as Cloudflare R2.
Key Benefits
- End-to-End Automation: No need to manage multiple tools manually.
docker-sync
uses the same underlying library used byskopeo
, and performs R2 operations using the S3 Golang SDK. - Flexible Backends: Whether you’re targeting Cloudflare R2, AWS ECR, GCR, or an S3 bucket,
docker-sync
supports various configurations through a unified YAML configuration file. - Scheduling and Reliability: Easily set up periodic synchronizations to ensure your image mirror stays up-to-date with minimal effort.
Example Usage
First, download your binary from the releases page .
A typical command to synchronize an image using docker-sync
might look like:
$ docker-sync --source docker.io/library/ubuntu \
--target r2:YOUR_CLOUDFLARE_ACCOUNT_ID:YOUR_R2_BUCKET:ubuntu \
--source-username DOCKER_HUB_USERNAME \
--source-password DOCKER_HUB_PAT \
–-target-username YOUR_R2_ACCESS_KEY_ID \
--target-password YOUR_R2_SECRET_ACCESS_KEY
This one-off command:
- Pulls the Ubuntu image from Docker Hub.
- Converts it to OCI format.
- Organizes the repository structure.
- Uploads it to your R2 bucket—all in a single step.
Note: if you are pulling from a public image, you don’t need to specify the –source-username and –source-password parameters.
For additional configuration and automation options, consult the docker-sync documentation by running:
$ docker-sync sync --help
Keep your Docker images in sync
Usage:
docker-sync [flags]
docker-sync [command]
Available Commands:
completion Generate the autocompletion script for the specified shell
help Help about any command
mergeYaml Merge two yaml files
sync Sync a single image
version Gets docker-sync version
writeConfig Write config to file
Flags:
--config string config file (default is config.yaml)
-h, --help help for docker-sync
Use "docker-sync [command] --help" for more information about a command.
Or check the official readme.
Conclusion
Docker Hub’s evolving policies call for innovative solutions. By setting up an OCI-compliant mirror on Cloudflare R2, you bypass restrictive pull limits, ensure global availability, and eliminate egress fees—all while maintaining complete control over your container images.
Whether you choose a hands-on approach for full customization or harness the power of docker-sync
for an automated, streamlined workflow, you are well on your way to creating secure, scalable, and resilient container deployments.
Happy syncing, and here’s to smooth, globally optimized container workflows!
ClickHouse® is a registered trademark of ClickHouse, Inc.; Altinity is not affiliated with or associated with ClickHouse, Inc.