Self-Hosted Object Storage with MinIO
Running MinIO on your own hardware, S3-compatible API, distributed mode, lifecycle policies, and using it from application code.
Real-World Analogy
Building a post office branch instead of routing everything through Amazon: same service (package delivery), same rules (addresses, tracking), your own infrastructure. MinIO speaks the S3 API so any code written for Amazon S3 works unchanged — you just point it at your server instead of Amazon’s.
Why MinIO
MinIO is an S3-compatible object storage server written in Go. The entire thing is a single binary. Use it when:
- You can’t send data to AWS (air-gapped, regulation, cost)
- You want S3-compatible storage on Hetzner or your own hardware
- Local S3-compatible development environment
- Egress costs from AWS are prohibitive
The S3 API compatibility means existing code — SDKs, libraries, tools — works unchanged.
Single-Node Setup
# Docker
docker run -d \
--name minio \
-p 9000:9000 \ # API
-p 9001:9001 \ # Console UI
-e MINIO_ROOT_USER=admin \
-e MINIO_ROOT_PASSWORD=supersecretpassword \
-v /data/minio:/data \
minio/minio server /data --console-address ":9001" # docker-compose.yml
services:
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: admin
MINIO_ROOT_PASSWORD: supersecretpassword
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s Access the console at http://localhost:9001. Create buckets, manage users, set policies.
Distributed Mode (Production)
Single-node MinIO has no redundancy. For production, run 4+ nodes with erasure coding:
# On each of 4 nodes (16 drives total — 4 per node)
docker run -d \
--name minio \
--network host \
-e MINIO_ROOT_USER=admin \
-e MINIO_ROOT_PASSWORD=supersecretpassword \
-e MINIO_VOLUMES="http://minio-{1...4}/data{1...4}" \
-v /data1:/data1 -v /data2:/data2 -v /data3:/data3 -v /data4:/data4 \
minio/minio server
# MINIO_VOLUMES uses expansion syntax:
# minio-{1...4} → minio-1, minio-2, minio-3, minio-4
# /data{1...4} → /data1, /data2, /data3, /data4 MinIO uses Reed-Solomon erasure coding — with 16 drives, it can tolerate losing any 8 and still serve data. With 4 drives (minimum), tolerates 2 failures.
Place a load balancer (nginx or HAProxy) in front of all nodes:
upstream minio {
server minio-1:9000;
server minio-2:9000;
server minio-3:9000;
server minio-4:9000;
}
server {
listen 9000;
location / {
proxy_pass http://minio;
proxy_set_header Host $host;
client_max_body_size 1g;
}
} Application Code (AWS SDK)
MinIO speaks S3 — use the official AWS SDK, just point the endpoint at MinIO:
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({
endpoint: process.env.MINIO_ENDPOINT ?? 'http://localhost:9000',
region: 'us-east-1', // required by SDK but ignored by MinIO
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY!,
secretAccessKey: process.env.MINIO_SECRET_KEY!,
},
forcePathStyle: true, // required for MinIO (vs virtual-hosted style)
});
const BUCKET = 'user-uploads';
// Upload
async function uploadFile(key: string, body: Buffer, contentType: string) {
await s3.send(new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: body,
ContentType: contentType,
Metadata: {
'uploaded-by': 'order-service',
},
}));
return `${process.env.MINIO_PUBLIC_URL}/${BUCKET}/${key}`;
}
// Download
async function downloadFile(key: string): Promise<Buffer> {
const response = await s3.send(new GetObjectCommand({
Bucket: BUCKET,
Key: key,
}));
const chunks: Uint8Array[] = [];
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
// Delete
async function deleteFile(key: string) {
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key }));
}
// List objects with prefix
async function listFiles(prefix: string) {
const response = await s3.send(new ListObjectsV2Command({
Bucket: BUCKET,
Prefix: prefix,
MaxKeys: 1000,
}));
return response.Contents ?? [];
} Presigned URLs
Let clients upload directly to MinIO — no proxying through your server:
// Generate upload URL (client uploads directly to MinIO)
async function getUploadUrl(key: string, contentType: string): Promise<string> {
const command = new PutObjectCommand({
Bucket: BUCKET,
Key: key,
ContentType: contentType,
});
return getSignedUrl(s3, command, { expiresIn: 300 }); // 5 minutes
}
// Generate download URL (time-limited access to private files)
async function getDownloadUrl(key: string): Promise<string> {
const command = new GetObjectCommand({ Bucket: BUCKET, Key: key });
return getSignedUrl(s3, command, { expiresIn: 3600 }); // 1 hour
} // API endpoint: client requests upload URL, then uploads directly
app.post('/files/upload-url', async (req, res) => {
const { filename, contentType } = req.body;
const key = `uploads/${req.userId}/${Date.now()}-${filename}`;
const uploadUrl = await getUploadUrl(key, contentType);
res.json({ uploadUrl, key });
});
// Client code (browser)
const { uploadUrl, key } = await fetch('/files/upload-url', {
method: 'POST',
body: JSON.stringify({ filename: file.name, contentType: file.type }),
}).then(r => r.json());
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
}); Direct upload bypasses your server entirely — reduces your bandwidth costs and server load.
Bucket Policies
Control public access per bucket:
import { PutBucketPolicyCommand } from '@aws-sdk/client-s3';
// Make a bucket publicly readable (for serving static assets)
const publicReadPolicy = {
Version: '2012-10-17',
Statement: [{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject'],
Resource: ['arn:aws:s3:::public-assets/*'],
}],
};
await s3.send(new PutBucketPolicyCommand({
Bucket: 'public-assets',
Policy: JSON.stringify(publicReadPolicy),
})); # Or via MinIO CLI (mc)
mc alias set myminio http://localhost:9000 admin supersecretpassword
mc mb myminio/public-assets
mc anonymous set public myminio/public-assets Lifecycle Policies
Auto-delete or transition objects:
import { PutBucketLifecycleConfigurationCommand } from '@aws-sdk/client-s3';
await s3.send(new PutBucketLifecycleConfigurationCommand({
Bucket: 'user-uploads',
LifecycleConfiguration: {
Rules: [
{
ID: 'delete-temp-files',
Status: 'Enabled',
Filter: { Prefix: 'temp/' },
Expiration: { Days: 1 }, // delete temp files after 1 day
},
{
ID: 'delete-old-logs',
Status: 'Enabled',
Filter: { Prefix: 'logs/' },
Expiration: { Days: 90 }, // delete logs after 90 days
},
],
},
})); MinIO as Local S3 in Development
# docker-compose.dev.yml — use MinIO locally to mirror production S3 behavior
services:
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
volumes:
- minio_dev:/data
# Create buckets on startup
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 minioadmin minioadmin;
mc mb local/user-uploads;
mc mb local/public-assets;
mc anonymous set public local/public-assets;
exit 0;
" # .env.development
MINIO_ENDPOINT=http://localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_PUBLIC_URL=http://localhost:9000 Switch to S3 in production by changing environment variables — no code changes.
Monitoring
# MinIO exposes Prometheus metrics
curl http://localhost:9000/minio/v2/metrics/cluster
# Key metrics:
# minio_bucket_usage_object_total — object count per bucket
# minio_bucket_usage_total_bytes — storage used per bucket
# minio_s3_requests_total — request rate by type
# minio_s3_requests_errors_total — error rate # prometheus.yml
scrape_configs:
- job_name: minio
metrics_path: /minio/v2/metrics/cluster
scheme: http
static_configs:
- targets: ['minio:9000']
bearer_token: <minio-prometheus-token> Generate the Prometheus token:
mc admin prometheus generate myminio cluster
# Returns a bearer token to use in the scrape config