Skip to main content
Cloudflare R2 keeps uploaded images and video in a bucket instead of on the server’s disk. It is the option when you run more than one PostQueen container, or want media on a CDN address. Whether you need it at all: Uploads and storage.

Before you start

Set it up

1

Create the bucket

In the Cloudflare dashboard, open R2 object storage and choose Create bucket. Give it a name and keep the default location and the Standard storage class.
If your media has to stay in one region, for example the EU, choose a jurisdiction now. It cannot be changed later, and a bucket with one also needs CLOUDFLARE_JURISDICTION below.
2

Create a token for this bucket only

On R2 object storage, open Overview and choose Manage next to API Tokens under Account Details. Create an Account API token with the Object Read & Write permission, applied to this bucket only.After you create it, Cloudflare shows the Access Key ID and the Secret Access Key once. Copy both, and your Account ID from the same page.
Do not give this token every bucket in the account. It lives in an environment variable on a web server.
3

Set the variables

.env
A bucket with a jurisdiction answers only on its own endpoint, <account-id>.eu.r2.cloudflarestorage.com. Without CLOUDFLARE_JURISDICTION, PostQueen asks the account-wide endpoint and reports the bucket as missing.
4

Choose where media is read from

CLOUDFLARE_BUCKET_URL is the address stored with every file. Browsers load it, and several networks (Instagram, Facebook and Threads among them) download from it with their own servers. It has to be public HTTPS.
Never use the S3 API endpoint, <account-id>.r2.cloudflarestorage.com. It refuses reads without a signature, so every image breaks.
Through PostQueen, the backend reads each file from the bucket and streams it back, cached for a year. It answers only for file names PostQueen itself writes: a random name with an image, video or audio extension. Even so, keep the bucket for PostQueen media only. The /api in the address is required: the container’s nginx serves /uploads/ from the local disk, and only /api/ reaches the backend.
5

Set the CORS policy

By default the browser uploads straight to the bucket, which is a cross-origin request the bucket has to allow. In the bucket’s Settings, edit the CORS Policy and use your own address:
ETag in ExposeHeaders is required: the upload is sent in parts, and the browser has to read each part’s ETag to finish it. Skip this step if you set UPLOAD_VIA_SERVER=true, where the browser never talks to R2.
6

Recreate the container

Recreate the app container so it reads the new values. A plain docker restart or docker compose restart keeps the old container and its old environment.In the folder that holds docker-compose.yaml, recreate only the postqueen service:
The database, Redis and Temporal keep running. Avoid docker compose down for a settings change: on a host that runs other Compose services in the same project, it stops them too. On Kubernetes, see Kubernetes with Helm.
Upload an image in Media. It appears in the library, and the object is in the bucket.
Files uploaded before the switch stay where they were written, on the local disk, and keep their old addresses.

Good to know

  • Back up the bucket and the database together. The database stores only each file’s address. Restore them from the same moment: Backups and upgrades.
  • Give backups their own token, with read access to this bucket and write access to the backup bucket, so a backup tool cannot delete the media it protects.
  • Unfinished uploads clean themselves up. A multipart upload that never completes leaves parts that appear in no listing. Every R2 bucket has a default lifecycle rule that aborts them after seven days.
Last modified on September 23, 2026