Self-Hosted File Storage on Cloudflare R2 with Url-Shorten-Worker
Preface
Previously we implemented Url-Shorten-Worker transforms into Image Hosting, converting images to Base64 and saving to KV
Actually, not only images can be saved, but all kinds of files. PDFs, zips, etc., are all fine.
Then we run into the issue of KV storage capacity limits.
Approach
What if we save files to R2 object storage?
Agent-Oriented Development
Hermes integrated with mimo-v2.5-pro
Hermes integrated with claude-opus-4.8
The natural language I sent to the Agent is in the quote boxes below
Clone the https://github.com/crazypeace/Url-Shorten-Worker project locally, and learn and analyze the entire project
I want to do this new development. You discuss the plan with me, do not implement.
Develop a new system mode
A file storage station file-r2 that saves to R2 object storage
Save images in the R2 database,
Use the link of the R2 database as the value, and save it in KV
The frontend page for user operations stays the same as the imghost project
Many details of design, polishing, testing, repetition, omitted.
Github
Brief Usage Instructions
If you have already set up the previous project by referring to the previous tutorial.
Then you need to:
Update worker.js
Note the settings
theme: "theme/file-r2-lite",
system_type: "file-r2",
Then set 5 variables
R2_ACCOUNT_ID - R2 Account ID
R2_ACCESS_KEY_ID - S3 API Access Key ID
R2_SECRET_ACCESS_KEY - S3 API Secret Access Key
R2_BUCKET_NAME - R2 Bucket Name
R2_PUBLIC_URL - R2 Public Access URL, e.g. https://pub-xxxx.r2.dev
For these 5 variables, I recommend you interact with an AI that has image recognition to guide you step by step. When you don't understand where to click, just take a screenshot and send it to the AI to ask.
Because cloudflare's interface changes over the years. Even if I post screenshots of the operation interface, if cloudflare's interface changes, it will become invalid.
Regardless, I will still post how to get these 5 variables from cloudflare's current interface.
R2_ACCOUNT_ID - R2 Account ID
After you log in to the cloudflare dashboard https://dash.cloudflare.com/, in the browser URL bar, it will show as
https://dash.cloudflare.com/****/home/overview
The long string of numbers and letters in the middle is this information, e.g.
This information doesn't only exist here, it can also be obtained elsewhere. If a tutorial you found or an AI you asked tells you to get it from somewhere else, it may also be correct.R2_BUCKET_NAME - R2 Bucket Name
In cloudflare dashboard - ... - R2 - Overview - "Create bucket"
Enter "Bucket name", click "Create bucket"
The "Bucket name" you entered here is this variable.
R2_PUBLIC_URL - R2 Public Access URL
After you just created the R2 object storage, enter the "Settings" page
Enable "Public Development URL"
The URL displayed https://pub-****.r2.dev/ is this variable
* What is shown above is a URL for development and debugging.
If following cloudflare's recommended usage, you would use your own domain.
If you successfully added your own domain using this method,
https://your added domain is this variable
R2_ACCESS_KEY_ID - S3 API Access Key ID
R2_SECRET_ACCESS_KEY - S3 API Secret Access Key
In cloudflare dashboard - ... - R2 - Overview - Manage
Select "Object Read & Write" - "Specify bucket(s)" - Select the one you just created
The generated Access Key ID and Secret Access Key are these variables
========
Postscript
It's been a project from a long time ago. I always had the idea, and I knew it was feasible in principle, but I was too lazy to do it.
Now it's great, there's AI to do the work, I just need to wag my tongue.
Used opus-4.8 to solve a difficulty
At that time, uploading files to R2 failed. mimo-2.5-pro told me it was a CORS issue, but after setting CORS, the upload still failed.
It couldn't be solved no matter what.
At this point, I picked up an opus 4.8 from a group. I told it, I switched you to a new model, please review all the code again.
Then the problem was found.
I didn't blindly believe "this is a CORS issue", but used curl to bypass the browser and directly test the signature — the result located the real root cause.
The real bug: signature error, not CORS
R2 returned the CanonicalRequest it computed in the 403 response. Comparing with ours, it was clear at a glance:
- R2 expected: X-Amz-Credential=...2cff98bb...%2F20260609%2Fauto%2Fs3%2Faws4_request (slashes encoded as %2F)
- We generated: X-Amz-Credential=...2cff98bb.../20260609/auto/s3/aws4_request (slashes not encoded)
In presigned URLs, the slashes in query parameter values must be encoded as %2F. When R2 reconstructs the canonical request, it calculates based on the encoded values, so the signatures on both sides never match → SignatureDoesNotMatch → 403. The browser displayed this 403 as "CORS header missing", leading us astray.
========
Related Recommendations
"ShareX uploads images to R2 object storage via S3 uploader"
"file-r2-worker File Storage Station Cloudflare R2 Object Storage"
"file-r2-worker File Storage Station Cloudflare R2 Object Storage"