MinIO Setup - Self-Hosted S3-Compatible Object Storage
Status: Active
Last Updated: 2026-08-26
Category: Cloud - Phase 2: Object Storage
Prerequisites: cloud-storage-concepts, nextcloud-setup
Time: 1-2 hours
Tags: minio, s3, object-storage, docker-compose, mc, buckets, lifecycle, presigned, backup-target
Summary
MinIO is an open-source, S3-compatible object storage server that turns any machine with a disk into a drop-in replacement for Amazon S3. This guide covers the object storage mental model (buckets, objects, keys, policies), deploys a single-node MinIO with Docker Compose, walks through the web console and access keys, teaches the mc client essentials (alias, bucket create, anonymous/policy management), configures lifecycle expiry rules, and shows how to point other tools โ restic, Nextcloud, anything with an "S3 endpoint" field โ at your new storage.
๐ฏ What You'll Learn
By the end of this article, you'll be able to:
- โ Explain the difference between block, file, and object storage โ and when object wins
- โ Deploy MinIO single-node single-drive with Docker Compose
- โ Navigate the web console and create least-privilege service accounts (access keys)
- โ
Use
mcto create aliases, buckets, and manage bucket policies - โ Set lifecycle rules so old objects expire automatically
- โ Connect restic, Nextcloud-style apps, and generic S3 clients to MinIO
Table of Contents
- Object Storage in Five Minutes
- Single-Node Docker Compose Deployment
- The Web Console and Access Keys
- mc Client Essentials
- Lifecycle and Expiry Policies
- Using MinIO as an S3 Backend
- Troubleshooting & Common Pitfalls
- Key Takeaways
- Sources & Related
Object Storage in Five Minutes
Three ways to store bytes on a server:
| Model | Unit | Addressing | Best for |
|---|---|---|---|
| Block | Disk sectors | Attached volume | Databases, VMs, OS disks |
| File | Files/dirs | POSIX paths (/data/file.txt) |
Shared folders, configs |
| Object | Objects | Flat keys inside buckets (bucket/photo.jpg โ no real dirs) |
Backups, media, logs, app uploads |
Object storage characteristics that matter:
- Buckets hold objects; each object is key + data + metadata. There is no filesystem hierarchy โ "folders" are just key prefixes.
- Access over HTTP(S) via the S3 API โ the de-facto standard since AWS defined it. Anything that speaks S3 works with MinIO.
- Scale-flat design: billions of objects per bucket are normal; no inode limits, no directory scans.
- Policy-based access instead of Unix perms: who can read/write which bucket/prefix is expressed as JSON IAM policies.
๐ก Rule of thumb: if an app or script just needs to put/get blobs by name โ backups, exports, uploads โ it wants object storage. If it needs random writes into existing files (a database!), it does not.
MinIO implements the S3 API in Go. A single-node single-drive (SNSD) deployment is perfect for homelabs, dev environments, and backup targets; production-grade erasure-coded clusters come later.
Single-Node Docker Compose Deployment
Compose File
# ~/apps/minio/docker-compose.yml
services:
minio:
image: quay.io/minio/minio:latest
container_name: minio
restart: unless-stopped
command: server /data --console-address ":9001"
ports:
- "127.0.0.1:9000:9000" # S3 API - bind localhost; front with TLS proxy for remote use
- "127.0.0.1:9001:9001" # Web console
environment:
# Root credentials โ change these! Consider *_FILE secrets instead.
MINIO_ROOT_USER: admin
MINIO_ROOT_PASSWORD: change-me-min-8-chars
volumes:
- ./data:/data # all objects live here
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 30s
timeout: 10s
retries: 3
Notes:
- Port 9000 = S3 API, port 9001 = web console. Clients connect to 9000; humans open 9001.
- The official docs deploy exactly this shape: one
miniocontainer serving/data, with console enabled via--console-address. - For production container deployments MinIO recommends file-based environment variables (e.g.
MINIO_ROOT_USER_FILE) read from orchestrator secrets โ nice hardening step once things work.
Bring It Up
mkdir -p ~/apps/minio && cd ~/apps/minio
nano docker-compose.yml
docker compose up -d
docker logs minio --tail 20 # expect "MinIO Object Storage Server ... API: http://172.x.x.x:9000"
Verify:
curl -I http://localhost:9000/minio/health/live # HTTP 200 = healthy
Open http://<server-ip>:9001, log in with the root user/password.
๐ Both ports are bound to
127.0.0.1above. Expose them only through a reverse proxy with TLS (kb/security/tls-configuration) or an SSH tunnel. The S3 API transmits credentials on every request โ never ship it plaintext across the internet.
Data Layout
Everything lands under ./data:
data/
โโโ <bucket-name>/ # one dir per bucket
โ โโโ photos/x1f2.../xl.meta # erasure metadata + inline small data
โ โโโ ...
โโโ .minio.sys/ # internal state โ do not touch
Back this directory up like any critical dataset โ but note that restic against MinIO itself (see restic-backups) gives you versioned, encrypted copies far better than rsyncing live object stores.
The Web Console and Access Keys
The console at :9001 handles day-to-day administration:
- Object Browser โ upload/download files, browse prefixes like a file manager
- Buckets โ create buckets, quotas, versioning, lifecycle rules, replication
- Access Keys โ create per-app credentials
- Monitoring โ dashboard with API traffic, drive usage, scan health
Why Not Use Root Everywhere?
The root user is god-mode: it can delete every bucket. Every app should get its own access key (access key ID + secret) restricted to what it needs:
- Console โ Identity โ Access Keys โ Create Access Key
- Optionally constrain it with a policy (JSON limiting bucket/prefix/actions)
Example least-privilege policy allowing a backup tool full access to one bucket only:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:*"],
"Resource": ["arn:aws:s3:::restic-backups", "arn:aws:s3:::restic-backups/*"]
}
]
}
Now leaking that key compromises one bucket, not your whole storage server. Rotate keys by creating a new one, updating the client, then deleting the old.
mc Client Essentials
mc (MinIO Client) is the CLI for administering MinIO from scripts and terminals. It's also bundled inside the server image (used by the healthcheck above).
Install standalone:
curl -o /usr/local/bin/mc https://dl.min.io/client/mc/release/linux-amd64/mc
chmod +x /usr/local/bin/mc
Aliases
Every mc command targets an alias โ a named connection:
mc alias set local http://localhost:9000 admin 'change-me-min-8-chars'
mc alias ls # list configured aliases
Buckets
mc mb local/photos # make bucket
mc mb local/restic-backups
mc ls local # list buckets
mc tree local/photos # view key structure
mc du local/photos # disk usage
Objects
mc cp ~/backup.tar.gz local/backups/ # upload
mc cp local/backups/backup.tar.gz ./ # download
mc ls --recursive local/backups
mc rm local/backups/old.tar.gz
mc mirror ~/important-docs local/docs/ # sync a directory (rsync-like)
Policies (Anonymous Access + User Policy)
By default everything is private. To serve a bucket publicly read-only (e.g., static assets):
mc anonymous set download local/photos # public read-only
mc anonymous set none local/photos # back to private
mc anonymous get local/photos # inspect current rule
For IAM-style control, attach managed policies to users:
mc admin user add local backupbot '<strong-secret>'
mc admin policy attach local readwrite --user backupbot
โ ๏ธ
mc anonymouscontrols unauthenticated access. Authenticated fine-grained access comes from JSON policies attached to users/access keys โ same model as AWS IAM, subset of actions.
Lifecycle and Expiry Policies
ILM (Information Lifecycle Management) rules let MinIO delete or transition objects automatically โ perfect for log retention, temp uploads, and rotated backups.
Via mc:
# Expire objects older than 30 days in a bucket
mc ilm rule add local/temp-uploads --expire-days 30
# Expire based on prefix (only "logs/" keys)
mc ilm rule add local/appdata --expire-days 90 --prefix logs/
# Inspect and remove rules
mc ilm rule ls local/temp-uploads
mc ilm rule rm --id "<rule-id-from-ls>" local/temp-uploads
Or in the console: Buckets โ
Two gotchas:
- Expiry runs during MinIO's background scanner โ deletion happens within roughly a day, not to-the-second.
- If bucket versioning is on, noncurrent versions follow separate noncurrent-expiry settings; a plain expire-days rule only removes current objects' markers. Decide versioning before designing retention.
Typical pattern for a backup target: keep the most recent N days hot in MinIO with ILM, while restic's own forget --keep policy (see restic-backups) governs snapshot-level retention. Belt and suspenders.
Using MinIO as an S3 Backend
Anything with an "S3-compatible" option can target MinIO. The universal connection triple:
| Field | Value |
|---|---|
| Endpoint | http://minio-host:9000 (or your TLS domain) |
| Access Key / Secret | the per-app access key from the console |
| Bucket | created via console or mc mb |
| Region | any (often us-east-1 default); MinIO doesn't care |
Generic examples
AWS CLI:
aws configure set default.s3.endpoint_url http://localhost:9000 # or profile-specific
aws --endpoint-url http://localhost:9000 s3 ls s3://photos/
s5cmd / rclone: point endpoint at http://minio:9000; everything else identical to AWS.
Nextcloud (Primary S3 storage in config.php):
'objectstore' => [
'class' => '\\OC\\Files\\ObjectStore\\S3',
'arguments' => [
'bucket' => 'nextcloud',
'hostname' => 'minio.internal.example.com',
'port' => 9000,
'use_ssl' => false, // true behind TLS proxy
'key' => 'nextcloud-key',
'secret' => 'nextcloud-secret',
'autocreate' => true,
],
],
restic (covered fully in restic-backups):
export AWS_ACCESS_KEY_ID=restic-key
export AWS_SECRET_ACCESS_KEY=restic-secret
restic -r s3:http://localhost:9000/restic-backups init
Note restic uses path-style URLs (host/bucket), which is exactly what MinIO serves โ no virtual-host DNS gymnastics needed.
Networking tip
If clients run in Docker on the same host, create a shared network and reach MinIO by container name:
networks:
storage:
services:
minio:
networks: [storage]
myapp:
networks: [storage]
# endpoint: http://minio:9000 โ resolves inside the compose network
Troubleshooting & Common Pitfalls
Console loads but login fails instantly
Wrong root credentials โ check MINIO_ROOT_USER/MINIO_ROOT_PASSWORD in the compose file and docker logs minio. Password must be โฅ 8 characters or startup fails.
"Bucket already exists" but you don't see it
Bucket names are globally unique per deployment and lowercase-DNS constrained (3โ63 chars, no underscores). An earlier failed mc mb may have left it โ check mc ls local.
Client gets SignatureDoesNotMatch
Usually a secret pasted with trailing whitespace/newline, or clock skew > 15 min between client and server. Check timedatectl on the host.
Works locally, fails remotely with redirect loops
You're exposing the console/API without setting the right external hostname. Behind a reverse proxy, ensure the proxy passes Host unchanged and you access via the advertised address.
Disk fills silently
MinIO preallocates metadata; small objects consume more on-disk space than their logical size. Monitor ./data growth (Netdata disk charts โ see netdata-basics) and set bucket quotas (mc quota set local/photos --size 100gi).
Deleted files didn't reclaim space With versioning enabled, deletes create delete-markers; old versions persist until noncurrent-expiry ILM rules run. Configure them or purge manually.
Root credentials committed to git
Rotate immediately (console โ account โ change password), then move to _FILE env secrets or a .env file excluded via .gitignore.
Key Takeaways
- Object storage = flat keys in buckets over the S3 API โ ideal for backups, media, and app uploads; wrong for databases.
- One container gets you a full S3 cloud: API on 9000, console on 9001, data in a bind mount.
- Never distribute root credentials โ per-app access keys with scoped JSON policies contain blast radius.
mc alias+mb/cp/mirror/ilmcovers 95% of daily object-storage administration.- ILM lifecycle rules automate retention; pair them with app-level pruning (e.g., restic forget).
- Any S3-speaking tool plugs in by swapping endpoint + keys โ path-style URLs make it painless.
Sources & Related
Web Sources
- MinIO Container Documentation: https://min.io/docs/minio/container/index.html
- Deploy MinIO Single-Node Single-Drive: https://min.io/docs/minio/container/operations/install-deploy-manage/deploy-minio-single-node-single-drive.html
- MinIO Client (
mc) Command Reference: https://min.io/docs/minio/linux/reference/minio-mc.html - Object Lifecycle Management (Automatic Expiration): https://min.io/docs/minio/container/operations/manage-data/lifecycle-management.html
- restic docs โ preparing an S3/Minio repository: https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html
Related KB Articles
- cloud-storage-concepts โ storage models compared (read first)
- nextcloud-setup โ primary external storage / S3 integration patterns
- restic-backups โ using this MinIO instance as an encrypted backup target
- kb/infrastructure/disaster-recovery โ where object storage fits in DR planning
- kb/containers/docker-volumes โ bind-mount vs named-volume tradeoffs for
/data - kb/observability/netdata-basics โ watching drive utilization grow
Change Log
- 2026-08-26: Initial draft created via headless-browser web research session.