SSD Nodes Learn 8GB RAM — $66/yr
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-02

How to Run MinIO for S3 Storage on Your VPS

Set up MinIO on one Ubuntu 24.04 VPS with verified binary, systemd, mc, presigned URLs, and restic backups, plus the honest one-copy storage trade-off.

Verified Every command ran end-to-end on a fresh Ubuntu 24.04 server, July 30, 2026.

Self-hosted object storage wey MinIO dey give you

MinIO na self-hosted object storage wey dey use Amazon S3 API. Point restic or any S3 SDK to your own server, change one endpoint setting, and the client no go know the difference. This guide dey build one node for Ubuntu 24.04: binary wey you don verify, dedicated system user, systemd unit wey keep the root credentials out of the unit file, and bucket wey restic go back up data into.

S3 (simple storage service) na HTTP API, no be filesystem. You PUT object inside bucket under one key and you GET am back. No partial write or rename dey happen. Backup tools like this model because object either arrive complete or e no arrive.

One node dey hold one copy of your data. Na this trade-off you dey make. You get S3 endpoint wey you control for VPS price, but you also inherit every work wey cloud provider dey do before, from replacing failed disk to patching server software. The section near the end go explain clearly when this trade-off make sense.

MinIO community edition state for July 2026

Read this part before you build on top of am, because e don change recently. For May 2025, MinIO remove the administration features from the web console for the community edition. Wetin remain for browser na object browser, so you go manage buckets and access keys with the mc command line client instead.

Later for 2025, MinIO stop to publish pre-compiled community binaries. The project README now talk say dem dey distribute the community edition as source code only. The old download URLs still dey work: as of July 2026, dem dey serve server build RELEASE.2025-09-07T16-13-09Z and client build RELEASE.2025-08-13T08-35-41Z, and no newer community build don show. So the binary wey dey below na real and e dey run, but dem don freeze am. Security fixes wey dem publish after September 2025 no dey inside am.

That one fact dey shape the rest of this guide. Na why MinIO for here dey listen on 127.0.0.1 and e dey reach the internet only through proxy wey you control. If you prefer track fixes, build am from source. The vendor README give one command, go install github.com/minio/minio@latest, wey need Go toolchain and dey write the binary to ~/go/bin/minio. Install that binary to /usr/local/bin/minio and every other step for here remain the same.

Install the MinIO binary and verify the download

Download the pinned release and the checksum wey dem publish. The -f flag dey make curl fail when HTTP error happen, instead of saving the error page with the name wey you request. Na so people dey install 404 page, then wonder why e no go execute.

cd /tmp
REL=RELEASE.2025-09-07T16-13-09Z
curl -fsSL "https://dl.min.io/server/minio/release/linux-amd64/archive/minio.$REL" -o minio
curl -fsSL "https://dl.min.io/server/minio/release/linux-amd64/archive/minio.$REL.sha256sum" -o minio.sha256sum

Compare the two hashes, and compare only the hashes.

published=$(awk '{print $1}' minio.sha256sum)
downloaded=$(sha256sum minio | awk '{print $1}')
[ "$published" = "$downloaded" ] && echo "checksum ok"

No use sha256sum -c minio.sha256sum here. The label wey dem write after the hash inside that file na minio.RELEASE.2025-09-07T16-13-09Z, and we save the download as minio, so -c dey look for file wey no exist. E report No such file or directory and then WARNING: 1 listed file could not be read. This one fit look like corrupted download, but e no be corrupted download. The label na only name. The hash na the part wey provide the guarantee.

Make you understand wetin this check prove. The binary and the hash come from the same vendor through the same connection, so when dem match, e prove say the download complete and no damage or change happen during transit. E no prove say the vendor trustworthy. Na different matter be that, and no sha256sum command fit solve am.

sudo install -o root -g root -m 755 minio /usr/local/bin/minio
minio --version

minio --version dey print minio version RELEASE.2025-09-07T16-13-09Z followed by some build lines. Permission denied here mean say the mode wrong, and command not found mean say /usr/local/bin no dey inside your PATH.

