MinIO on a VPS: Self-Hosted S3 Storage for Backups and App Files

 


Backups usually fail for a boring reason: nobody decided clearly where they should live. Archives pile up on the same disk as the app, then disappear along with that disk when something goes wrong. Or you pay a hosted object storage bill for a workload so small the invoice feels out of proportion.

MinIO is a straightforward fix. It is an S3-compatible object storage server you can run on your own VPS. Anything that already talks to S3 — backup scripts, a CMS, a web app — can usually point at MinIO with little change. The difference is that your data stays on hardware you control, and the monthly cost is the VPS you already pay for.

This guide takes you from zero to a working backup target: Docker Compose setup, a bucket and a dedicated access key, upload and download tests, then one real backup sent to MinIO. It ends with a security warning you should not skip: the MinIO console must never sit exposed on the public internet without protection.

What you need first

You need a VPS with Docker and Docker Compose installed, plus SSH access as root or a sudo user. A single MinIO node runs fine on 2 GB of RAM with a few GB free for a first setup, but do not treat one node as your only copy of anything important. A backup that exists in exactly one place is not a backup strategy yet. Near the end you will see how to add a second copy.

If you plan to expose the console over HTTPS later, prepare a domain or subdomain — for example minio.yourdomain.com for the console and s3.yourdomain.com for the API. Neither is required for the install itself: you can work through an SSH tunnel first and add a reverse proxy afterwards.

Step 1: Create the folder and the Compose file

Keep MinIO's configuration in its own folder, away from your other projects:

sudo mkdir -p /opt/minio
cd /opt/minio

Now create /opt/minio/docker-compose.yml:

services:
  minio:
    image: minio/minio:latest
    container_name: minio
    restart: unless-stopped
    command: server /data --console-address ":9001"
    ports:
      - "127.0.0.1:9000:9000"
      - "127.0.0.1:9001:9001"
    environment:
      MINIO_ROOT_USER: ${MINIO_ROOT_USER}
      MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
    volumes:
      - minio_data:/data

volumes:
  minio_data:

A few choices here are deliberate:

  • The command server /data --console-address ":9001" tells MinIO to store objects in /data and serve the web console on port 9001. Port 9000 is the S3 API, where apps and CLIs send files.
  • Ports bind to 127.0.0.1, not to every interface. MinIO is reachable only from inside the VPS until you put a reverse proxy in front of it. That is intentional: the easiest way to get MinIO compromised is to leave its console reachable directly from the internet.
  • Root credentials do not live in the Compose file. They come from a separate .env file.

Create /opt/minio/.env and replace the values with strong credentials of your own:

MINIO_ROOT_USER=your-minio-admin
MINIO_ROOT_PASSWORD=replace-with-a-long-random-password

A MinIO root password must be at least 8 characters, but for the root account go far beyond that — letters, numbers, and symbols, stored in a password manager. Then lock the file down:

sudo chmod 600 /opt/minio/.env

Expected result: /opt/minio holds docker-compose.yml and .env, and .env is not readable by other users on the server.

Step 2: Start MinIO and check it is alive

From the same folder, run:

cd /opt/minio
sudo docker compose up -d
sudo docker compose ps

Healthy output shows the minio container as Up or running. If it keeps restarting, read the logs before guessing:

sudo docker compose logs --tail 50 minio

The two usual culprits are a root password shorter than 8 characters, or ports 9000/9001 already taken by another service. The log normally names the problem plainly. Fix it, then run sudo docker compose up -d again.

Confirm the API answers from inside the VPS:

curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:9000/minio/health/live

Expected result: 200. The S3 API is up and ready for connections.


Step 3: Create a bucket and a dedicated app key

Open the console through an SSH tunnel, not by opening a port to the internet:

ssh -L 9001:127.0.0.1:9001 root@YOUR-VPS-IP

While the tunnel is open, visit http://127.0.0.1:9001 in your browser and sign in with the root credentials from .env. Create a bucket — the example here is backup-aplikasi. Bucket names use lowercase letters, numbers, dots, and hyphens only; no capitals or spaces.

Do not stop at the root account. Create a separate user for backups — for example backup-writer — under Identity, then Users, and attach a policy limited to the backup bucket. A narrowed version of readWrite, or a custom policy that only writes to backup-aplikasi, is the right shape. Scripts should never need root access to your object store.

Install the MinIO Client, mc, which is far more convenient than the console for testing and scripts:

curl https://dl.min.io/client/mc/release/linux-amd64/mc --create-dirs -o $HOME/minio-binaries/mc
chmod +x $HOME/minio-binaries/mc
export PATH=$PATH:$HOME/minio-binaries/

Add that export to ~/.bashrc if you want mc available in every shell. Then add an alias for your server:

mc alias set lokal http://127.0.0.1:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD

Replace the last two values with the root credentials from .env. Expected output:

Added 'lokal' successfully.

Then create the bucket and the dedicated user, and inspect the server:

