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:


Table of Contents

  1. Object Storage in Five Minutes
  2. Single-Node Docker Compose Deployment
  3. The Web Console and Access Keys
  4. mc Client Essentials
  5. Lifecycle and Expiry Policies
  6. Using MinIO as an S3 Backend
  7. Troubleshooting & Common Pitfalls
  8. Key Takeaways
  9. 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:

๐Ÿ’ก 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:

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.1 above. 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:

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:

  1. Console โ†’ Identity โ†’ Access Keys โ†’ Create Access Key
  2. 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 anonymous controls 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 โ†’ โ†’ Lifecycle โ†’ Add Lifecycle Rule (expiry after N days, optional prefix filter).

Two gotchas:

  1. Expiry runs during MinIO's background scanner โ€” deletion happens within roughly a day, not to-the-second.
  2. 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

  1. Object storage = flat keys in buckets over the S3 API โ€” ideal for backups, media, and app uploads; wrong for databases.
  2. One container gets you a full S3 cloud: API on 9000, console on 9001, data in a bind mount.
  3. Never distribute root credentials โ€” per-app access keys with scoped JSON policies contain blast radius.
  4. mc alias + mb/cp/mirror/ilm covers 95% of daily object-storage administration.
  5. ILM lifecycle rules automate retention; pair them with app-level pruning (e.g., restic forget).
  6. Any S3-speaking tool plugs in by swapping endpoint + keys โ€” path-style URLs make it painless.

Sources & Related

Web Sources

Related KB Articles

Change Log

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse