Cloudflare R2
Connect an R2 bucket with an access key scoped to that bucket, and hand readers derived credentials for one root.
Connect a Cloudflare R2 bucket once, and everyone in the Project can read it through Locations without holding its key. R2 has no token service, so Spiral derives each short-lived credential from the stored key itself: a token, signed with the key, that names the bucket, read access, and the root of one Location. R2 checks the signature and the claims on every request, so a derived credential can do no more than the key or the claims allow. See Connections for what readers receive.
An r2 Connection reads s3:// URIs, because R2 serves the S3 API. It stores
your Cloudflare account ID, an R2 access key, and an optional jurisdiction.
Before you start
connection:createin the Project. Theeditorandadminroles have it.- The CLI, signed in with
spiral login, or Spiral Studio. - Permission to create R2 API tokens in the Cloudflare account that owns the bucket.
Create Connections on the branch main. All branches share one credential
for each Connection, so Spiral creates and drops Connections only on main.
Create an R2 API token
In the Cloudflare dashboard, open R2 → Manage API tokens and create an API token:
- Set the permission to Object Read only.
- Under Specify bucket(s), select Apply to specific buckets only, and
select the one bucket that the Connection names, such as
example-robotics. - Create the token, and keep its access key ID and secret access key. The dashboard shows the secret only once.
Do not use an Admin token or one that applies to all buckets. Spiral derives credentials only within what the stored key can do, so a broader key is the ceiling of every credential, and of any leak.
Create the Connection
The CLI reads the secret from a file or from stdin, never from an argument:
commit = client.project("demo").branch("main").commit()
credential = ConnectionsCredential(
r2_access_key=ConnectionsR2AccessKey(
account_id="0123456789abcdef0123456789abcdef",
access_key_id="fedcba9876543210fedcba9876543210",
secret_access_key=Path("r2-secret-access-key.txt").read_text().strip(),
)
)
created = commit.create_connection(
"robotics-r2", "s3://example-robotics/", credential, description="Robot recordings in R2, read-only"
)
head = commit.publish()
print(f"Connection {created.connection_id} at commit {head.commit_id}")The account ID has 32 hexadecimal digits, as the dashboard shows it. Spiral
derives the endpoint from it, such as
https://0123456789abcdef0123456789abcdef.r2.cloudflarestorage.com. For a
bucket created in a jurisdiction, add --jurisdiction eu, fedramp, or
us.
In Studio, select Create resource → Connection, select the provider Cloudflare R2, and enter the URI, the account ID, the access key ID, and the secret access key.
Verify the result
Test the Connection. Spiral first lists the bucket with the stored key, because a derived credential reaches R2 only at its first read. It then derives a credential and lists one page of objects with it:
head = client.project("demo").branch("main")
found = client.catalog.resolve_path("demo", head.commit_id, CatalogResolvePathRequest(path=[name]))
if found.resource is None:
raise LookupError(f"no Connection named {name}")
result = client.connections.test_connection(found.resource.descriptor.resource_id)
print(f"credential accepted: {result.vend.succeeded}")
if result.access is not None:
print(f"storage reachable: {result.access.succeeded}")See Check that a Connection works for each step.
Revoke the key
Spiral derives credentials without calling R2, so it does not learn that you revoked the key in Cloudflare. Readers fail at their next read, because R2 can no longer verify the token. To cut off access in Spiral as well, update the credential with a new key, or drop the Connection.
Reads only
An r2 Connection allows only read. R2's only scope that writes also
replaces and deletes objects, and Spiral never replaces or deletes objects
through a Connection, so keep the stored key at Object Read only.
Next step
Create a Location with a root
inside the Connection URI, such as s3://example-robotics/recordings/.