mc mb lokal/backup-aplikasi
mc admin user add lokal backup-writer LONG-PASSWORD-FOR-BACKUP-WRITER
mc admin info lokal

The mc admin info output confirms the server is reachable, and the bucket now exists alongside its dedicated user.

Step 4: Test upload, download, and delete with mc

Never trust a backup target until a round trip works. Create a small test file, upload it, list it, download it back, compare the contents, then delete the test copy:

echo "MinIO backup test" > /tmp/minio-test.txt
mc cp /tmp/minio-test.txt lokal/backup-aplikasi/
mc ls lokal/backup-aplikasi/
mc cp lokal/backup-aplikasi/minio-test.txt /tmp/minio-test-downloaded.txt
cat /tmp/minio-test-downloaded.txt

Expected output of the last command: MinIO backup test. If it matches, the round trip works. Clean up the test files:

mc rm lokal/backup-aplikasi/minio-test.txt
rm /tmp/minio-test.txt /tmp/minio-test-downloaded.txt

Step 5: Test with the AWS CLI as a second S3 client

One client can hide a quirk. Proving a second, independent S3 client works against the same bucket is what makes MinIO's compatibility claim real. With the AWS CLI installed, run:

aws --endpoint-url http://127.0.0.1:9000 s3 ls s3://backup-aplikasi

Then test a write:

echo "test via aws cli" > /tmp/aws-test.txt
aws --endpoint-url http://127.0.0.1:9000 s3 cp /tmp/aws-test.txt s3://backup-aplikasi/
aws --endpoint-url http://127.0.0.1:9000 s3 ls s3://backup-aplikasi/
rm /tmp/aws-test.txt
aws --endpoint-url http://127.0.0.1:9000 s3 rm s3://backup-aplikasi/aws-test.txt

Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to the backup user's key pair, and keep the HTTP/HTTPS scheme consistent between the endpoint URL and your reverse proxy setup later.

Step 6: Send a real backup to MinIO

The pattern for a real backup is simple: archive, send it to the bucket, verify it is there, then delete the local archive if you want. A database plus app files example:

mysqldump -u DB_USER -p DB_NAME > /tmp/db-backup.sql
tar -czf /tmp/app-backup.tar.gz /tmp/db-backup.sql /var/www/your-app
mc cp /tmp/app-backup.tar.gz lokal/backup-aplikasi/$(date +%F)/
mc ls lokal/backup-aplikasi/$(date +%F)/
rm /tmp/db-backup.sql /tmp/app-backup.tar.gz

Save that pattern as a script — for example /opt/minio/backup-to-minio.sh — make it executable with chmod +x, and schedule it with cron using the full path to mc, for example $HOME/minio-binaries/mc, so the scheduled job does not depend on your interactive shell's PATH.

Test a restore, not just the upload:

mc cp lokal/backup-aplikasi/2026-10-26/app-backup.tar.gz /tmp/restore-test/
tar -tzf /tmp/restore-test/app-backup.tar.gz | head

If the archive lists its files, the restore path works. A backup you have never restored is only a hope.

Security warning: do not expose the console to the public as-is

This is the rule to take seriously. Keep ports 9000 and 9001 bound to 127.0.0.1 until a reverse proxy is in place, and reach the console through an SSH tunnel in the meantime. When you are ready for a domain, use Nginx with HTTPS from Let's Encrypt, with separate subdomains for 9001 and 9000, and add access restrictions where you can.

Do not use a default or short root password. Give each application its own access key, and revoke keys you no longer use through the console or with mc admin user remove. Keep the backup bucket private. And remember that one node on one VPS is not the complete answer: mirror to a second MinIO server with mc mirror, or keep an archive copy off the VPS, so a single server failure does not take your backups with it.

Summary table

ComponentDetails
Docker imageminio/minio:latest
S3 API Port9000
Web console Port9001
Data directory/data in the container, volume minio_data
Config files/opt/minio/docker-compose.yml and /opt/minio/.env
Root credentialsMINIO_ROOT_USER and MINIO_ROOT_PASSWORD in .env
Example bucketbackup-aplikasi
Basic testmc cp, mc ls, download and compare, then mc rm
Second S3 testaws --endpoint-url http://127.0.0.1:9000 s3 ls and s3 cp
Safe accessBind 127.0.0.1, SSH tunnel, then reverse proxy + HTTPS

Final checklist

  • The MinIO container is Up and /minio/health/live returns 200.
  • The backup-aplikasi bucket exists.
  • A dedicated backup user exists, so scripts do not use root.
  • Upload, download, and delete of a test file through mc worked.
  • The AWS CLI can read and write to the same bucket.
  • One real backup was sent and successfully test-restored from a temporary folder.
  • The console is not exposed directly to the internet without a reverse proxy and HTTPS.
  • .env is permission 600 and credentials are in a password manager.

Which workload will you point at MinIO first — database backups, user uploads, or log archives? Share your choice and your docker system df — no, your MinIO test result — in the comments.

Baca juga:

Komentar