Self-Host S3-Compatible Storage with MinIO
23 Apr 2026S3 is cheap to fill and expensive to leave. Egress fees are the part nobody budgets for until the bill arrives. MinIO gives you the same S3 API on hardware you already pay for, and every SDK that speaks S3 works against it unchanged.
Why MinIO and not something else
It’s a single Go binary, it implements the S3 API closely enough that aws-sdk, boto3, and every S3-compatible backup tool just work, and it has a real web console for poking around buckets by hand. You are not learning a new API. You’re pointing the old one somewhere else.
1. Run it
Docker Compose is the fastest path:
services:
minio:
image: minio/minio:RELEASE.2026-01-15T00-00-00Z
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: admin
MINIO_ROOT_PASSWORD: change-this-to-something-long
volumes:
- minio-data:/data
volumes:
minio-data:
docker compose up -d. Port 9000 is the S3 API, 9001 is the console. Pin the image tag — MinIO ships new releases constantly and you don’t want a surprise upgrade on restart.
2. Create a bucket and scoped credentials
Don’t use the root user in your app. Create a service account with access to one bucket:
mc alias set local http://localhost:9000 admin change-this-to-something-long
mc mb local/uploads
mc admin user add local app-uploader some-long-secret-key
mc admin policy attach local readwrite --user app-uploader
If you want the policy scoped to just the uploads bucket instead of everything, write a small JSON policy document and attach that instead of the built-in readwrite.
3. Point your app at it
Any S3 SDK takes an endpoint override. In Node with @aws-sdk/client-s3:
const client = new S3Client({
endpoint: 'https://storage.yourdomain.com',
region: 'us-east-1', // MinIO ignores this but the SDK requires it
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY,
secretAccessKey: process.env.MINIO_SECRET_KEY,
},
forcePathStyle: true,
})
forcePathStyle: true is the setting people forget. Without it the SDK tries virtual-hosted addressing (bucket.storage.yourdomain.com), your DNS doesn’t have that wildcard, and every request fails with a confusing connection error, not an auth error.
Put it behind a real TLS endpoint
Don’t expose port 9000 directly to the internet. Reverse proxy it through Caddy or nginx with a real certificate, and firewall the raw port to your VPS’s private network or localhost only. Caddy example:
storage.yourdomain.com {
reverse_proxy localhost:9000
}
MinIO is not your backup
Object storage on the same VPS as your app is still one disk failure from gone. Either mirror the bucket to a second MinIO instance or a real cloud bucket with mc mirror --watch local/uploads remote/uploads, or point your existing backup tool (restic, rclone) at it since it’s just another S3 endpoint now.
The gotcha that eats an afternoon
Multipart uploads for large files need a lifecycle rule to clean up aborted parts, or your disk fills silently with orphaned chunks nobody’s tracking:
mc ilm add --expiry-days 1 --noncurrent-expiry-days 1 local/uploads
Run that once after creating any bucket that takes uploads over ~5MB. Skip it and six months later you’re debugging why a 50GB bucket has 400GB used.