Your cloud storage bill just tripled. Or maybe you're staring at egress charges that make no sense for what should be a simple "store files and serve them" workflow. Either way, you're wondering: can I just run this myself?
Short answer: yes. And it's more practical than you think in 2026.
I recently went through this migration on a project where we were storing monitoring data and attachments in a managed object storage service. The monthly cost had crept from "barely noticeable" to "we should probably talk about this." Here's how I approached moving to self-hosted object storage without losing my mind.
Why Cloud Object Storage Costs Sneak Up on You
The pricing model for most cloud object storage looks great on paper. A few dollars per terabyte for storage, pennies per thousand requests. But the costs that get you are the ones you don't think about upfront:
- Egress fees — every byte that leaves the provider's network costs money
- API request charges — LIST and GET operations add up fast with monitoring or logging workloads
- Minimum storage duration — delete a file after a day, still pay for 30 days on some tiers
- Cross-region transfer — if your compute and storage aren't co-located, you're paying twice
For applications that do a lot of small reads and writes — think health check pings, log aggregation, or time-series attachments — these costs compound quickly. The per-request pricing model works against you when your access pattern is "millions of tiny operations."
Choosing a Self-Hosted Solution
The two heavyweights in the self-hosted S3-compatible storage space are MinIO and Garage. There are others (SeaweedFS, Ceph with its S3 gateway), but these two cover most use cases.
MinIO is the obvious first choice. It's mature, well-documented, and implements the S3 API thoroughly enough that most applications work without code changes. It's what I reached for.
# Quick single-node MinIO setup for evaluation
# Don't use this in production without proper volume configuration
mkdir -p /data/minio
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-secure-password-here \
minio/minio server /data --console-address ":9001"
Garage is worth considering if you need a lightweight, multi-node setup without the operational overhead. It's designed for geo-distributed deployments and uses significantly less memory than MinIO. I haven't tested it thoroughly yet in a high-throughput scenario, but the architecture looks promising for smaller teams.
For most single-server or small-cluster deployments, MinIO is the pragmatic choice. The documentation is excellent and the community is large enough that you'll find answers to most questions.
The Migration: Step by Step
Here's the approach that worked for me. The key insight is that because we're targeting S3-compatible APIs, the application code changes are minimal — it's mostly infrastructure work.
Step 1: Set Up MinIO With Proper Disk Configuration
For production, you want erasure coding. MinIO needs at least 4 drives for this (it splits data across drives with parity for fault tolerance).
# docker-compose.yml for a single-node, multi-drive setup
services:
minio:
image: minio/minio:latest
command: server /data/{1...4} --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes:
- /mnt/disk1:/data/1
- /mnt/disk2:/data/2
- /mnt/disk3:/data/3
- /mnt/disk4:/data/4
ports:
- "9000:9000"
- "9001:9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
The {1...4} syntax tells MinIO to use these as an erasure coding set. You get redundancy — lose one drive, keep serving data.
Step 2: Update Your Application's S3 Configuration
This is where self-hosted storage shines. If your app already uses an S3 SDK, you typically just change the endpoint URL:
import boto3
# Before: pointing at a cloud provider
# s3 = boto3.client('s3')
# After: pointing at your MinIO instance
s3 = boto3.client(
's3',
endpoint_url='https://minio.yourdomain.com',
aws_access_key_id='your-access-key',
aws_secret_access_key='your-secret-key',
region_name='us-east-1' # MinIO ignores this but some SDKs require it
)
# Everything else stays the same
s3.put_object(Bucket='my-bucket', Key='data/file.json', Body=payload)
obj = s3.get_object(Bucket='my-bucket', Key='data/file.json')
That's it. No application logic changes. The S3 API compatibility means your existing code, backup scripts, and CLI tools all work.
Step 3: Migrate Existing Data
The mc (MinIO Client) tool handles this well. It can mirror data from any S3-compatible source to your new setup:
# Add your source (cloud provider) and destination (self-hosted)
mc alias set cloudsrc https://s3.amazonaws.com ACCESS_KEY SECRET_KEY
mc alias set local https://minio.yourdomain.com ACCESS_KEY SECRET_KEY
# Create the destination bucket
mc mb local/my-bucket
# Mirror everything — this preserves metadata and handles retries
mc mirror cloudsrc/my-bucket local/my-bucket --watch
# The --watch flag keeps syncing new objects during migration
# Remove it once you've cut over
Step 4: Put a Reverse Proxy in Front
Don't expose MinIO directly. Use nginx or Caddy to handle TLS and add a layer of access control:
# nginx config for MinIO behind a reverse proxy
server {
listen 443 ssl;
server_name minio.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/minio.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/minio.yourdomain.com/privkey.pem;
# Important: MinIO needs these for large uploads
client_max_body_size 0;
proxy_buffering off;
location / {
proxy_pass http://127.0.0.1:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Required for streaming large objects
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
What You Need to Handle Yourself
Self-hosting means you own the operational burden. Be honest with yourself about whether you're ready for this:
-
Backups — MinIO's erasure coding protects against drive failures, not against you accidentally deleting a bucket. Set up
mc mirrorto a separate backup location or use MinIO's built-in bucket replication. -
Monitoring — MinIO exposes Prometheus metrics at
/minio/v2/metrics/cluster. Hook these up to your alerting. At minimum, watch disk usage, request latency, and error rates. - Disk management — Plan your capacity. Running out of disk space on an object store is a bad day. Set alerts at 80% utilization.
- Updates — MinIO releases frequently. Stay reasonably current, especially for security patches.
When Self-Hosting Doesn't Make Sense
I want to be fair here. Self-hosted object storage isn't always the right call:
- If your storage needs are under 100GB and access patterns are simple, cloud storage is probably cheaper when you factor in your time
- If you need cross-region replication with single-digit millisecond failover, the cloud providers have a significant edge
- If you don't have someone on your team comfortable with Linux server administration, the operational overhead will bite you
The Results
For my project — roughly 500GB of monitoring data with high read/write frequency — the cost went from around $80/month (mostly egress and API calls) to effectively $15/month in additional server costs (extra disk on existing infrastructure). Performance actually improved because storage is now co-located with compute. No more cross-network latency for every read.
The migration took about a weekend. Most of that was testing, not actual infrastructure work.
The S3 API has become such a universal standard that switching between providers — cloud or self-hosted — is genuinely straightforward. If your storage bill is making you wince, running your own object storage is a legitimate option. Just go in with your eyes open about the operational trade-offs.
Top comments (0)