Organize a Document Bucket

CloudflareBeginner
Practice Now

Introduction

Your support team needs a small document store. An object is a file's bytes plus metadata; a bucket groups objects, and a key is an object's full name. Slashes in keys make useful prefixes, but do not create ordinary filesystem directories. You will create a private bucket, upload two synthetic documents, inspect their metadata, download exact bytes, and remove only the selected document before cleaning up.

Complete Connect LabEx to Your Cloudflare Account first. It teaches the LabEx terminal, device authorization, learning-account confirmation and account-ID configuration. This lab starts independently in /home/labex/project/r2-lab with Node.js 22.22.0, Wrangler 4.131.1 and AWS SDK 3.888.0 prepared. On your own computer, install Node.js first, then install Wrangler and the AWS SDK as project dependencies with npm install.

Before starting: your learning account must have an active R2 subscription. Cloudflare's R2 setup includes a checkout flow; review it yourself if R2 is not active. A Free account does not automatically activate R2. Read pricing for storage and operation charges. This exercise uses tiny synthetic files and no purchased domain. You need bucket-management permission and permission to create a user R2 token scoped to this new bucket. Keep public access disabled. Never paste credentials into this lesson, chat or screenshots.

Create your private document bucket

In this step, you authorize this VM and create one disposable bucket. Device authorization confirms your learning account. R2 bucket management uses a separate API token restricted to that account.

Start Bash for the command syntax used below, then move to the prepared project and check its tools. Keep this same terminal open so your resource-name variables remain available:

bash
cd /home/labex/project/r2-lab
export PATH="$PWD/.tools/node-v22.22.0-linux-x64/bin:$PATH"
node --version
npx wrangler --version

Authorize the displayed device code in your own browser. Confirm the learning account and the requested account and user read scopes before granting consent:

npx wrangler login --device --browser=false --scopes account:read user:read
npx wrangler whoami --json

Require loggedIn: true. Read the account name even if only one is listed. Replace YOUR_ACCOUNT_ID below with that account's actual 32-character ID. openssl rand -hex 6 generates twelve random hexadecimal characters so this lab cannot collide with a previous run. The here-document writes a standard configuration file; the shell substitutes your variables into it.

ACCOUNT_ID=YOUR_ACCOUNT_ID
RUN_ID=$(openssl rand -hex 6)
NAME="labex-c05-r01-$RUN_ID"
BUCKET="$NAME-docs"
cat > wrangler.jsonc <<JSON
{"name":"$NAME","account_id":"$ACCOUNT_ID","compatibility_date":"2026-07-30","r2_buckets":[{"binding":"DOCUMENTS","bucket_name":"$BUCKET"}]}
JSON

For bucket management, open your Cloudflare profile's API Tokens page and create a custom token named after this lab. Grant Account → Workers R2 Storage → Edit, and restrict Account Resources to the learning account whose ID you saved. Set a short expiry. Do not include other accounts or unrelated permissions. This account-level permission creates/deletes buckets; the object-only token in the next step cannot do that.

Copy the token once into this hidden VM prompt. umask 077 restricts the file to your user; read -s hides input. The file uses Wrangler's standard token variable and is excluded from Git.

umask 077
read -r -s -p 'R2 management API token: ' R2_MANAGEMENT_TOKEN; printf '\n'
printf 'CLOUDFLARE_API_TOKEN=%s\n' "$R2_MANAGEMENT_TOKEN" > .env.management
unset R2_MANAGEMENT_TOKEN

Use --env-file=.env.management only for R2 management commands; ordinary whoami continues to check the VM's device authorization.

Put --env-file at the end of each Wrangler command so its list of file arguments does not consume the command name. After creating each bucket, if Wrangler asks whether to add a binding to your configuration, type n and press Enter. The configuration already contains the intended binding.

npx wrangler r2 bucket create "$BUCKET" --env-file=.env.management

