Wiring managed object storage (S3/R2) into a Node app
Looking for persistent disk (named-volume mounts, e.g. a cache or on-disk index)? That's
deploymill://guides/storage. This page is about object storage (a managed S3-compatible bucket).
You are an agent updating a DeployMill-managed Node app that has just had a managed object-storage bucket provisioned. Object storage is a managed S3-compatible bucket for blobs, distinct from a persistent volume mount (single-host disk) and from the managed database (structured/relational data).
Precondition: the bucket is already provisioned, so these env vars are set in the app's environment and present in the container at boot. Done looks like: the app reads/writes objects through one module-scope client and never logs the credentials.
S3_ENDPOINT: the S3-compatible API endpoint (Cloudflare R2 today).S3_REGION: the region (autofor R2).S3_BUCKET: the bucket name to read/write.S3_ACCESS_KEY_ID/S3_SECRET_ACCESS_KEY: bucket-scoped credentials. Treat the secret as sensitive: never log it or echo it back to a client.S3_PREFIX: present only when several apps share one bring-your-own bucket (prefix isolation). It ends in/and is the key prefix your app's objects must stay under. It is absent on the managed R2 default and on a bucket-per-app connection, where the app owns the whole bucket. When it is set, prepend it to every key you read or write — that prefix is the isolation boundary. (Theput_object/get_object/delete_objectMCP tools below take keys relative to the prefix and add it for you.)
Use object storage for large or long-lived blobs: user-uploaded images/video, datasets, anything you serve to clients. Do not use a volume mount for that. A volume is single-host, fixed-size, and can't be served directly. Use Postgres (database) for structured/relational data, not blobs.
Follow these steps in order. Each step is a concrete file edit, not advice.
1. Add the S3 client
Add the AWS S3 SDK (it speaks the S3 API, which R2 implements) to dependencies in package.json:
"@aws-sdk/client-s3": "^3.600.0"
There is no R2-specific SDK. The S3 SDK pointed at S3_ENDPOINT is the supported path.
2. Create a single client module
Create src/storage.js (or src/storage.ts):
import { S3Client } from "@aws-sdk/client-s3";
for (const k of ["S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY"]) {
if (!process.env[k]) {
throw new Error(`${k} is not set. Add \`storage: { provider: "r2" }\` to .deploymill/project.json and run reconcile_project.`);
}
}
export const BUCKET = process.env.S3_BUCKET;
// Empty unless this app shares a bucket with others (prefix isolation). Route
// every key through objectKey() so the same code works either way.
export const PREFIX = process.env.S3_PREFIX || "";
export const objectKey = (key) => `${PREFIX}${key}`;
export const s3 = new S3Client({
region: process.env.S3_REGION || "auto",
endpoint: process.env.S3_ENDPOINT,
// R2 (and most non-AWS S3 endpoints) require path-style addressing.
forcePathStyle: true,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
});
Create the client once per process at module scope, not per request.
3. Upload, fetch, and list objects
import { PutObjectCommand, GetObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
import { s3, BUCKET, PREFIX, objectKey } from "./storage.js";
export async function putObject(key, body, contentType) {
await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: objectKey(key), Body: body, ContentType: contentType }));
}
export async function getObject(key) {
const r = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: objectKey(key) }));
return r.Body; // a stream; pipe it to the HTTP response
}
export async function listObjects(prefix = "") {
const r = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET, Prefix: objectKey(prefix) }));
// S3 returns full keys; strip PREFIX back off so callers keep using app-relative keys.
return (r.Contents ?? []).map((o) => o.Key.slice(PREFIX.length));
}
4. Serve blobs without leaking credentials
Stream objects back through your own route (proxy), or, for large public media, generate a pre-signed GET URL so the client fetches directly from the bucket:
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { GetObjectCommand } from "@aws-sdk/client-s3";
export function presignGet(key, expiresIn = 300) {
return getSignedUrl(s3, new GetObjectCommand({ Bucket: BUCKET, Key: objectKey(key) }), { expiresIn });
}
Add @aws-sdk/s3-request-presigner to dependencies if you use pre-signing.
Managing the bucket from outside the app (list_objects / put_object / get_object / delete_object)
To inspect or manage the bucket without going through the app's own code, an agent can call these MCP tools (all ownership-checked, all deriving the bucket name from the app record (the injected S3_BUCKET env only gates whether storage is provisioned, it's never trusted as the name, DET-823), and none ever returning credentials):
list_objects({ applicationId, prefix?, limit?, cursor? })→{ ok: true, bucket, objects: [{ key, size }], count, truncated, nextCursor, limit }. Read-only.prefixnarrows the listing;limitsizes the page (default 200, max 1000). This is ONE page:truncated: truemeans more objects exist, andnextCursoris how you reach them — pass it back ascursorunmodified, keeping the sameprefix, and loop untilnextCursorisnull.put_object({ applicationId, key, content, encoding?, contentType? })→{ ok: true, bucket, key, size, contentType }. Uploadscontenttokey(replacing any existing object).contentis plain text by default. Setencoding:"base64"for binary. Decoded size must be ≤ 10 MB. Push larger blobs through the app's own S3 client (above).get_object({ applicationId, key })→{ ok: true, bucket, key, size, encoding:"base64", content }. Downloads the object. Decodecontentfrom base64. Objects over 10 MB return{ ok:false, errorCode:"object_too_large" }. Fetch those through the app's S3 client.delete_object({ applicationId, key })→{ ok: true, bucket, key, deleted }. Deletes the single object at the exact key (never a prefix match). Idempotent (deleted:falsewhen it was already gone).
Each returns { ok:false, errorCode } on a recoverable precondition. Object storage is a paid capability, so a free Explore org gets upgrade_required (with the upgrade-guide pointer), and an org whose subscription lapsed gets object_storage_locked (data retained 30 days, then deleted). The provisioning/runtime codes are object_storage_not_enabled (reconcile to provision the bucket first), object_store_not_configured, storage_connection_unavailable (the app's bring-your-own storage connection was disconnected — re-register it), object_too_large, or an object_store_* provider code. These are for occasional management (seed a fixture, inspect an upload, clear a test blob). High-volume or large-blob traffic still belongs in app code through the S3 client above.
What NOT to do
- Don't commit credentials. They live only in the app env, injected by reconcile. Never hardcode
S3_*values or write them into the repo. - Don't park blobs on a volume mount. Volumes are single-host and fixed-size. Object storage is the right home for media/datasets.
- Don't create a client per request. One module-scope client.
- Don't assume virtual-hosted-style URLs. Keep
forcePathStyle: truefor R2.
After wiring this up, commit via push_files and call deploy.