Self-Hosted S3 Storage After MinIO: Your Options
MinIO community edition is archived. Your options for self-hosted S3-compatible storage (RustFS, Garage), R2 and B2 costs, and keeping presigned uploads working.
You can add S3-compatible blob storage to any app without AWS. Self-host RustFS, Garage or SeaweedFS on a VPS, use a managed store like Cloudflare R2 or Backblaze B2, or use a platform like Temps that runs S3-compatible object storage (RustFS) as a managed service next to your deployments. MinIO, long the default answer, archived its open-source repository in 2026 and is no longer maintained.
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. RustFS, Garage, SeaweedFS, 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.
Can I Use S3-Compatible Blob Storage Without AWS?
Yes — completely. The S3 API (a REST interface using AWS Signature Version 4 authentication) is the de facto standard for object storage, and almost every object store now implements it. That means the same @aws-sdk/client-s3 code that talks to AWS also talks to RustFS, Garage, SeaweedFS, Cloudflare R2, Backblaze B2 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.
Comparison: Temps vs Self-Hosted RustFS or Garage vs Cloudflare R2
| Feature | Temps (managed S3) | RustFS / Garage (self-hosted) | Cloudflare R2 |
|---|---|---|---|
| S3-compatible API | Yes (RustFS-backed) | Yes | Yes |
| Self-hostable | Yes — runs as a container Temps manages on your server | Yes — you run and upgrade it | No — managed only |
| Pricing model | Free self-hosted; Temps Cloud from $29/mo (coming soon) | Free; the server is your cost | $0.015/GB-month, free egress |
| CDN integration | Bring your own (Cloudflare free tier works) | Bring your own | Built into Cloudflare network |
| Wiring into apps | Link the service to a project; S3_* and AWS_* variables are injected | You manage keys and env vars | You manage keys and env vars |
| Backups | Scheduled mirror of buckets to an off-server S3 target | You set it up | Managed by Cloudflare |
| License | MIT / Apache 2.0 (Temps); RustFS is Apache-2.0 | RustFS Apache-2.0; Garage AGPL-3.0 | Proprietary SaaS |
| Includes deployment, analytics, error tracking | Yes — same platform | No | No |
What about MinIO? The minio/minio repository was archived in 2026 and its README says it is no longer maintained; the company points users to its commercial AIStor product. Existing MinIO installs keep working, but don't start new ones on it — Temps itself marks MinIO deprecated for new services.
Object tagging on R2: Cloudflare R2 doesn't implement S3 object tagging (x-amz-tagging and PutObjectTagging). If your workflow depends on object tags, test your backend before switching.
What Is the S3 API?
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.
Core Operations
| 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.
How Do You Set Up an S3-Compatible Storage Backend?
Option 1: RustFS or Garage (Self-Hosted)
RustFS (Apache-2.0) is a Rust-native, S3-compatible object store that runs as a single container, and the closest drop-in for a single-node MinIO. Garage (AGPL-3.0) is built for small, geo-distributed self-hosted clusters, and SeaweedFS (Apache-2.0) suits billions of small files. This guide doesn't reproduce their install steps, because flags and image tags change between releases: follow the project's own Docker instructions, then come back here for the client code. Whichever you choose, mount a persistent volume for the data, put the S3 port behind TLS, and keep the admin credentials out of your app.
Once the store is running, confirm it speaks S3 before touching your app (replace the endpoint and use the access key you created):
AWS_ACCESS_KEY_ID=<key> AWS_SECRET_ACCESS_KEY=<secret> \
aws s3 ls --endpoint-url https://s3.example.com
If you're still on MinIO, it keeps running, but the open-source repository is archived and no longer maintained, so plan a migration with rclone (see the FAQ).
Option 2: Cloudflare R2 (Managed, Zero Egress Fees)
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. If you need tags, test a self-hosted backend instead.
Option 3: Temps Managed S3
Temps runs S3-compatible object storage as a managed service, backed by RustFS, on your own server: you create the service, link it to a project, and Temps injects the endpoint, keys and bucket name into that project's environment. Temps manages the container; you don't run it by hand.
Connecting with the AWS SDK
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 RustFS, Garage, 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 a local RustFS
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
forcePathStyle: true, // Required for most self-hosted 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.
Connecting to Temps Specifically
When you link a Temps S3 service to a project, it injects S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, S3_REGION and S3_BUCKET (plus AWS-style aliases such as AWS_ACCESS_KEY_ID and AWS_ENDPOINT_URL):
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
// Temps injects these variables into linked projects
const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY,
secretAccessKey: process.env.S3_SECRET_KEY,
},
forcePathStyle: true,
});
await s3.send(new PutObjectCommand({
Bucket: process.env.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.
How Do Presigned URLs Enable Direct Browser Uploads?
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.
The Flow
- Browser asks your API: "I want to upload
photo.jpg" - Your API generates a presigned PUT URL (valid 5–15 minutes)
- Browser uploads directly to storage via
fetch PUT - Storage validates the signature and accepts the file
Generating Presigned URLs (Backend)
import { 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 };
}
Uploading from the Browser
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 — RustFS, Garage, Temps, Backblaze B2 — generates and validates presigned URLs the same way. The @aws-sdk/s3-request-presigner package works unmodified across all of them.
Security Rules for Presigned URLs
- Short expiration. Five to fifteen minutes is enough for an upload.
- Validate content type server-side. Restrict the content type in the presigned URL to prevent uploading executables when you expect images.
- Use unique keys. Never let users control the full object key. Prefix with a UUID.
- Set size limits. S3-compatible services support content-length conditions in presigned URLs to reject oversized files before they finish uploading.
What About Multipart Uploads for Large Files?
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, RustFS, 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.
How Does CDN Caching Work with S3-Compatible Storage?
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.
A self-hosted store behind Cloudflare's free tier: Achieves a similar 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.
How Much Does Self-Hosted Storage Actually Save?
AWS S3 charges $0.023/GB-month for Standard storage and $0.09/GB for data transfer out after the first 100 GB a month. For an app storing 100 GB with 500 GB of monthly downloads, that's $2.30 for storage plus $36 for the 400 GB above the free allowance — $38.30/month. Most of that cost is bandwidth, not storage.
| Provider | Storage Cost | Bandwidth Cost | Monthly Total |
|---|---|---|---|
| AWS S3 | $2.30 | $36.00 | $38.30 |
| Cloudflare R2 | $1.35 ($0.015/GB, first 10 GB free) | $0.00 | ~$1.35 + requests |
| Backblaze B2 + Cloudflare | ~$0.63 ($6.95/TB, first 10 GB free) | $0.00 (free via CDN partners) | ~$0.63 |
| RustFS or Garage on a VPS | Share of the VPS* | $0.00** | Your VPS |
| Temps (managed S3) | Share of the VPS* | $0.00** | Your VPS; Temps itself is free |
Prices checked October 2026. Request charges are extra on S3 and R2. *Disk on a server you already run. **Behind a CDN. Self-hosted Temps is free (MIT / Apache 2.0); Temps Cloud, a hosted control plane for your own servers, is coming soon from $29/month.
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.
When AWS S3 Still Makes Sense
- You need 11 nines of durability (99.999999999%) and don't want to manage replication
- You're already deep in the AWS ecosystem (IAM, Lambda, CloudFront)
- Your team lacks ops bandwidth to maintain another service
- You need out-of-the-box compliance certifications (SOC 2, HIPAA)
For many small teams, self-hosted storage plus off-server backups is durable enough, and the bandwidth savings show up on the first bill. Treat a single-disk store as one copy, not as durable storage.
How Does Temps Handle Blob Storage?
Temps runs S3-compatible object storage as a managed service on your own server. From the managed S3 docs:
- RustFS by default — new S3 services use RustFS (Apache-2.0), a Rust-native S3-compatible store. MinIO is available only for existing services and is deprecated.
- Linked, not hand-wired — create the service, link it to a project, and Temps injects
S3_ENDPOINT,S3_HOST,S3_PORT,S3_ACCESS_KEY,S3_SECRET_KEY,S3_REGIONandS3_BUCKET, plus AWS-style aliases (AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_DEFAULT_REGION,AWS_ENDPOINT_URL), so standard SDKs work unchanged. - Backups to another S3 — on the backup schedule you set, Temps mirrors the service's buckets to an off-server S3 target, and you can restore in place or into a new service. There's no point-in-time recovery, so objects written after the last backup aren't covered.
No per-GB, request or bandwidth fees: the storage is your server's disk. Self-hosting Temps is free under MIT / Apache 2.0. Object storage sits next to the rest of the platform — git-push deployments, analytics, error tracking, session replay and uptime monitoring.
FAQ
Is S3-compatible storage as reliable as AWS S3?
AWS S3 offers 99.999999999% (eleven nines) durability through massive cross-region replication. Self-hosted stores such as RustFS, Garage or SeaweedFS can add redundancy with erasure coding or replication across drives and nodes, but they won't match AWS's scale. On a single server, durability comes from your backups: a disk failure loses anything written since the last off-server copy. Decide how much recent data you can afford to lose, and back up at least that often.
Can I migrate existing files from AWS S3 to a self-hosted backend?
Yes. rclone syncs entire buckets between any two S3-compatible endpoints: configure both remotes, then run rclone sync aws:my-bucket selfhosted:my-bucket. It supports parallel transfers and can be re-run to pick up where it left off. The same approach moves an existing MinIO bucket to RustFS or Garage.
Do presigned URLs work the same across all S3-compatible services?
Presigned URLs use SigV4, part of the S3 protocol specification. Any compliant service — RustFS, Garage, 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.
Does Cloudflare R2 support object tagging?
No. Cloudflare R2 doesn't implement S3 object tagging (x-amz-tagging on upload or PutObjectTagging). If your workflow depends on object tags, test a self-hosted backend that supports them before you migrate.
How much storage can a single VPS handle?
A Hetzner CX23 comes with 40 GB of NVMe disk for €5.49/month excluding VAT (checked 7 October 2026). For more, attach a Hetzner Cloud Volume (10 GB to 10 TB); volumes are billed per GB, so a terabyte costs many times the server itself — check the current rate. Hetzner's much cheaper Storage Boxes are backup space accessed over SFTP/SMB, not block storage to run an object store on. If you need multiple terabytes, R2 or B2 is often cheaper than attached block storage.
What S3-compatible backends can replace MinIO?
- RustFS — Rust-native, Apache-2.0, single container; the default engine for Temps managed S3
- Garage — lightweight, AGPL-3.0, designed for small geo-distributed self-hosted clusters
- SeaweedFS — distributed, Apache-2.0, optimized for billions of small files, with an S3 gateway
- Ceph RADOS Gateway — enterprise-grade, complex to operate
- LakeFS — adds Git-like versioning on top of S3 storage (not a store by itself)
For a single server, RustFS is the simplest MinIO replacement; for a small multi-site cluster, Garage. If you want storage managed next to your deployments and observability, Temps runs RustFS for you.
Get weekly updates