March 12, 2026 (3mo ago)
Written by Temps Team
Last updated March 12, 2026 (3mo ago)
You can add S3-compatible blob storage to any app without AWS. Use MinIO on a VPS, Cloudflare R2, Backblaze B2, or a platform like Temps that bundles S3-compatible object storage as a built-in feature alongside deployment, analytics, and error tracking — all in one self-hosted binary.
This guide covers the options, the tradeoffs, and the code to connect any of them with the standard @aws-sdk/client-s3 library. The S3 API is an open protocol, not an AWS exclusive. Your upload code works identically against any compliant backend.
TL;DR: The S3 API is a protocol, not an AWS product. MinIO, Cloudflare R2, Backblaze B2, and Temps all speak it. Presigned URLs let browsers upload directly without routing bytes through your server. Self-hosted storage behind a CDN eliminates bandwidth bills entirely.
Yes — completely. The S3 API (a REST interface using AWS Signature Version 4 authentication) has become the universal standard for object storage. According to Gartner's 2024 Cloud Infrastructure report, over 90% of object storage solutions now advertise S3 API compatibility. That means the same @aws-sdk/client-s3 code that talks to AWS also talks to MinIO, Cloudflare R2, Backblaze B2, RustFS, and Temps — with only the endpoint URL changing.
The practical consequence: build against the S3 API once and you're never locked to a provider. Changing backends is one environment variable.
| Feature | Temps | MinIO | Cloudflare R2 |
|---|---|---|---|
| S3-compatible API | Yes (RustFS-backed) | Yes (native) | Yes |
| Self-hostable | Yes — single Rust binary | Yes — single binary | No — managed only |
| Pricing model | ~$6/mo Cloud (Hetzner cost + 30%), free self-hosted | Free OSS, commercial for enterprise | See R2 pricing page |
| Object tagging | Via S3 API (RustFS) | Yes | No — 501 NotImplemented |
| CDN integration | Bring your own (Cloudflare free tier works) | Bring your own | Built into Cloudflare network |
| Managed service option | Yes — Temps Cloud | MinIO Cloud (see pricing page) | Yes (only option) |
| Access control | Per-project isolation | IAM-style policies | S3-compatible ACLs |
| License | Apache 2.0 | GNU AGPL 3.0 (OSS), commercial | Proprietary SaaS |
| Includes deployment, analytics, error tracking | Yes — one binary | No | No |
Important on R2 object tagging: Cloudflare R2 returns 501 NotImplemented on both the x-amz-tagging upload header and PutObjectTagging. If your workflow depends on S3 object tags, R2 is not a compatible replacement. MinIO supports tagging natively. Temps uses RustFS as its S3 backend, which implements S3 object tagging at the protocol level.
The S3 API is a REST interface for storing and retrieving binary objects in named buckets. Authentication uses AWS Signature Version 4 (SigV4) — a cryptographic signature derived from your access key, the current timestamp, the region, and the request body. Any service that implements SigV4 correctly accepts the same SDK calls as AWS S3.
| Operation | HTTP Method | What It Does |
|---|---|---|
| CreateBucket | PUT /{bucket} | Create a storage namespace |
| PutObject | PUT /{bucket}/{key} | Upload a file |
| GetObject | GET /{bucket}/{key} | Download a file |
| DeleteObject | DELETE /{bucket}/{key} | Remove a file |
| ListObjects | GET /{bucket}?list-type=2 | List files in a bucket |
| HeadObject | HEAD /{bucket}/{key} | Get metadata without downloading |
That covers 80% of what most apps need. Multipart uploads, lifecycle policies, and versioning cover the remaining edge cases.
MinIO is the most popular open-source S3-compatible object storage server, with over 50,000 GitHub stars.
docker run -d \
--name minio \
-p 9000:9000 \
-p 9001:9001 \
-v /data/minio:/data \
-e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=your-secret-key-here \
quay.io/minio/minio server /data --console-address ":9001"
Port 9000 serves the S3 API. Port 9001 gives you a web console. The /data volume persists files across restarts.
R2 is S3-compatible, lives inside Cloudflare's network, and charges no egress fees. It is a managed service — not self-hostable. Configure it via the Cloudflare dashboard, then connect with the S3 SDK using https://<account_id>.r2.cloudflarestorage.com as the endpoint.
Note: R2 does not support S3 object tagging (returns 501). If you need tags, choose MinIO or Temps instead.
Temps includes S3-compatible blob storage as a built-in feature. No separate MinIO instance. No AWS credentials. No extra Docker container to manage. Storage is powered by RustFS, a high-performance S3-compatible storage engine written in Rust. You get a project-isolated bucket for every deployed app, with environment variables injected automatically.
The key insight: you use the exact same @aws-sdk/client-s3. Only the endpoint changes.
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
// Works against MinIO, Cloudflare R2, Temps, or AWS S3
const s3 = new S3Client({
region: "us-east-1", // Required by the SDK, often ignored by non-AWS backends
endpoint: process.env.S3_ENDPOINT, // e.g. http://localhost:9000 for MinIO
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
forcePathStyle: true, // Required for MinIO and most S3-compatible services
});
// Upload a file
await s3.send(new PutObjectCommand({
Bucket: "user-uploads",
Key: `avatars/${userId}.webp`,
Body: fileBuffer,
ContentType: "image/webp",
}));
// Download a file
const response = await s3.send(new GetObjectCommand({
Bucket: "user-uploads",
Key: `avatars/${userId}.webp`,
}));
const fileBytes = await response.Body.transformToByteArray();
Notice forcePathStyle: true. AWS uses virtual-hosted-style URLs (bucket.s3.amazonaws.com/key), but most S3-compatible services use path-style URLs (endpoint/bucket/key). This one flag handles the difference.
When you deploy on Temps, blob storage environment variables are injected automatically:
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
// Temps injects these variables automatically for deployed apps
const s3 = new S3Client({
endpoint: process.env.TEMPS_S3_ENDPOINT,
region: "auto",
credentials: {
accessKeyId: process.env.TEMPS_S3_ACCESS_KEY,
secretAccessKey: process.env.TEMPS_S3_SECRET_KEY,
},
forcePathStyle: true,
});
await s3.send(new PutObjectCommand({
Bucket: process.env.TEMPS_S3_BUCKET,
Key: `uploads/${file.name}`,
Body: fileBuffer,
ContentType: file.type,
}));
This code is fully portable. Move off Temps: change the endpoint and credentials. Your upload logic, presigned URL generation, and multipart handling stay identical.
Presigned URLs eliminate the need to proxy file uploads through your backend. Your server generates a signed permission, the browser uploads directly to storage, and your server never touches the file bytes.
photo.jpg"fetch PUTimport { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: "auto",
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
forcePathStyle: true,
});
export async function generateUploadUrl(fileName: string, contentType: string) {
const key = `uploads/${crypto.randomUUID()}/${fileName}`;
const command = new PutObjectCommand({
Bucket: "user-uploads",
Key: key,
ContentType: contentType,
});
const url = await getSignedUrl(s3, command, {
expiresIn: 600, // 10 minutes
});
return { url, key };
}
async function uploadFile(file: File) {
// Step 1: Get a presigned URL from your API
const response = await fetch("/api/upload-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileName: file.name, contentType: file.type }),
});
const { url, key } = await response.json();
// Step 2: Upload directly to storage — your server never touches the bytes
await fetch(url, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
return key;
}
Presigned URLs use SigV4, which is part of the S3 protocol specification. Any compliant service — MinIO, Temps, Backblaze B2 — generates and validates presigned URLs the same way. The @aws-sdk/s3-request-presigner package works unmodified across all of them.
The S3 protocol supports multipart uploads for files larger than 5 MB. AWS recommends multipart for anything over 100 MB.
import {
S3Client,
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand,
} from "@aws-sdk/client-s3";
async function multipartUpload(s3: S3Client, bucket: string, key: string, file: Buffer) {
const { UploadId } = await s3.send(new CreateMultipartUploadCommand({
Bucket: bucket,
Key: key,
}));
const partSize = 10 * 1024 * 1024; // 10MB parts
const parts = [];
for (let i = 0; i < file.length; i += partSize) {
const partNumber = Math.floor(i / partSize) + 1;
const chunk = file.slice(i, i + partSize);
const { ETag } = await s3.send(new UploadPartCommand({
Bucket: bucket,
Key: key,
UploadId,
PartNumber: partNumber,
Body: chunk,
}));
parts.push({ PartNumber: partNumber, ETag });
}
await s3.send(new CompleteMultipartUploadCommand({
Bucket: bucket,
Key: key,
UploadId,
MultipartUpload: { Parts: parts },
}));
}
This same code works against AWS S3, MinIO, Cloudflare R2, and Temps. Only the endpoint changes.
For browser-based multipart uploads, libraries like @uppy/aws-s3-multipart handle splitting, parallel uploading, and resuming failed parts across any S3-compatible backend.
Serving files directly from your storage backend works for low traffic. Once you're handling thousands of requests per second, a CDN adds a critical caching layer.
Browser → CDN (Cloudflare/CloudFront) → S3-Compatible Storage
↑ |
└── Cache HIT ───┘ (File served from CDN edge, storage not hit)
Set Cache-Control headers when uploading:
await s3.send(new PutObjectCommand({
Bucket: "public-assets",
Key: "images/hero.webp",
Body: imageBuffer,
ContentType: "image/webp",
CacheControl: "public, max-age=31536000, immutable",
}));
For user-generated content that changes, use content-addressed keys (include a hash in the filename so new versions get new URLs) with shorter cache times.
Cloudflare R2: Zero egress fees and built into Cloudflare's CDN network. No egress billing is a real advantage for read-heavy workloads. Just note: R2 is managed-only and lacks object tagging support.
MinIO behind Cloudflare free tier: Achieves the same egress-free result with full control over your data and no storage vendor lock-in.
If you're running a deployment platform like Temps, storage running on the same server as your app eliminates network latency between app and storage. The CDN handles global distribution while your origin serves from a fast local path.
AWS S3 charges $0.023/GB for storage and $0.09/GB for bandwidth. For an app storing 100 GB with 500 GB of monthly downloads, that's $2.30 for storage plus $45 for bandwidth — $47.30/month. Most of that cost is bandwidth, not storage.
| Provider | Storage Cost | Bandwidth Cost | Monthly Total |
|---|---|---|---|
| AWS S3 | $2.30 | $45.00 | $47.30 |
| Cloudflare R2 | See pricing page | $0.00 | See pricing page |
| Backblaze B2 + Cloudflare | See pricing page | $0.00 | See pricing page |
| MinIO on a VPS | ~$1.00* | $0.00** | ~$6.00 |
| Temps (built-in storage) | Included | Included | ~$6.00*** |
*Portion of VPS cost allocated to storage. **Behind CDN. ***Temps Cloud pricing covers compute, storage, and all built-in platform features.
The bandwidth trap is what catches developers off guard. S3's storage pricing looks reasonable until your files get popular. Self-hosted storage behind a CDN sidesteps this entirely.
For most indie hackers, startups, and small teams, self-hosted storage is more than durable enough, and the cost difference pays for itself in month one.
Temps includes S3-compatible blob storage as a built-in feature. No separate MinIO instance. No AWS credentials. No additional configuration needed.
Three verified capabilities from the Temps blob crate:
S3-compatible API via RustFS — Temps uses RustFS (rustfs/rustfs:1.0.0-beta.6), a high-performance S3-compatible storage engine written in Rust, as the blob backend. The RustFS source notes 2.3x faster throughput than MinIO for small object payloads.
Per-project isolation — each deployed project gets its own isolated storage namespace. Your files cannot bleed into another project's bucket.
Full blob operation set — put, delete, head, list, download, and copy operations are all implemented, with presigned URL generation for direct browser uploads.
TEMPS_S3_ENDPOINT, TEMPS_S3_ACCESS_KEY, TEMPS_S3_SECRET_KEY, TEMPS_S3_BUCKET injected into deployed appsTemps replaces Vercel (deployment), PostHog/Plausible (analytics), FullStory (session replay), Sentry (error tracking), and Pingdom (uptime monitoring) — all from a single Rust binary with a git-push workflow. Blob storage is one piece of that unified platform.
AWS S3 offers 99.999999999% (eleven nines) durability through massive cross-region replication. Self-hosted solutions like MinIO achieve high durability with erasure coding across multiple drives, but they won't match AWS's scale of redundancy. For most apps, a single-server setup with regular backups is more than sufficient. The real question is whether you need eleven nines or whether five nines — trivial to achieve with daily backups — covers your use case.
Yes. Tools like rclone and mc (MinIO Client) sync entire buckets between any two S3-compatible endpoints. Run rclone sync s3:my-bucket minio:my-bucket and every object copies with metadata preserved. Both tools support parallel transfers and resume-on-failure. A 100 GB bucket typically migrates in under an hour on a standard VPS connection.
Presigned URLs use SigV4, part of the S3 protocol specification. Any compliant service — MinIO, Temps, Backblaze B2 — generates and validates them identically. The @aws-sdk/s3-request-presigner package works without modification across all of them. The only difference is the base URL in the signed output.
No. Cloudflare R2 returns 501 NotImplemented on both the x-amz-tagging upload header and the PutObjectTagging API call. If your workflow depends on S3 object tags, use MinIO or Temps instead.
A Hetzner CX22 with 40 GB of NVMe storage costs around $4/month. Add a 1 TB storage volume for approximately $4.35/month and you have over a terabyte of S3-compatible storage for under $9/month total. MinIO on that setup can sustain 1 Gbps throughput for reads and writes — enough to serve over 10,000 image downloads per minute. Most apps won't outgrow a single VPS for years.
For most developers, MinIO hits the sweet spot between features and operational simplicity. If you want storage bundled with deployment and observability, Temps removes the need to manage the storage layer entirely.