IAnswerable

Self-Host S3-Compatible Storage with MinIO

23 Apr 2026

S3 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.