List your buckets and find the exact generated name. Other buckets belong to other work; leave them alone.

npx wrangler r2 bucket list --env-file=.env.management

In Dashboard, open Storage & databases → R2 → Overview, select this exact bucket, and inspect its empty object list. In its settings, leave the public development URL and custom domains disabled. A bucket name in Dashboard confirms identity; later download checks prove the stored bytes.

A private Standard bucket with no objects

This example shows Standard storage and Public Access Disabled. Your generated bucket name will differ.

Upload documents with metadata

In this step, you give the SDK access to only this bucket and store two documents. Content type tells a client how to interpret bytes; custom metadata stores your own small labels alongside the object. Neither is an access-control rule.

The S3-compatible API gives standard storage SDKs access to R2. It uses a separate access-key pair rather than Wrangler's device token. In R2 Overview, use Account Details → Manage API Tokens, then create a User API token named after this lab's generated resource name. Choose Object Read & Write, restrict it to this exact new bucket, and select a short expiry if the form offers one. Do not choose all buckets or Admin access. Keep this token page available until you have stored the one-time secret.

Use the following Bash prompts in the VM. read -s hides input; umask 077 makes the credential file readable only by your user. These names are the standard AWS SDK environment variables. Paste the Access Key ID and Secret Access Key into their respective prompts, then press Enter. Do not paste the general API token value.

In the token form, select 24 hours under TTL, then review the exact bucket and Object Read & Write permission before creating the token. Revoke it when the lab ends; the expiry is only a fallback.

umask 077
read -r -s -p 'Access Key ID: ' AWS_ACCESS_KEY_ID; printf '\n'
read -r -s -p 'Secret Access Key: ' AWS_SECRET_ACCESS_KEY; printf '\n'
printf 'AWS_ACCESS_KEY_ID=%s\nAWS_SECRET_ACCESS_KEY=%s\n' "$AWS_ACCESS_KEY_ID" "$AWS_SECRET_ACCESS_KEY" > .env.s3
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY

Write a reusable standard SDK client. The SDK requires a region string; R2 uses auto. Reading the existing configuration keeps CLI and SDK operations aimed at the same account and bucket.