Create system user and data directory

MinIO dey accept uploads from network, so e no suppose run as root. Give am account wey no get home directory and no login shell.

sudo groupadd -r minio-user
sudo useradd -M -r -g minio-user -s /usr/sbin/nologin minio-user
sudo mkdir -p /var/lib/minio/data
sudo chown -R minio-user:minio-user /var/lib/minio
sudo chmod 750 /var/lib/minio

-r dey create system account with UID wey dey below 1000, so e no go enter the range wey people dey use. -M dey skip the home directory, because account wey never dey log in no get anything to keep there. Check the result with id minio-user, and with stat -c '%U %a' /var/lib/minio, wey suppose print minio-user 750.

The data directory must dey writable by that user, no be only readable. When MinIO start for the first time, e dey create .minio.sys directory inside the volume to keep im own configuration. So, directory wey root own go make MinIO exit during startup with message wey end with permission denied. This same rule apply to every service wey you run this way, and service users with least privilege for VPS explain am properly.

Put the root credentials inside an environment file

Root credentials fit open every bucket, so dem no belong inside the unit file, wey everybody fit read. Create the file with the correct mode first, then write inside am. This one make sure say password no dey inside readable file even for one moment.

sudo install -o root -g root -m 600 /dev/null /etc/default/minio
printf 'MINIO_ROOT_USER=minio-root\nMINIO_ROOT_PASSWORD=%s\nMINIO_VOLUMES="/var/lib/minio/data"\nMINIO_OPTS="--address 127.0.0.1:9000 --console-address 127.0.0.1:9001"\n' "$(openssl rand -base64 24)" | sudo tee /etc/default/minio > /dev/null
sudo sed -n 's/^MINIO_ROOT_PASSWORD=//p' /etc/default/minio

tee dey truncate existing file instead of creating another one, so the mode remain 600 and the owner remain root. Na deliberate setup be this. systemd dey read EnvironmentFile as root before e drop privileges to User=. This mean say the service account no need read its own credentials. After the service start, prove am with sudo -u minio-user cat /etc/default/minio. That command must print Permission denied.

Two MinIO behaviours dey important to know before you start am. If MINIO_ROOT_USER and MINIO_ROOT_PASSWORD no dey for its environment, MinIO no refuse to start. E start with the documented default credentials minioadmin:minioadmin. Na the first credential pair wey any scanner go try, and the service go look completely healthy while e dey use am. MinIO reject password wey dey under 8 characters instead. MinIO go exit during startup with error say the credentials no valid, because the access key need at least 3 characters and the secret key need at least 8.

MINIO_VOLUMES na the data path, while MINIO_OPTS dey hold the flags. Binding to 127.0.0.1 mean say nothing outside this VPS fit reach the S3 API yet. Na the correct default be this. Later, you go open am deliberately through a proxy wey dey hold a certificate.

Write the systemd unit

Create /etc/systemd/system/minio.service:

[Unit]
Description=MinIO object storage
Documentation=https://github.com/minio/minio
Wants=network-online.target
After=network-online.target

[Service]
User=minio-user
Group=minio-user
EnvironmentFile=/etc/default/minio
ExecStart=/usr/local/bin/minio server $MINIO_VOLUMES $MINIO_OPTS
Restart=always
RestartSec=5
LimitNOFILE=65536
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target

No - dey before EnvironmentFile, and na deliberate choice, no be typo. If dash dey there, systemd go ignore file wey no dey and start MinIO anyway. So, if person delete file or spell path wrong, e go silently give you server wey dey run with minioadmin:minioadmin. Without dash, missing file go make the unit fail before MinIO ever runs, and journalctl -u minio go show Failed to load environment files: No such file or directory. Unit wey refuse to start dey easier to notice pass server wey quietly dey accept default password.

