Bring your own object storage (external S3 / R2 / MinIO / B2)
This page is about pointing an app's object storage at your own S3-compatible store instead of deploymill's managed Cloudflare R2. For the managed-R2 default and how to use the injected
S3_*env vars in app code, seedeploymill://guides/object-storage/nodeanddeploymill://guides/object-storage/python. For persistent disk (volume mounts), seedeploymill://guides/storage.
By default a managed object-storage bucket lives on deploymill's R2 account. With bring-your-own object storage you register your own S3-compatible store once per org and select it per app, so the app's runtime objects live in your bucket (data residency, ownership, cost control). Any S3-compatible endpoint works: AWS S3, Cloudflare R2, MinIO, Backblaze B2.
This is a two-part flow: a one-time register (workspace admin, in the dashboard) and a per-app select (in .deploymill/project.json).
1. Register your store (one-time, per org, in the dashboard)
Registering a store is workspace administration, so it's done by an org admin in the dashboard (API-first carve-out), not through an MCP tool. The agent never sees your secret keys. The admin provides:
- endpoint: the S3 API endpoint, e.g.
https://s3.us-east-1.amazonaws.com(AWS),https://<account>.r2.cloudflarestorage.com(R2), or your MinIO/B2 host. - region: e.g.
us-east-1(useautofor R2). - bucket: an existing bucket in your account that deploymill may use.
- accessKeyId / secretAccessKey: credentials scoped to that bucket.
- forcePathStyle: set true for MinIO and some Backblaze B2 endpoints (path-style addressing,
host/bucket/key). Leave false for AWS S3 / R2.
Not sure where these values come from on your provider? See Provider setup recipes below for click-by-click AWS S3 and Cloudflare R2 walkthroughs (including the exact IAM policy the connect probe needs).
On submit, deploymill runs a validation probe, a real put/get/delete round-trip against the bucket, so a misconfigured store fails immediately at registration, not at first write. The probe also checks whether your credentials can create buckets, which decides the isolation mode (below). Credentials are stored encrypted (AES-256-GCM) and are never returned by any API or shown to the agent.
The register call returns a connection id, the opaque handle you reference from project.json.
Isolation mode (chosen automatically)
- bucket-per-app: if your credentials can create/delete buckets, each app (and each preview) gets its own dedicated bucket in your account. Strongest isolation, mirroring how the managed R2 default works.
- shared-bucket prefix: if your credentials are object-only (can't create buckets), all apps share the one registered bucket and each app is confined to a key prefix (
<app>/…). deploymill injects an extraS3_PREFIXenv var so the app knows its slice, and teardown deletes only that prefix, never the bucket, never another app's data.
You don't choose this. The probe picks the safe option your credentials support.
Provider setup recipes (gather the register values)
The hard part is creating the bucket and scoped credentials on your provider. That's what the values above come from. The credentials must already allow object read/write on the bucket, or the registration probe fails with a 403 AccessDenied ("no identity-based policy allows the s3:PutObject action"). Pick your provider:
AWS S3
- Create the bucket (S3 console → Create bucket), e.g.
my-app-storageinus-east-1. Keep Block all public access on. deploymill serves objects through presigned URLs, so the bucket never needs to be public. - Create an IAM user (IAM console → Users → Create user), e.g.
dm-s3-storage. No console access needed, this is a programmatic-only user. - Attach a scoped inline policy to that user (replace
my-app-storagewith your bucket name in both ARNs). This is the minimum for shared-bucket prefix mode (object read/write/delete + list):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DeploymillObjectRW",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::my-app-storage/*"
},
{
"Sid": "DeploymillBucketList",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::my-app-storage"
}
]
}
- Create an access key for the user (Security credentials → Create access key → Application running outside AWS). Copy the Access key ID and Secret access key.
- Register with: endpoint
https://s3.<region>.amazonaws.com(e.g.https://s3.us-east-1.amazonaws.com), region your bucket's region, bucket the name from step 1, the two keys from step 4, forcePathStyle off.
If the probe still 403s after attaching the policy, check for a restrictive bucket policy or an org SCP with an explicit
Deny. An explicit deny anywhere overrides the allow above.
Cloudflare R2
- Create the bucket (Cloudflare dashboard → R2 → Create bucket), e.g.
my-app-storage. - Create an R2 API token (R2 → Manage R2 API Tokens → Create API token) with the Object Read & Write permission, scoped to that bucket. R2 tokens can't create buckets, so R2 always lands in shared-bucket prefix mode. That's expected.
- Copy the token's Access Key ID and Secret Access Key, and note your account's S3 API endpoint:
https://<account-id>.r2.cloudflarestorage.com(shown on the token page and the bucket's settings). - Register with: endpoint the R2 S3 URL from step 3, region
auto, bucket the name from step 1, the token's keys, forcePathStyle off.
MinIO / Backblaze B2 / other S3-compatible
Any S3-compatible endpoint works. Create the bucket and an access key/secret with object read/write on it through that provider's console, then register with its endpoint and region. Set forcePathStyle on for MinIO and some Backblaze B2 endpoints (they use path-style addressing, host/bucket/key). Leave it off for endpoints that support virtual-host addressing.
2. Select it for an app (.deploymill/project.json)
Point an app at the registered store by naming its connection id:
{
"storage": { "provider": "s3", "connection": "<connection-id>" }
}
Then run reconcile_project. deploymill provisions the app's bucket (or prefix) on your store and injects the same env vars your app already uses for managed storage:
S3_ENDPOINT,S3_REGION,S3_BUCKET,S3_ACCESS_KEY_ID,S3_SECRET_ACCESS_KEY: identical to the managed-R2 case, so no app code changes are needed to switch stores.S3_PREFIX: present only in shared-bucket prefix mode. When set, write and read objects under this prefix (e.g.${S3_PREFIX}uploads/cat.png). Absent ⇒ the app owns its whole bucket.
The managed-R2 client code in the Node/Python object-storage guides works unchanged. Just honor S3_PREFIX when it's present.
Previews
A preview of a BYO-storage app gets its own isolated slice of your store (a fresh bucket in bucket mode, a distinct key prefix in prefix mode), so preview writes never touch production blobs, the same isolation guarantee as managed R2.
Switching stores (new-objects-only)
Changing an app's storage backend (managed R2 → your store, or one connection → another) is new-objects-only: deploymill points the app at the new store, but does not migrate pre-existing objects, and does not delete the old store's data. Objects written before the switch stay where they were. Re-upload or re-point them if the app needs them at the new location. (A copy/migration helper is planned as a follow-up.)
Entitlement
Managed object storage, including bring-your-own, is a paying-plan capability. On the free Explore tier, registering a store returns upgrade_required. Add a card to enable it.
What this does NOT do
- It does not give deploymill broad access to your account, only the bucket (or prefix) it provisions for your apps.
- Forgetting a connection (deleting it in the dashboard) removes deploymill's stored credentials. It does not delete any data in your bucket.
- Backups-to-your-own-bucket is a separate, later capability that builds on this one.