Docs / Wire managed object storage (S3/R2) into a Node app
Guidedeploymill://guides/storage/node

Wiring managed object storage (S3/R2) into a Node app (deploymill)

You are an agent updating a deploymill-managed Node app that has just had a managed object-storage bucket provisioned. These env vars are already set in the app's environment and will be present in the container at boot:

  • S3_ENDPOINT — the S3-compatible API endpoint (Cloudflare R2 today).
  • S3_REGION — the region (auto for 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.

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;

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 } from "./storage.js";

export async function putObject(key, body, contentType) {
  await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: body, ContentType: contentType }));
}

export async function getObject(key) {
  const r = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: 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: prefix }));
  return (r.Contents ?? []).map((o) => o.Key);
}

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: key }), { expiresIn });
}

Add @aws-sdk/s3-request-presigner to dependencies if you use pre-signing.

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: true for R2.

After wiring this up, commit via push_files and call deploy.