$MINIO_VOLUMES and $MINIO_OPTS no get quotes deliberately, because systemd dey split unquoted variables for whitespace into separate arguments. Na so the four words for MINIO_OPTS become four arguments for minio server. LimitNOFILE=65536 dey increase file descriptor limit, because every open connection and every open data file dey use one descriptor, and default value of 1024 go finish when load high.

sudo systemctl daemon-reload
sudo systemctl enable --now minio
systemctl is-active minio
curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9000/minio/health/live

is-active suppose print active, and health endpoint suppose answer 200. journalctl -u minio -n 20 --no-pager dey show API address wey server dey listen on. If unit dey restart again and again, systemd go give up and log Start request repeated too quickly. This mean say MinIO dey exit every time: the reason dey printed for the lines above that message, so read upward.

For more isolation, add ProtectSystem=full and ProtectHome=true to the [Service] section. Both need mount namespaces from the host kernel. For container virtualisation wey share host kernel, like OpenVZ or LXC, dem fit fail, and the unit go report status=226/NAMESPACE. Remove those two lines and e go start. The unit itself na ordinary one, and systemd services and timers for VPS cover the remaining directives.

Install mc and prove one round trip

The MinIO client na be mc. No install am with apt install mc. That package na Midnight Commander, wey be file manager and e no get connection with MinIO.

cd /tmp
curl -fsSL https://dl.min.io/client/mc/release/linux-amd64/mc -o mc
curl -fsSL https://dl.min.io/client/mc/release/linux-amd64/mc.sha256sum -o mc.sha256sum
[ "$(awk '{print $1}' mc.sha256sum)" = "$(sha256sum mc | awk '{print $1}')" ] && echo "checksum ok"
sudo install -o root -g root -m 755 mc /usr/local/bin/mc

Register the server as alias, then move one object through am.

MINIO_PASS=$(sudo sed -n 's/^MINIO_ROOT_PASSWORD=//p' /etc/default/minio)
mc alias set local http://127.0.0.1:9000 minio-root "$MINIO_PASS"
mc mb local/backups
echo "hello object storage" > /tmp/hello.txt
mc cp /tmp/hello.txt local/backups/hello.txt
mc ls local/backups
mc cat local/backups/hello.txt

mc ls suppose list hello.txt with its size, and mc cat suppose print hello object storage. That round trip na the real proof say the server dey work, because e dey make the same signed S3 requests wey every other client go make. mc admin info local go print the server status if you want another confirmation.

Run one more check now, while the box still empty.

mc alias set defaultcheck http://127.0.0.1:9000 minioadmin minioadmin

This command must fail. If e succeed, the environment file no reach the process, and your server dey run with the default credentials. Fix that before anything else touch the machine.

mc dey store aliases for ~/.mc/config.json as plain text, so whoever run the command go get those credentials for the home directory. If you run mc under sudo, e go put the root credentials for /root/.mc/config.json. Keep the root alias for one administrator account, and give every application its own key.

Give one object with a presigned URL

A presigned URL na normal HTTPS link wey get signature and expiry attached. Anybody wey hold the link fit fetch that one object without account and without client.

mc share download --expire 12h local/backups/hello.txt

The output get X-Amz-Signature and X-Amz-Expires for the query string. Two things about am dey surprise people. The link dey use the endpoint for the alias wey you use, so alias for 127.0.0.1 go produce link wey only this machine fit open: make another alias for your public hostname for links wey you plan to send. And no revoke button dey. The signature go remain valid until e expire, so short expiry na the only control wey you get. Seven days na the maximum wey the S3 signature format allow.

Give restic im own key and bucket

The root credentials fit read and delete every bucket, so backup job no suppose hold dem. Create one bucket, one policy wey scope to that bucket, and one user wey no get anything else.

mc mb local/restic
cat > /tmp/restic-rw.json <<'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:ListBucket", "s3:GetBucketLocation"],
      "Resource": ["arn:aws:s3:::restic"]
    },
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
      "Resource": ["arn:aws:s3:::restic/*"]
    }
  ]
}
EOF
RESTIC_KEY=$(openssl rand -base64 24)
mc admin policy create local restic-rw /tmp/restic-rw.json
mc admin user add local restic-backup "$RESTIC_KEY"
mc admin policy attach local restic-rw --user restic-backup