cat > storage.mjs <<'JS'
import { S3Client } from "@aws-sdk/client-s3";
import { readFileSync } from "node:fs";
const config = JSON.parse(readFileSync("wrangler.jsonc", "utf8"));
export const Bucket = config.r2_buckets[0].bucket_name;
export const s3 = new S3Client({
  region: "auto",
  endpoint: `https://${config.account_id}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
  }
});
JS

Now create the upload program. PutObjectCommand stores bytes at the given key. Both documents are synthetic; the retained handbook will prove that a later selected delete does not erase unrelated keys.

cat > upload.mjs <<'JS'
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { readFileSync } from "node:fs";
import { s3, Bucket } from "./storage.mjs";
await s3.send(new PutObjectCommand({
  Bucket, Key: "documents/report.txt", Body: readFileSync("document.txt"),
  ContentType: "text/plain", Metadata: { team: "blue", revision: "1" }
}));
await s3.send(new PutObjectCommand({
  Bucket, Key: "retained/handbook.txt", Body: readFileSync("retained.txt"),
  ContentType: "text/plain"
}));
console.log("Uploaded two synthetic documents");
JS

--env-file loads the credential values without printing them:

node --env-file=.env.s3 upload.mjs

The success line is printed only after both awaited API calls complete. The platform check independently reads the real objects and metadata.

List metadata and compare downloaded bytes

In this step, you inspect keys without downloading every object, then retrieve the report. ListObjectsV2 lists keys, while HeadObject retrieves metadata only. This tiny bucket fits in one listing page; production listings must follow continuation tokens when IsTruncated is true.

cat > inspect.mjs <<'JS'
import { ListObjectsV2Command, HeadObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { writeFileSync } from "node:fs";
import { s3, Bucket } from "./storage.mjs";
const page = await s3.send(new ListObjectsV2Command({ Bucket }));
console.log(page.Contents.map(object => object.Key));
const metadata = await s3.send(new HeadObjectCommand({ Bucket, Key: "documents/report.txt" }));
console.log({ contentType: metadata.ContentType, metadata: metadata.Metadata });
const object = await s3.send(new GetObjectCommand({ Bucket, Key: "documents/report.txt" }));
writeFileSync("download.txt", await object.Body.transformToByteArray());
JS
node --env-file=.env.s3 inspect.mjs

The list contains documents/report.txt and retained/handbook.txt. The report has text/plain, team: blue and revision: 1. Metadata output ordering can differ.

cmp compares bytes and prints nothing when the files match. The following message appears only if the comparison succeeds:

cmp document.txt download.txt && printf "Downloaded bytes match\n"

Refresh the same bucket's Dashboard object list and open the report's details. Compare its key and content type with the SDK output. Use the CLI output as the metadata evidence if the current Dashboard does not expose a custom metadata field.

Report object type, custom metadata and preview

The example shows text/plain, revision 1, team blue and the synthetic report preview. Your bucket name and creation date will differ.

Remove only the selected report

In this step, you delete one full object key while keeping the handbook. A prefix is not a directory to remove recursively; pass exactly the report key to the API.

cat > remove-report.mjs <<'JS'
import { DeleteObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
import { s3, Bucket } from "./storage.mjs";
await s3.send(new DeleteObjectCommand({ Bucket, Key: "documents/report.txt" }));
const page = await s3.send(new ListObjectsV2Command({ Bucket }));
console.log(page.Contents.map(object => object.Key));
JS
node --env-file=.env.s3 remove-report.mjs

Only retained/handbook.txt remains. The platform check also downloads the handbook to confirm that its content remains unchanged. Run that check before proceeding to total cleanup.

Clean up the owned bucket

In this step, you remove the remaining object and then its empty bucket. Keep your credentials active until remote deletion has been confirmed.

cat > cleanup.mjs <<'JS'
import { DeleteObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
import { s3, Bucket } from "./storage.mjs";
await s3.send(new DeleteObjectCommand({ Bucket, Key: "retained/handbook.txt" }));
const page = await s3.send(new ListObjectsV2Command({ Bucket }));
console.log("Remaining objects:", page.KeyCount);
JS
node --env-file=.env.s3 cleanup.mjs

Require Remaining objects: 0. Read the generated bucket name from the configuration if you opened a new terminal; node -p prints that one field.

BUCKET=$(node -p "JSON.parse(require('fs').readFileSync('wrangler.jsonc')).r2_buckets[0].bucket_name")
npx wrangler r2 bucket delete "$BUCKET" --env-file=.env.management

Confirm only the exact lab bucket when prompted. List buckets again; the successful listing must omit that name. An authentication or network error does not establish deletion.

npx wrangler r2 bucket list --env-file=.env.management

Refresh the same Dashboard list, then run this step's platform check while still logged in.

Revoke the lab credential and log out

In this step, you close access left by this exercise. On the R2 API Tokens page, revoke only the object token named for this lab. On your profile API Tokens page, revoke the separate R2 management token you created for this lab. Deleting a bucket does not revoke a token, and Wrangler logout does not revoke S3 credentials.

After revocation, remove the local credential file and log this VM out:

rm .env.s3 .env.management
npx wrangler logout

Inspect structured identity. Its nonzero status is expected when logged out:

npx wrangler whoami --json || true

Require loggedIn: false; keep your ordinary Dashboard login. The platform checks local credential removal and Wrangler logout. Both token revocations are manual Dashboard checkpoints in this candidate; it is not inferred from file deletion.

Summary

You created a private R2 bucket, stored object bytes and metadata, listed and downloaded documents, proved a selective deletion, and cleaned up bucket access.