{"schemaVersion":1,"id":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris","title":"S3-Compatible Object Storage: MinIO, Tigris, R2, and Multipart Upload Internals","description":"A technical walkthrough of S3-compatible object storage internals, from multipart upload mechanics and presigned URL security to building your own CDN layer with edge caching on top of MinIO, Tigris, and Cloudflare R2.","language":"en","datePublished":"2025-11-05","dateModified":null,"contentFormat":"text/markdown","content":"Infrastructure\n\n# S3-Compatible Object Storage: MinIO, Tigris, R2, and Multipart Upload Internals\n\n2025-11-059 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris>)\n\n## The S3 API: A De Facto Standard\n\nThe S3 API has become the TCP/IP of object storage. Every serious storage system implements it because the ecosystem of tools, SDKs, and mental models is too valuable to ignore. But \"S3-compatible\" covers a spectrum from \"mostly works\" to \"bit-for-bit identical responses.\"\n\nThe core operations are deceptively simple:\n\n```\nPutObject → Write bytes to a key\nGetObject → Read bytes from a key\nDeleteObject → Remove a key\nListObjectsV2 → Enumerate keys with prefix filtering\n```\n\nWhere things get interesting, and where compatibility breaks down, is in the advanced operations: multipart uploads, presigned URLs, bucket policies, and event notifications.\n\n## Multipart Upload Internals\n\nSingle ` PutObject ` calls cap at 5GB on S3 (and most compatible services). For anything larger, or for anything where you want resumable uploads, multipart uploads are required. The following illustrates the sequence:\n\n```javascript\n// Step 1: Initiate, server returns an UploadId\nconst { UploadId } = await s3.send(new CreateMultipartUploadCommand({\n Bucket: 'my-bucket',\n Key: 'large-video.mp4',\n ContentType: 'video/mp4',\n}));\n\n// Step 2: Upload parts, each gets an ETag back\nconst partSize = 25 * 1024 * 1024; // 25MB chunks\nconst parts = [];\n\nfor (let i = 0; i < totalParts; i++) {\n const start = i * partSize;\n const end = Math.min(start + partSize, fileSize);\n\n const { ETag } = await s3.send(new UploadPartCommand({\n Bucket: 'my-bucket',\n Key: 'large-video.mp4',\n UploadId,\n PartNumber: i + 1,\n Body: fileStream.slice(start, end),\n }));\n\n parts.push({ PartNumber: i + 1, ETag });\n}\n\n// Step 3: Complete, server assembles the object\nawait s3.send(new CompleteMultipartUploadCommand({\n Bucket: 'my-bucket',\n Key: 'large-video.mp4',\n UploadId,\n MultipartUpload: { Parts: parts },\n}));\n```\n\nWhat the SDK hides from you: each part is stored independently on the server until ` CompleteMultipartUpload ` is called. This means abandoned multipart uploads leak storage. Buckets with terabytes of orphaned parts are common because nobody configured lifecycle rules to clean them up.\n\n```json\n{\n \"Rules\": [{\n \"ID\": \"abort-incomplete-multipart\",\n \"Status\": \"Enabled\",\n \"AbortIncompleteMultipartUpload\": {\n \"DaysAfterInitiation\": 3\n }\n }]\n}\n```\n\nOn MinIO, this lifecycle rule works identically. On Tigris and R2, the implementation differs slightly, Tigris auto-cleans after 24 hours by default, and R2 requires you to set it through the dashboard or API.\n\n### Part Size Strategy\n\nThis is where most implementations get it wrong. The S3 spec allows parts between 5MB and 5GB, with a maximum of 10,000 parts per upload. A naive 5MB part size means your maximum file size is ~50GB. Bump it to 100MB parts and you can handle nearly a terabyte.\n\n```javascript\nfunction calculatePartSize(fileSize) {\n const MIN_PART = 5 * 1024 * 1024; // 5MB\n const MAX_PARTS = 10000;\n\n // Start with 5MB, double until we fit within 10k parts\n let partSize = MIN_PART;\n while (Math.ceil(fileSize / partSize) > MAX_PARTS) {\n partSize *= 2;\n }\n\n // But also consider network conditions,\n // larger parts mean fewer requests but bigger retry penalties\n const networkAdjusted = Math.max(partSize, 25 * 1024 * 1024);\n\n return networkAdjusted;\n}\n```\n\nBenchmarks on a 10GB file upload across all three services from a São Paulo datacenter show the following. MinIO (self-hosted, same region) completed in 47 seconds with 25MB parts. R2 took 63 seconds. Tigris, surprisingly, hit 51 seconds thanks to their distributed edge writes. The bottleneck was almost always the network, not the storage backend.\n\n## Presigned URLs: The Security Layer Nobody Thinks About\n\nPresigned URLs are the mechanism that lets you give temporary, scoped access to objects without sharing credentials. The signature is computed using HMAC-SHA256 over a canonical request string.\n\n```python\nimport boto3\nfrom botocore.config import Config\n\ns3 = boto3.client('s3',\n endpoint_url='https://minio.internal:9000',\n aws_access_key_id='AKIAEXAMPLE',\n aws_secret_access_key='secret',\n config=Config(signature_version='s3v4'),\n)\n\n# Generate a presigned PUT URL for direct browser upload\nupload_url = s3.generate_presigned_url(\n 'put_object',\n Params={\n 'Bucket': 'user-uploads',\n 'Key': f'avatars/{user_id}/{filename}',\n 'ContentType': 'image/jpeg',\n },\n ExpiresIn=3600,\n)\n```\n\nThe critical detail: the ` ContentType ` in the presigned URL parameters must match exactly what the client sends. If you presign for ` image/jpeg ` and the browser sends ` image/png `, the request fails with ` SignatureDoesNotMatch `. This trips up nearly every team at least once.\n\nFor downloads with R2, there's a neat trick, you can use [Cloudflare](<https://cloudflare.com/>) Workers as a presigning proxy that also injects cache headers:\n\n```javascript\n// Cloudflare Worker acting as presigned URL generator\nexport default {\n async fetch(request, env) {\n const url = new URL(request.url);\n const key = url.pathname.slice(1);\n\n const object = await env.R2_BUCKET.get(key);\n if (!object) return new Response('Not found', { status: 404 });\n\n const headers = new Headers();\n headers.set('Content-Type', object.httpMetadata.contentType);\n headers.set('Cache-Control', 'public, max-age=86400, immutable');\n headers.set('ETag', object.httpEtag);\n\n return new Response(object.body, { headers });\n }\n};\n```\n\n## MinIO in Production: Erasure Coding and Monitoring\n\n[MinIO](<https://min.io/>) serves well for cases requiring data sovereignty (healthcare data, government contracts). The minimum production deployment uses erasure coding with a 4-drive setup:\n\n```bash\n# docker-compose.yml, production MinIO with erasure coding\nservices:\n minio:\n image: minio/minio:RELEASE.2026-01-15T00-00-00Z\n command: server /data{1...4} --console-address \":9001\"\n environment:\n MINIO_ROOT_USER: ${MINIO_ROOT_USER}\n MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}\n MINIO_PROMETHEUS_AUTH_TYPE: public\n volumes:\n - /mnt/disk1:/data1\n - /mnt/disk2:/data2\n - /mnt/disk3:/data3\n - /mnt/disk4:/data4\n healthcheck:\n test: [\"CMD\", \"mc\", \"ready\", \"local\"]\n interval: 30s\n timeout: 5s\n retries: 3\n```\n\nErasure coding with 4 drives gives you ` EC:2 ` by default, you can lose any 2 drives and still read all data. The math behind it is Reed-Solomon coding, the same algorithm used in QR codes and CDs. MinIO splits each object into data and parity shards across the drives.\n\nFor monitoring, MinIO exposes a Prometheus endpoint at ` /minio/v2/metrics/cluster `. The critical metrics to alert on:\n\n```yaml\n# Prometheus alert rules\ngroups:\n - name: minio\n rules:\n - alert: MinIODiskOffline\n expr: minio_cluster_disk_offline_total > 0\n for: 5m\n labels:\n severity: critical\n - alert: MinIOHighLatency\n expr: histogram_quantile(0.99, minio_s3_requests_duration_seconds_bucket) > 2\n for: 10m\n labels:\n severity: warning\n - alert: MinIOCapacity\n expr: (minio_cluster_capacity_usable_free_bytes / minio_cluster_capacity_usable_total_bytes) < 0.15\n for: 1h\n labels:\n severity: warning\n```\n\n## Tigris: The Globally Distributed Newcomer\n\nTigris takes a different approach, it's built on FoundationDB and distributes objects across edge locations automatically. You write to a single endpoint and reads are served from the closest region. The pricing model is simpler than S3 too: you pay for storage and requests, with free egress.\n\nA notable feature is their metadata search capability:\n\n```bash\n# Tigris supports querying object metadata directly\naws s3api list-objects-v2 \\\n --bucket my-bucket \\\n --query \"Contents[?Metadata.project=='website']\" \\\n --endpoint-url https://fly.storage.tigris.dev\n```\n\nThe catch: Tigris is still young. Edge cases appeared in their multipart upload implementation in late 2025 where ` ListParts ` returned parts in a different order than S3. It's been fixed since, but it's a reminder that \"S3-compatible\" is aspirational for newer services.\n\n## Building a CDN Layer with Edge Caching\n\nHere is a proven architecture for a media-heavy application, object storage as origin, with a caching layer that handles the edge distribution:\n\n```\nClient → Cloudflare (CDN) → Origin Worker → R2/MinIO\n ↓\n Cache (Tiered)\n ├── L1: Edge (each PoP, 1h TTL)\n └── L2: Regional (shared, 24h TTL)\n```\n\nThe origin worker does the heavy lifting:\n\n```javascript\nasync function handleRequest(request, env) {\n const url = new URL(request.url);\n const cacheKey = new Request(url.toString(), request);\n const cache = caches.default;\n\n // Check L1 edge cache\n let response = await cache.match(cacheKey);\n if (response) {\n return new Response(response.body, {\n headers: {\n ...Object.fromEntries(response.headers),\n 'X-Cache': 'HIT',\n },\n });\n }\n\n // Fetch from R2 origin\n const objectKey = url.pathname.slice(1);\n const object = await env.R2_BUCKET.get(objectKey);\n\n if (!object) {\n return new Response('Object not found', { status: 404 });\n }\n\n // Build response with aggressive caching for immutable assets\n const isImmutable = objectKey.includes('/immutable/');\n const cacheControl = isImmutable\n ? 'public, max-age=31536000, immutable'\n : 'public, max-age=3600, stale-while-revalidate=86400';\n\n response = new Response(object.body, {\n headers: {\n 'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',\n 'Cache-Control': cacheControl,\n 'ETag': object.httpEtag,\n 'X-Cache': 'MISS',\n },\n });\n\n // Store in edge cache (non-blocking)\n request.method === 'GET' && cacheControl !== 'no-store'\n && event.waitUntil(cache.put(cacheKey, response.clone()));\n\n return response;\n}\n```\n\n### Cache Invalidation\n\nThe hard part, as always. The recommended approach: content-addressable keys for static assets (hash in the filename), and short TTLs + ` stale-while-revalidate ` for dynamic content.\n\n```javascript\n// Content-addressable upload, the key IS the cache buster\nimport { createHash } from 'crypto';\n\nfunction contentAddressableKey(buffer, originalName) {\n const hash = createHash('sha256').update(buffer).digest('hex').slice(0, 12);\n const ext = originalName.split('.').pop();\n return `immutable/${hash}.${ext}`;\n}\n```\n\nFor cases where you need instant invalidation (like a user updating their profile picture), a redirect layer works well:\n\n```\n/avatars/user-123 → 302 → /immutable/a1b2c3d4e5f6.jpg\n```\n\nUpdate the redirect pointer, and the old cached object naturally expires. The new pointer gets cached with a short TTL. No purge API calls, no cache stampedes.\n\n## Cost Comparison: Real Numbers\n\nTracked costs for a workload doing 500GB storage, 2TB egress/month, 10M GET requests:\n\n| Service | Storage | Egress | Requests | Total/mo |\n| --- | --- | --- | --- | --- |\n| AWS S3 | $11.50 | $180.00 | $4.00 | $195.50 |\n| Cloudflare R2 | $7.50 | $0.00 | $3.60 | $11.10 |\n| Tigris | $10.00 | $0.00 | $2.50 | $12.50 |\n| MinIO (self-hosted) | ~$40\\* | $0.00 | $0.00 | ~$40.00 |\n\n\\*MinIO cost is the server rental, 4x 1TB NVMe on Hetzner.\n\nThe egress cost on S3 is the killer. If your objects are frequently accessed, R2 or Tigris will save you a fortune. If you need data sovereignty or have compliance requirements, MinIO on your own metal is the way, but you're paying with operational overhead instead of dollars.\n\n## Wrapping Up\n\nObject storage is one of those things that seems simple until you're debugging why your multipart upload failed at part 9,437 of a 50GB file at 3 AM. The S3 API being a de facto standard is genuinely great, switching between MinIO, R2, and Tigris is mostly a configuration change.\n\nA recommended stack: R2 for public assets (zero egress is unbeatable), MinIO for compliance-sensitive data, and Tigris for workloads where global distribution matters. Presigned URLs for all client-facing operations, content-addressable keys for cache management, and lifecycle rules to clean up after incomplete uploads.\n\nThe infrastructure is the boring part. Which means it should work without you thinking about it.\n\n## Frequently asked questions\n\n### Is MinIO truly production-ready for large-scale workloads?\n\nYes, but with caveats. MinIO handles petabyte-scale storage well when deployed with erasure coding and proper monitoring. The key is understanding that it's not a managed service, you own the operational overhead. For most teams, starting with Tigris or R2 and moving to self-hosted MinIO only when you need data sovereignty or cost control at scale is the pragmatic path.\n\n### How do presigned URLs differ from IAM-based access for object storage?\n\nPresigned URLs encode the authorization into the URL itself using HMAC signatures. They're time-limited, scoped to a specific operation (GET, PUT), and don't require the client to have any AWS credentials. IAM-based access requires the client to hold and manage credentials. Use presigned URLs for browser uploads and temporary sharing; use IAM for server-to-server communication.\n\n### Can Cloudflare R2 replace S3 entirely?\n\nFor many workloads, yes. R2 has zero egress fees which changes the economics dramatically. The main gaps are lifecycle policies (still maturing), event notifications (limited compared to S3 EventBridge), and the ecosystem of tools that assume S3-specific features like S3 Select or Glacier transitions. If you don't need those, R2 is a serious contender.\n\n## Tips\n\n1. **Tip:** Always set ` partSize ` dynamically based on file size, 5MB for files under 100MB, 25MB+ for larger objects. Fixed part sizes are the fastest way to waste bandwidth on retries.\n2. **Tip:** Presigned URLs should have the shortest TTL you can get away with. A recommended default is ` 3600 ` seconds for uploads, ` 900 ` for downloads. If the flow needs longer, the flow has a design problem.\n3. **Tip:** When running MinIO in production, always deploy with erasure coding (` minio server /data{1...4} `). Single-disk mode is for dev only, A disk failure eating an invoice archive demonstrates the importance of this.\n\n- s3\n- minio\n- tigris\n- cloudflare-r2\n- object-storage\n- multipart-upload\n- presigned-urls\n- cdn\n- edge-caching\n- infrastructure\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"S3-Compatible Object Storage: MinIO, Tigris, R2, and Multipart Upload Internals","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#title"},{"level":2,"text":"The S3 API: A De Facto Standard","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#the-s3-api-a-de-facto-standard"},{"level":2,"text":"Multipart Upload Internals","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#multipart-upload-internals"},{"level":3,"text":"Part Size Strategy","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#part-size-strategy"},{"level":2,"text":"Presigned URLs: The Security Layer Nobody Thinks About","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#presigned-urls-the-security-layer-nobody-thinks-about"},{"level":2,"text":"MinIO in Production: Erasure Coding and Monitoring","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#minio-in-production-erasure-coding-and-monitoring"},{"level":2,"text":"Tigris: The Globally Distributed Newcomer","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#tigris-the-globally-distributed-newcomer"},{"level":2,"text":"Building a CDN Layer with Edge Caching","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#building-a-cdn-layer-with-edge-caching"},{"level":3,"text":"Cache Invalidation","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#cache-invalidation"},{"level":2,"text":"Cost Comparison: Real Numbers","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#cost-comparison-real-numbers"},{"level":2,"text":"Wrapping Up","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#wrapping-up"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#questions"},{"level":3,"text":"Is MinIO truly production-ready for large-scale workloads?","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#question-1"},{"level":3,"text":"How do presigned URLs differ from IAM-based access for object storage?","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#question-2"},{"level":3,"text":"Can Cloudflare R2 replace S3 entirely?","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#article-tips"}],"sections":[{"heading":"S3-Compatible Object Storage: MinIO, Tigris, R2, and Multipart Upload Internals","level":1,"url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris","content":"Infrastructure"},{"heading":"S3-Compatible Object Storage: MinIO, Tigris, R2, and Multipart Upload Internals","level":1,"url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#title","content":"2025-11-059 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris>)"},{"heading":"The S3 API: A De Facto Standard","level":2,"url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#the-s3-api-a-de-facto-standard","content":"The S3 API has become the TCP/IP of object storage. Every serious storage system implements it because the ecosystem of tools, SDKs, and mental models is too valuable to ignore. But \"S3-compatible\" covers a spectrum from \"mostly works\" to \"bit-for-bit identical responses.\"\n\nThe core operations are deceptively simple:\n\n```\nPutObject → Write bytes to a key\nGetObject → Read bytes from a key\nDeleteObject → Remove a key\nListObjectsV2 → Enumerate keys with prefix filtering\n```\n\nWhere things get interesting, and where compatibility breaks down, is in the advanced operations: multipart uploads, presigned URLs, bucket policies, and event notifications."},{"heading":"Multipart Upload Internals","level":2,"url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#multipart-upload-internals","content":"Single ` PutObject ` calls cap at 5GB on S3 (and most compatible services). For anything larger, or for anything where you want resumable uploads, multipart uploads are required. The following illustrates the sequence:\n\n```javascript\n// Step 1: Initiate, server returns an UploadId\nconst { UploadId } = await s3.send(new CreateMultipartUploadCommand({\n Bucket: 'my-bucket',\n Key: 'large-video.mp4',\n ContentType: 'video/mp4',\n}));\n\n// Step 2: Upload parts, each gets an ETag back\nconst partSize = 25 * 1024 * 1024; // 25MB chunks\nconst parts = [];\n\nfor (let i = 0; i < totalParts; i++) {\n const start = i * partSize;\n const end = Math.min(start + partSize, fileSize);\n\n const { ETag } = await s3.send(new UploadPartCommand({\n Bucket: 'my-bucket',\n Key: 'large-video.mp4',\n UploadId,\n PartNumber: i + 1,\n Body: fileStream.slice(start, end),\n }));\n\n parts.push({ PartNumber: i + 1, ETag });\n}\n\n// Step 3: Complete, server assembles the object\nawait s3.send(new CompleteMultipartUploadCommand({\n Bucket: 'my-bucket',\n Key: 'large-video.mp4',\n UploadId,\n MultipartUpload: { Parts: parts },\n}));\n```\n\nWhat the SDK hides from you: each part is stored independently on the server until ` CompleteMultipartUpload ` is called. This means abandoned multipart uploads leak storage. Buckets with terabytes of orphaned parts are common because nobody configured lifecycle rules to clean them up.\n\n```json\n{\n \"Rules\": [{\n \"ID\": \"abort-incomplete-multipart\",\n \"Status\": \"Enabled\",\n \"AbortIncompleteMultipartUpload\": {\n \"DaysAfterInitiation\": 3\n }\n }]\n}\n```\n\nOn MinIO, this lifecycle rule works identically. On Tigris and R2, the implementation differs slightly, Tigris auto-cleans after 24 hours by default, and R2 requires you to set it through the dashboard or API."},{"heading":"Part Size Strategy","level":3,"url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#part-size-strategy","content":"This is where most implementations get it wrong. The S3 spec allows parts between 5MB and 5GB, with a maximum of 10,000 parts per upload. A naive 5MB part size means your maximum file size is ~50GB. Bump it to 100MB parts and you can handle nearly a terabyte.\n\n```javascript\nfunction calculatePartSize(fileSize) {\n const MIN_PART = 5 * 1024 * 1024; // 5MB\n const MAX_PARTS = 10000;\n\n // Start with 5MB, double until we fit within 10k parts\n let partSize = MIN_PART;\n while (Math.ceil(fileSize / partSize) > MAX_PARTS) {\n partSize *= 2;\n }\n\n // But also consider network conditions,\n // larger parts mean fewer requests but bigger retry penalties\n const networkAdjusted = Math.max(partSize, 25 * 1024 * 1024);\n\n return networkAdjusted;\n}\n```\n\nBenchmarks on a 10GB file upload across all three services from a São Paulo datacenter show the following. MinIO (self-hosted, same region) completed in 47 seconds with 25MB parts. R2 took 63 seconds. Tigris, surprisingly, hit 51 seconds thanks to their distributed edge writes. The bottleneck was almost always the network, not the storage backend."},{"heading":"Presigned URLs: The Security Layer Nobody Thinks About","level":2,"url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#presigned-urls-the-security-layer-nobody-thinks-about","content":"Presigned URLs are the mechanism that lets you give temporary, scoped access to objects without sharing credentials. The signature is computed using HMAC-SHA256 over a canonical request string.\n\n```python\nimport boto3\nfrom botocore.config import Config\n\ns3 = boto3.client('s3',\n endpoint_url='https://minio.internal:9000',\n aws_access_key_id='AKIAEXAMPLE',\n aws_secret_access_key='secret',\n config=Config(signature_version='s3v4'),\n)\n\n# Generate a presigned PUT URL for direct browser upload\nupload_url = s3.generate_presigned_url(\n 'put_object',\n Params={\n 'Bucket': 'user-uploads',\n 'Key': f'avatars/{user_id}/{filename}',\n 'ContentType': 'image/jpeg',\n },\n ExpiresIn=3600,\n)\n```\n\nThe critical detail: the ` ContentType ` in the presigned URL parameters must match exactly what the client sends. If you presign for ` image/jpeg ` and the browser sends ` image/png `, the request fails with ` SignatureDoesNotMatch `. This trips up nearly every team at least once.\n\nFor downloads with R2, there's a neat trick, you can use [Cloudflare](<https://cloudflare.com/>) Workers as a presigning proxy that also injects cache headers:\n\n```javascript\n// Cloudflare Worker acting as presigned URL generator\nexport default {\n async fetch(request, env) {\n const url = new URL(request.url);\n const key = url.pathname.slice(1);\n\n const object = await env.R2_BUCKET.get(key);\n if (!object) return new Response('Not found', { status: 404 });\n\n const headers = new Headers();\n headers.set('Content-Type', object.httpMetadata.contentType);\n headers.set('Cache-Control', 'public, max-age=86400, immutable');\n headers.set('ETag', object.httpEtag);\n\n return new Response(object.body, { headers });\n }\n};\n```"},{"heading":"MinIO in Production: Erasure Coding and Monitoring","level":2,"url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#minio-in-production-erasure-coding-and-monitoring","content":"[MinIO](<https://min.io/>) serves well for cases requiring data sovereignty (healthcare data, government contracts). The minimum production deployment uses erasure coding with a 4-drive setup:\n\n```bash\n# docker-compose.yml, production MinIO with erasure coding\nservices:\n minio:\n image: minio/minio:RELEASE.2026-01-15T00-00-00Z\n command: server /data{1...4} --console-address \":9001\"\n environment:\n MINIO_ROOT_USER: ${MINIO_ROOT_USER}\n MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}\n MINIO_PROMETHEUS_AUTH_TYPE: public\n volumes:\n - /mnt/disk1:/data1\n - /mnt/disk2:/data2\n - /mnt/disk3:/data3\n - /mnt/disk4:/data4\n healthcheck:\n test: [\"CMD\", \"mc\", \"ready\", \"local\"]\n interval: 30s\n timeout: 5s\n retries: 3\n```\n\nErasure coding with 4 drives gives you ` EC:2 ` by default, you can lose any 2 drives and still read all data. The math behind it is Reed-Solomon coding, the same algorithm used in QR codes and CDs. MinIO splits each object into data and parity shards across the drives.\n\nFor monitoring, MinIO exposes a Prometheus endpoint at ` /minio/v2/metrics/cluster `. The critical metrics to alert on:\n\n```yaml\n# Prometheus alert rules\ngroups:\n - name: minio\n rules:\n - alert: MinIODiskOffline\n expr: minio_cluster_disk_offline_total > 0\n for: 5m\n labels:\n severity: critical\n - alert: MinIOHighLatency\n expr: histogram_quantile(0.99, minio_s3_requests_duration_seconds_bucket) > 2\n for: 10m\n labels:\n severity: warning\n - alert: MinIOCapacity\n expr: (minio_cluster_capacity_usable_free_bytes / minio_cluster_capacity_usable_total_bytes) < 0.15\n for: 1h\n labels:\n severity: warning\n```"},{"heading":"Tigris: The Globally Distributed Newcomer","level":2,"url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#tigris-the-globally-distributed-newcomer","content":"Tigris takes a different approach, it's built on FoundationDB and distributes objects across edge locations automatically. You write to a single endpoint and reads are served from the closest region. The pricing model is simpler than S3 too: you pay for storage and requests, with free egress.\n\nA notable feature is their metadata search capability:\n\n```bash\n# Tigris supports querying object metadata directly\naws s3api list-objects-v2 \\\n --bucket my-bucket \\\n --query \"Contents[?Metadata.project=='website']\" \\\n --endpoint-url https://fly.storage.tigris.dev\n```\n\nThe catch: Tigris is still young. Edge cases appeared in their multipart upload implementation in late 2025 where ` ListParts ` returned parts in a different order than S3. It's been fixed since, but it's a reminder that \"S3-compatible\" is aspirational for newer services."},{"heading":"Building a CDN Layer with Edge Caching","level":2,"url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#building-a-cdn-layer-with-edge-caching","content":"Here is a proven architecture for a media-heavy application, object storage as origin, with a caching layer that handles the edge distribution:\n\n```\nClient → Cloudflare (CDN) → Origin Worker → R2/MinIO\n ↓\n Cache (Tiered)\n ├── L1: Edge (each PoP, 1h TTL)\n └── L2: Regional (shared, 24h TTL)\n```\n\nThe origin worker does the heavy lifting:\n\n```javascript\nasync function handleRequest(request, env) {\n const url = new URL(request.url);\n const cacheKey = new Request(url.toString(), request);\n const cache = caches.default;\n\n // Check L1 edge cache\n let response = await cache.match(cacheKey);\n if (response) {\n return new Response(response.body, {\n headers: {\n ...Object.fromEntries(response.headers),\n 'X-Cache': 'HIT',\n },\n });\n }\n\n // Fetch from R2 origin\n const objectKey = url.pathname.slice(1);\n const object = await env.R2_BUCKET.get(objectKey);\n\n if (!object) {\n return new Response('Object not found', { status: 404 });\n }\n\n // Build response with aggressive caching for immutable assets\n const isImmutable = objectKey.includes('/immutable/');\n const cacheControl = isImmutable\n ? 'public, max-age=31536000, immutable'\n : 'public, max-age=3600, stale-while-revalidate=86400';\n\n response = new Response(object.body, {\n headers: {\n 'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',\n 'Cache-Control': cacheControl,\n 'ETag': object.httpEtag,\n 'X-Cache': 'MISS',\n },\n });\n\n // Store in edge cache (non-blocking)\n request.method === 'GET' && cacheControl !== 'no-store'\n && event.waitUntil(cache.put(cacheKey, response.clone()));\n\n return response;\n}\n```"},{"heading":"Cache Invalidation","level":3,"url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#cache-invalidation","content":"The hard part, as always. The recommended approach: content-addressable keys for static assets (hash in the filename), and short TTLs + ` stale-while-revalidate ` for dynamic content.\n\n```javascript\n// Content-addressable upload, the key IS the cache buster\nimport { createHash } from 'crypto';\n\nfunction contentAddressableKey(buffer, originalName) {\n const hash = createHash('sha256').update(buffer).digest('hex').slice(0, 12);\n const ext = originalName.split('.').pop();\n return `immutable/${hash}.${ext}`;\n}\n```\n\nFor cases where you need instant invalidation (like a user updating their profile picture), a redirect layer works well:\n\n```\n/avatars/user-123 → 302 → /immutable/a1b2c3d4e5f6.jpg\n```\n\nUpdate the redirect pointer, and the old cached object naturally expires. The new pointer gets cached with a short TTL. No purge API calls, no cache stampedes."},{"heading":"Cost Comparison: Real Numbers","level":2,"url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#cost-comparison-real-numbers","content":"Tracked costs for a workload doing 500GB storage, 2TB egress/month, 10M GET requests:\n\n| Service | Storage | Egress | Requests | Total/mo |\n| --- | --- | --- | --- | --- |\n| AWS S3 | $11.50 | $180.00 | $4.00 | $195.50 |\n| Cloudflare R2 | $7.50 | $0.00 | $3.60 | $11.10 |\n| Tigris | $10.00 | $0.00 | $2.50 | $12.50 |\n| MinIO (self-hosted) | ~$40\\* | $0.00 | $0.00 | ~$40.00 |\n\n\\*MinIO cost is the server rental, 4x 1TB NVMe on Hetzner.\n\nThe egress cost on S3 is the killer. If your objects are frequently accessed, R2 or Tigris will save you a fortune. If you need data sovereignty or have compliance requirements, MinIO on your own metal is the way, but you're paying with operational overhead instead of dollars."},{"heading":"Wrapping Up","level":2,"url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#wrapping-up","content":"Object storage is one of those things that seems simple until you're debugging why your multipart upload failed at part 9,437 of a 50GB file at 3 AM. The S3 API being a de facto standard is genuinely great, switching between MinIO, R2, and Tigris is mostly a configuration change.\n\nA recommended stack: R2 for public assets (zero egress is unbeatable), MinIO for compliance-sensitive data, and Tigris for workloads where global distribution matters. Presigned URLs for all client-facing operations, content-addressable keys for cache management, and lifecycle rules to clean up after incomplete uploads.\n\nThe infrastructure is the boring part. Which means it should work without you thinking about it."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#questions","content":""},{"heading":"Is MinIO truly production-ready for large-scale workloads?","level":3,"url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#question-1","content":"Yes, but with caveats. MinIO handles petabyte-scale storage well when deployed with erasure coding and proper monitoring. The key is understanding that it's not a managed service, you own the operational overhead. For most teams, starting with Tigris or R2 and moving to self-hosted MinIO only when you need data sovereignty or cost control at scale is the pragmatic path."},{"heading":"How do presigned URLs differ from IAM-based access for object storage?","level":3,"url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#question-2","content":"Presigned URLs encode the authorization into the URL itself using HMAC signatures. They're time-limited, scoped to a specific operation (GET, PUT), and don't require the client to have any AWS credentials. IAM-based access requires the client to hold and manage credentials. Use presigned URLs for browser uploads and temporary sharing; use IAM for server-to-server communication."},{"heading":"Can Cloudflare R2 replace S3 entirely?","level":3,"url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#question-3","content":"For many workloads, yes. R2 has zero egress fees which changes the economics dramatically. The main gaps are lifecycle policies (still maturing), event notifications (limited compared to S3 EventBridge), and the ecosystem of tools that assume S3-specific features like S3 Select or Glacier transitions. If you don't need those, R2 is a serious contender."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#article-tips","content":"1. **Tip:** Always set ` partSize ` dynamically based on file size, 5MB for files under 100MB, 25MB+ for larger objects. Fixed part sizes are the fastest way to waste bandwidth on retries.\n2. **Tip:** Presigned URLs should have the shortest TTL you can get away with. A recommended default is ` 3600 ` seconds for uploads, ` 900 ` for downloads. If the flow needs longer, the flow has a design problem.\n3. **Tip:** When running MinIO in production, always deploy with erasure coding (` minio server /data{1...4} `). Single-disk mode is for dev only, A disk failure eating an invoice archive demonstrates the importance of this.\n\n- s3\n- minio\n- tigris\n- cloudflare-r2\n- object-storage\n- multipart-upload\n- presigned-urls\n- cdn\n- edge-caching\n- infrastructure\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris"},{"text":"Cloudflare","url":"https://cloudflare.com/"},{"text":"MinIO","url":"https://min.io/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris"},"headline":"S3-Compatible Object Storage: MinIO, Tigris, R2, and Multipart Upload Internals","description":"A technical walkthrough of S3-compatible object storage internals, from multipart upload mechanics and presigned URL security to building your own CDN layer with edge caching on top of MinIO, Tigris, and Cloudflare R2.","datePublished":"2025-11-05","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://statics.lucioduran.com/images/lucioduran-social-card.jpg","url":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Infrastructure","keywords":"s3, minio, tigris, cloudflare-r2, object-storage, multipart-upload, presigned-urls, cdn, edge-caching, infrastructure","wordCount":1554,"timeRequired":"PT9M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/object-storage-s3-compatible-minio-tigris"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"S3-Compatible Object Storage: MinIO, Tigris, R2, and Multipart Upload Internals","item":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Is MinIO truly production-ready for large-scale workloads?","acceptedAnswer":{"@type":"Answer","text":"Yes, but with caveats. MinIO handles petabyte-scale storage well when deployed with erasure coding and proper monitoring. The key is understanding that it's not a managed service, you own the operational overhead. For most teams, starting with Tigris or R2 and moving to self-hosted MinIO only when you need data sovereignty or cost control at scale is the pragmatic path."}},{"@type":"Question","name":"How do presigned URLs differ from IAM-based access for object storage?","acceptedAnswer":{"@type":"Answer","text":"Presigned URLs encode the authorization into the URL itself using HMAC signatures. They're time-limited, scoped to a specific operation (GET, PUT), and don't require the client to have any AWS credentials. IAM-based access requires the client to hold and manage credentials. Use presigned URLs for browser uploads and temporary sharing; use IAM for server-to-server communication."}},{"@type":"Question","name":"Can Cloudflare R2 replace S3 entirely?","acceptedAnswer":{"@type":"Answer","text":"For many workloads, yes. R2 has zero egress fees which changes the economics dramatically. The main gaps are lifecycle policies (still maturing), event notifications (limited compared to S3 EventBridge), and the ecosystem of tools that assume S3-specific features like S3 Select or Glacier transitions. If you don't need those, R2 is a serious contender."}}]}]}],"contentHash":"b97b0ca19b741e967f5246cca3d26f43c5acd547f01b404f960e30001d76725c","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/object-storage-s3-compatible-minio-tigris","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":70766,"contentBytes":14305}}