MinIO get built-in readwrite policy wey for make this one command shorter, but e dey give full access to every bucket for the server. The policy above name the bucket two times on purpose: once as arn:aws:s3:::restic so listing the bucket go work, and once as arn:aws:s3:::restic/* for the objects inside am. For S3, bucket and objects na separate resources, so policy wey name only one of dem go fail for way wey fit look like client don spoil.

Test the limit before you trust am.

mc alias set resticuser http://127.0.0.1:9000 restic-backup "$RESTIC_KEY"
mc ls resticuser/restic
mc ls resticuser/backups

The first ls go succeed and the second one go fail with Access Denied. Policy wey you never test na just guess.

Now point restic to the bucket. restic dey read S3 credentials from the standard AWS environment variables, so no restic-specific credential file dey involved.

sudo apt install -y restic
export AWS_ACCESS_KEY_ID=restic-backup
export AWS_SECRET_ACCESS_KEY="$RESTIC_KEY"
restic -r s3:http://127.0.0.1:9000/restic init
restic -r s3:http://127.0.0.1:9000/restic backup /etc
restic -r s3:http://127.0.0.1:9000/restic snapshots

restic init dey ask for repository password. That password dey encrypt the repository, so MinIO go only store ciphertext, and if password loss, backup loss too. A run wey systemd timer start no get terminal wey person fit type inside, so set RESTIC_PASSWORD_FILE to a mode 600 file for scheduled backups.

One placement rule matter pass any command above. If restic repository dey for the same VPS as the data wey e protect, e go save you from bad rm and nothing else. The MinIO node suppose dey for another machine, and e better make e dey for another region. restic backups for VPS explain scheduling and retention on top of this.

Terminate TLS with nginx

MinIO dey run for localhost, so nginx na the public surface. First issue the certificate, as e dey described for Let's Encrypt certificates with certbot and nginx, then use this server block.

server {
    listen 443 ssl;
    server_name s3.example.com;

    ignore_invalid_headers off;
    client_max_body_size 0;
    proxy_buffering off;
    proxy_request_buffering off;

    location / {
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_connect_timeout 300;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        chunked_transfer_encoding off;
        proxy_pass http://127.0.0.1:9000;
    }
}

Some of these lines dey important well-well. client_max_body_size 0 removes the default 1 MB body limit. Without am, e go reject any upload wey pass that size with 413 Request Entity Too Large before MinIO even sees the request. proxy_request_buffering off streams the upload direct through, because the default setting first dey save the whole request for temporary file. Large object then go need disk space two times. proxy_set_header Host $http_host na the subtle one: S3 signature dey cover the Host header. So, if proxy rewrites am, every request go fail with SignatureDoesNotMatch, even though access log shows say normal request reach.

Tell MinIO the public name too, so the links wey e generates go point to the proxy instead of localhost.

echo 'MINIO_SERVER_URL=https://s3.example.com' | sudo tee -a /etc/default/minio
sudo systemctl restart minio

The firewall fit remain small. Allow SSH and HTTPS. Leave ports 9000 and 9001 without any rule, because address wey bind to 127.0.0.1 no fit reach from another machine, no matter wetin firewall talk. ufw firewall basics on a VPS get the commands.

When single-node MinIO dey enough, and when you want real S3

Single node for here mean one drive with zero parity. MinIO own documentation describe this layout as suitable for testing and small workloads wey no need availability. No second copy dey inside the deployment, so durability of every object na durability of one VPS disk. Features wey assume distributed erasure-coded backend, including bucket replication and object locking, belong to multi-drive deployments. So no promise anybody immutable retention policy for this setup.

E good as restic target for another VPS for another region, and as S3 endpoint for development work and CI artifacts, where losing one bucket mean say you rebuild am and nothing more. E still make sense for user uploads for small application, as long as you own the recovery plan and you don actually test restore.

Choose managed S3 when contract or regulator ask for object lock or multi-region durability, or when you no want make dem page you for 03:00 because disk don full. The frozen build na the other honest reason. As of July 2026, the pre-compiled community binary come from September 2025 and no longer receive fixes. So to run am mean say you accept that, or you build from source and keep up with the project by yourself.

One boundary dey worth stating because e dey come up often. Object storage no be database. Every write replace the whole object, so live SQL file for S3 bucket slow and unsafe. Keep the database for local disk and back am up into the bucket instead: running SQLite in production on a VPS describe that split.

Failure modes and the messages wey you go see

The unit fails right after systemctl enable --now. Read journalctl -u minio -n 30 --no-pager. Failed to load environment files: No such file or directory mean say /etc/default/minio dey miss or dem spell the path wrongly for the unit. Message wey end with permission denied mean say the data directory no writable by the service account, so check say stat -c '%U' /var/lib/minio/data dey print minio-user.

minioadmin:minioadmin still logs in. The environment file no reach the process. Confirm say the unit get EnvironmentFile=/etc/default/minio, run sudo systemctl daemon-reload, then restart the service. MinIO dey read its root credentials once when e start, so if you edit that file without restart, nothing go change.

Address already in use for startup. Another process dey hold port 9000. Find am with sudo ss -ltnp | grep :9000 before you change MinIO port.

Uploads over 1 MB fail through the proxy. nginx answer 413 Request Entity Too Large and MinIO no see the request. Set client_max_body_size 0 for the server block.

SignatureDoesNotMatch. Either the secret key no correct, or something between the client and MinIO rewrite the Host header, wey the signature cover.

RequestTimeTooSkewed. The clock for the client or server no correct. Every S3 request carry timestamp and dem reject am outside 15 minute window. Check timedatectl and confirm say time synchronisation dey active.

Access Denied for bucket wey you know say e dey exist. The key dey scoped to another bucket. Print wetin the policy really allow with mc admin policy info local restic-rw and compare the bucket name for the resource lines.

FAQ

MinIO single-node setup good enough for real backups?

E good enough as restic target wey dey run for another machine, separate from the data wey e dey protect. But e no good enough as your only copy. Single-drive deployment get zero parity, so no second copy dey inside MinIO. If that VPS disk lose data, the objects don disappear. Keep another target for another place, and restore from both at least once so you fit confirm say the process dey work.

Why sha256sum -c dey fail for MinIO checksum file?

Na because the label after the hash inside that file dey name the release, minio.RELEASE.2025-09-07T16-13-09Z, but the file wey you download normally get name minio. sha256sum -c dey look for file wey get the name written inside the checksum file. E no find am, then e report No such file or directory and WARNING: 1 listed file could not be read. The download dey okay. Compare the hash strings directly and ignore the label, because the label no get any security meaning.

Where MinIO admin web console go?

MinIO remove the administration features from the community edition console for May 2025. Wetin remain for the web interface na object browser. Now, you dey manage buckets and users with the mc client, using commands like mc admin user add and mc admin policy attach. Na the supported method for the community edition, no be workaround. Na why this guide dey do everything from the command line.

How I go point restic to MinIO as S3 backend?

Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to MinIO access key and the secret wey belong to am. Then use repository string for the form s3:https://s3.example.com/restic, where the last path element na the bucket name. Create the bucket first with mc mb, because key wey get permission for only one bucket no fit create buckets. restic dey encrypt everything with its own repository password before upload. So MinIO dey store ciphertext and e never see your files.

I must run MinIO behind nginx?

You need TLS (transport layer security) anytime the client no dey on the same machine, because S3 credentials and object data dey travel inside the request. Proxy for port 443 with certificate from certbot na the simplest way to do am. E also keeps certificate renewal away from MinIO. MinIO fit terminate TLS by itself if you point --certs-dir to directory wey hold public.crt and private.key. But then the service account need read access to the renewed private key. That one na extra work for the same result.

#minio#s3#object-storage#self-hosted#vps