Skip to main content
Once you have a bucket and credentials, you can read and write objects three ways:
  1. From the dashboard. Drag-and-drop file uploads, single-file downloads, a tree-style browser.
  2. From an S3-compatible SDK or CLI. AWS SDK for JS / Python / Go, the AWS CLI, boto3, rclone, mc, anything that speaks S3.
  3. With presigned URLs. Generate a short-lived URL on your server and hand it to a browser to upload or download directly, no proxying through your app.
This page covers all three.

Dashboard

Open the bucket’s Objects tab. From there:
  • Upload, click Upload and pick files, or drag them onto the file list. Folder uploads are supported on browsers that allow it.
  • New folder, click New folder to create a path prefix. Note: empty folders are a dashboard convenience; once you upload an object below them, the folder is real on the object store.
  • Download, click a file’s overflow menu and choose Download. Large files stream.
  • Delete, single-file via overflow menu, or select multiple files and click Delete for bulk delete (up to 100 at a time).
  • Search, type into the search box to filter the current prefix.

S3-compatible SDKs

Every Brimble bucket exposes the same S3 endpoints the AWS SDKs expect. Configure the client with:
  • Endpoint: the URL shown on the bucket detail page.
  • Region: the bucket’s region (also on the detail page).
  • Credentials: the access key pair from Storage credentials.
  • forcePathStyle: true (or your SDK’s equivalent). Path-style addressing keeps the bucket name in the URL path instead of the hostname.
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({
  endpoint: process.env.BRIMBLE_S3_ENDPOINT,    // https://<bucket-endpoint>
  region: process.env.BRIMBLE_S3_REGION,
  credentials: {
    accessKeyId: process.env.BRIMBLE_ACCESS_KEY_ID!,
    secretAccessKey: process.env.BRIMBLE_SECRET_ACCESS_KEY!,
  },
  forcePathStyle: true,
});

await s3.send(new PutObjectCommand({
  Bucket: "my-bucket",
  Key: "reports/2026/q1.pdf",
  Body: pdfBytes,
  ContentType: "application/pdf",
}));

const obj = await s3.send(new GetObjectCommand({
  Bucket: "my-bucket",
  Key: "reports/2026/q1.pdf",
}));

Listing and deleting

import { ListObjectsV2Command, DeleteObjectCommand } from "@aws-sdk/client-s3";

const page = await s3.send(new ListObjectsV2Command({
  Bucket: "my-bucket",
  Prefix: "reports/2026/",
  MaxKeys: 100,
}));

for (const obj of page.Contents ?? []) {
  console.log(obj.Key, obj.Size, obj.LastModified);
}

await s3.send(new DeleteObjectCommand({
  Bucket: "my-bucket",
  Key: "reports/2026/q1.pdf",
}));
For very deep listings, page through ContinuationToken until IsTruncated is false. The standard S3 pagination shape applies.

Presigned URLs

A presigned URL is a temporary, signed URL that lets the holder upload or download an object without your credentials being involved. Generate it server-side, hand it to a browser, let the browser talk to the bucket directly.

Presigned download

import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const url = await getSignedUrl(
  s3,
  new GetObjectCommand({ Bucket: "my-bucket", Key: "reports/2026/q1.pdf" }),
  { expiresIn: 60 * 15 } // 15 minutes
);

Presigned upload

import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const uploadUrl = await getSignedUrl(
  s3,
  new PutObjectCommand({
    Bucket: "my-bucket",
    Key: `uploads/${userId}/${Date.now()}-${filename}`,
    ContentType: mimeType,
  }),
  { expiresIn: 60 * 5 } // 5 minutes
);

// Browser does the actual upload:
// await fetch(uploadUrl, { method: "PUT", headers: { "Content-Type": mimeType }, body: file });
Keep expiries short. A 5-minute upload window and a 15-minute download window cover most flows. Longer expiries widen the time a leaked URL can be used.

Multipart upload

For files larger than ~100 MB, use multipart upload. The S3 SDKs handle this for you (@aws-sdk/lib-storage’s Upload class in Node, upload_file in boto3); use those instead of the lower-level multipart APIs.
import { Upload } from "@aws-sdk/lib-storage";
import { createReadStream } from "fs";

const upload = new Upload({
  client: s3,
  params: {
    Bucket: "my-bucket",
    Key: "video/long-cut.mp4",
    Body: createReadStream("./long-cut.mp4"),
    ContentType: "video/mp4",
  },
  partSize: 10 * 1024 * 1024, // 10 MB per part
  queueSize: 4,
});

upload.on("httpUploadProgress", (p) => console.log(p.loaded, "/", p.total));
await upload.done();
Part size minimum is 5 MB (S3 standard), part size maximum is 5 GB. Aborted multipart uploads leave orphan parts; the SDKs clean these up automatically on retry.

Folders

There are no real directories. A path like reports/2026/q1.pdf is one object with a key that contains slashes; “the folder reports/2026/” is just every object whose key starts with that prefix. Creating an empty folder via the dashboard puts a placeholder marker; uploading any object under that prefix removes the placeholder.

Access model

Objects are private. The only way to read or write a bucket is with a valid storage credential, no per-object public toggle, no anonymous public-read URLs. Two ways to hand out access:
  • Server-side, with an Editor or ReadOnly credential. Embed the credential in your app and use the S3 SDK directly.
  • To a browser, with a presigned URL. Generate a short-lived signed GET or PUT server-side and hand it to the client. See Presigned URLs.
To make a static asset reachable from a browser without a presigned URL, put a CDN or your application in front of the bucket and serve the bytes through that. See Storage credentials for the role split.

Troubleshooting

AccessDenied from an S3 client. Most likely the credentials are wrong, expired, or scoped read-only for a write call. Re-check the access key pair, and confirm the credential’s role under Storage credentials. SignatureDoesNotMatch. Region mismatch, the bucket’s region must match what you configured on the S3 client. Copy the region from the bucket detail page. NoSuchBucket. The bucket name in the URL doesn’t exist in this region. Double-check forcePathStyle: true so the bucket name lives in the URL path, not the hostname. Presigned URL “expired” before the user uploaded. Lengthen the expiresIn window, or generate the URL closer to when the user actually uploads (typically after they pick the file). Multipart upload stuck. Aborts happen client-side. Use the SDK’s high-level Upload / upload_file instead of stitching multipart parts yourself; those handle retries and aborts for you.

Next steps

Last modified on May 31, 2026