SSD Nodes Learn 8GB RAM — $66/yr
Guides Matt ConnorBy Matt Connor

MinIO: self-hosted object storage on a VPS

Run MinIO on one Ubuntu 24.04 VPS for an S3 API you own: verified binary install, systemd unit, mc basics, presigned URLs, and a restic backup target.

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

What self-hosted object storage with MinIO gives you

MinIO is self-hosted object storage that speaks the Amazon S3 API. Point restic or any S3 SDK at your own server, change one endpoint setting, and the client cannot tell the difference. This guide builds a single node on Ubuntu 24.04: a verified binary, a dedicated system user, a systemd unit that keeps the root credentials out of the unit file, and a bucket that restic backs up into.

S3 (simple storage service) is an HTTP API rather than a filesystem. You PUT an object into a bucket under a key and you GET it back, and there is no partial write and no rename. Backup tools like that model, because an object either arrived whole or it did not arrive.

One node holds one copy of your data. That is the trade you are making. You get an S3 endpoint you control for the price of a VPS, and you also inherit every job the cloud provider used to do, from replacing a failed disk to patching the server software. The section near the end says plainly when that trade is a good one.

The state of the MinIO community edition in July 2026

Read this part before you build on it, because it changed recently. In May 2025 MinIO removed the administration features from the web console in the community edition. What remains in the browser is an object browser, so buckets and access keys are managed with the mc command line client instead.

Later in 2025 MinIO stopped publishing pre-compiled community binaries. The project README now says the community edition is distributed as source code only. The older download URLs still work: as of July 2026 they serve server build RELEASE.2025-09-07T16-13-09Z and client build RELEASE.2025-08-13T08-35-41Z, and no newer community build has appeared. So the binary below is real and it runs, and it is frozen. Security fixes published after September 2025 are not in it.

That one fact shapes the rest of this guide. It is why MinIO here listens on 127.0.0.1 and reaches the internet only through a proxy you control. If you would rather track fixes, build from source. The vendor README gives a single command, go install github.com/minio/minio@latest, which needs a Go toolchain and writes the binary to ~/go/bin/minio. Install that binary to /usr/local/bin/minio and every other step here is unchanged.

Install the MinIO binary and verify the download

Download the pinned release and its published checksum. The -f flag makes curl fail on an HTTP error instead of saving the error page under the name you asked for, which is how people end up installing a 404 page and wondering why it will not 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"

Do not reach for sha256sum -c minio.sha256sum here. The label written after the hash inside that file is minio.RELEASE.2025-09-07T16-13-09Z, and we saved the download as minio, so -c looks for a file that does not exist. It reports No such file or directory and then WARNING: 1 listed file could not be read, which looks like a corrupted download and is not one. The label is only a name. The hash is the part that carries the guarantee.

Be clear about what this check proves. The binary and the hash come from the same vendor over the same connection, so a match proves the download is complete and was not damaged or altered in transit. It does not prove the vendor is trustworthy. That is a different problem and no sha256sum command solves it.

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

minio --version prints minio version RELEASE.2025-09-07T16-13-09Z followed by a couple of build lines. A Permission denied here means the mode is wrong, and command not found means /usr/local/bin is not on your PATH.

Create a system user and a data directory

MinIO accepts uploads from the network, so it should not run as root. Give it an account with no 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 creates a system account with a UID below 1000, which keeps it out of the range used for people. -M skips the home directory, because an account that never logs in has nothing to keep in one. Check the result with id minio-user, and with stat -c '%U %a' /var/lib/minio, which should print minio-user 750.

The data directory has to be writable by that user, not only readable. On first start MinIO creates a .minio.sys directory inside the volume to hold its own configuration, so a root-owned directory makes MinIO exit during startup with a message that ends in permission denied. The same rule applies to every service you run this way, and least-privilege service users on a VPS goes through it properly.

Put the root credentials in an environment file

The root credentials open every bucket, so they do not belong in the unit file, which is world readable. Create the file with the right mode first and write into it second, so the password is never sitting in a readable file even for a 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 truncates an existing file rather than recreating it, so the mode stays at 600 and the owner stays root. That is deliberate. systemd reads EnvironmentFile as root before it drops privileges to User=, which means the service account never needs to read its own credentials. Once the service is running, prove it with sudo -u minio-user cat /etc/default/minio. That command must print Permission denied.

Two MinIO behaviours are worth knowing before you start it. With no MINIO_ROOT_USER and no MINIO_ROOT_PASSWORD in its environment, MinIO does not refuse to start. It starts with the documented default credentials minioadmin:minioadmin, which is the first pair any scanner tries, and it looks completely healthy while doing it. A password under 8 characters is rejected instead: MinIO exits at startup with an error saying the credentials are invalid, because the access key needs at least 3 characters and the secret key at least 8.

MINIO_VOLUMES is the data path and MINIO_OPTS holds the flags. Binding to 127.0.0.1 means nothing outside this VPS can reach the S3 API yet, which is the right default. You open it deliberately later, through a proxy holding 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

There is no leading - on EnvironmentFile, and that is a decision rather than a typo. With the dash, systemd ignores a missing file and starts MinIO anyway, so a deleted file or a misspelled path silently hands you a server running on minioadmin:minioadmin. Without the dash, a missing file fails the unit before MinIO ever runs, and journalctl -u minio shows Failed to load environment files: No such file or directory. A unit that refuses to start is much easier to notice than a server quietly accepting the default password.

$MINIO_VOLUMES and $MINIO_OPTS are unquoted on purpose, because systemd splits unquoted variables on whitespace into separate arguments. That is how the four words in MINIO_OPTS become four arguments to minio server. LimitNOFILE=65536 raises the file descriptor limit, because every open connection and every open data file costs one descriptor and the default of 1024 runs out under load.

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 should print active, and the health endpoint should answer 200. journalctl -u minio -n 20 --no-pager shows the API address the server is listening on. If the unit keeps restarting, systemd gives up and logs Start request repeated too quickly, which means MinIO exits on every attempt: the reason is printed in 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. On container virtualisation that shares the host kernel, such as OpenVZ or LXC, they can fail, and the unit then reports status=226/NAMESPACE. Remove those two lines and it starts. The unit itself is an ordinary one, and systemd services and timers on a VPS covers the rest of the directives.

Install mc and prove a round trip

The MinIO client is mc. Do not install it with apt install mc. That package is Midnight Commander, a file manager unrelated to 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 an alias, then move an object through it.

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 should list hello.txt with its size, and mc cat should print hello object storage. That round trip is the real proof the server works, because it makes the same signed S3 requests every other client will make. mc admin info local prints the server status if you want a second opinion.

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

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

This command must fail. If it succeeds, the environment file never reached the process and your server is running on the default credentials. Fix that before anything else touches the machine.

mc stores aliases in ~/.mc/config.json in plain text, so those credentials sit in the home directory of whoever ran the command. Running mc under sudo puts the root credentials in /root/.mc/config.json. Keep the root alias on one administrator account and give every application its own key.

Hand out one object with a presigned URL

A presigned URL is an ordinary HTTPS link with a signature and an expiry attached. Anyone holding the link can fetch that one object without an account and without a client.

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

The output carries X-Amz-Signature and X-Amz-Expires in the query string. Two things about it surprise people. The link is built from the endpoint in the alias you used, so an alias on 127.0.0.1 produces a link that only this machine can open: make a second alias on your public hostname for links you intend to send. And there is no revoke button. The signature stays valid until it expires, so a short expiry is the only control you have. Seven days is the maximum the S3 signature format allows.

Give restic its own key and bucket

The root credentials can read and delete every bucket, so a backup job must not hold them. Create a bucket, a policy scoped to that bucket, and a user that gets nothing 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 ships a built-in readwrite policy that would have been one command shorter, and it grants full access to every bucket on the server. The policy above names the bucket twice on purpose: once as arn:aws:s3:::restic so that listing the bucket works, and once as arn:aws:s3:::restic/* for the objects inside it. In S3 a bucket and its objects are separate resources, so a policy naming only one of them fails in a way that looks like a broken client.

Test the limit before you trust it.

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 succeeds and the second fails with Access Denied. A policy you have not tested is a guess.

Now point restic at the bucket. restic reads S3 credentials from the standard AWS environment variables, so there is no restic-specific credential file 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 asks for a repository password. That password encrypts the repository, so MinIO only ever stores ciphertext, and losing the password loses the backup. A run started by a systemd timer has no terminal to type into, so set RESTIC_PASSWORD_FILE to a mode 600 file for scheduled backups.

One placement rule matters more than any command above. A restic repository on the same VPS as the data it protects saves you from a bad rm and from nothing else. The MinIO node should be a different machine, ideally in a different region. restic backups on a VPS covers scheduling and retention on top of this.

Terminate TLS with nginx

MinIO sits on localhost, so nginx is the public surface. Issue the certificate first, as described in 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;
    }
}

Several of those lines are load-bearing. client_max_body_size 0 removes the default 1 MB body limit, which otherwise rejects any larger upload with 413 Request Entity Too Large before MinIO sees the request at all. proxy_request_buffering off streams the upload straight through, because the default spools the whole request into a temporary file first, and a large object then needs disk space twice over. proxy_set_header Host $http_host is the subtle one: an S3 signature covers the Host header, so a proxy that rewrites it makes every request fail with SignatureDoesNotMatch while the access log shows a normal request arriving.

Tell MinIO its public name as well, so the links it generates point at the proxy rather than at localhost.

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

The firewall stays small. Allow SSH and HTTPS, and leave ports 9000 and 9001 with no rule at all, because an address bound to 127.0.0.1 is unreachable from another machine whatever the firewall says. ufw firewall basics on a VPS has the commands.

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

Single node here means one drive with zero parity. MinIO's own documentation describes this layout as suited to testing and to small workloads with no availability requirement. There is no second copy inside the deployment, so the durability of every object is the durability of one VPS disk. Features that assume a distributed erasure-coded backend, bucket replication and object locking among them, belong to multi-drive deployments, so do not promise anyone an immutable retention policy on this setup.

It is a good fit as a restic target on a second VPS in another region, and as an S3 endpoint for development work and CI artifacts, where losing a bucket costs you a rebuild and nothing more. It is also reasonable for user uploads in a small application, as long as you own the recovery plan and you have actually tested a restore.

Choose managed S3 when a contract or a regulator asks for object lock or multi-region durability, or when you would rather not be the person paged at 03:00 because a disk filled up. The frozen build is the other honest reason. As of July 2026 the pre-compiled community binary dates from September 2025 and receives no fixes, so running it means accepting that, or building from source and keeping up with the project yourself.

One boundary is worth stating because it comes up often. Object storage is not a database. Every write replaces a whole object, so a live SQL file on an S3 bucket is slow and unsafe. Keep the database on local disk and back it up into the bucket instead: running SQLite in production on a VPS describes that split.

Failure modes and the messages you will 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 means /etc/default/minio is missing or its path is misspelled in the unit. A message ending in permission denied means the data directory is not writable by the service account, so check that stat -c '%U' /var/lib/minio/data prints minio-user.

minioadmin:minioadmin still logs in. The environment file never reached the process. Confirm the unit contains EnvironmentFile=/etc/default/minio, run sudo systemctl daemon-reload, then restart the service. MinIO reads its root credentials once at startup, so editing that file without a restart changes nothing.

Address already in use at startup. Another process holds port 9000. Find it with sudo ss -ltnp | grep :9000 before you change MinIO's port.

Uploads over 1 MB fail through the proxy. nginx answered 413 Request Entity Too Large and MinIO never saw the request. Set client_max_body_size 0 in the server block.

SignatureDoesNotMatch. Either the secret key is wrong, or something between the client and MinIO rewrote the Host header, which the signature covers.

RequestTimeTooSkewed. The clock on the client or the server is wrong. Every S3 request carries a timestamp and is rejected outside a 15 minute window. Check timedatectl and confirm that time synchronisation is active.

Access Denied on a bucket you know exists. The key is scoped to a different bucket. Print what the policy actually allows with mc admin policy info local restic-rw and compare the bucket name in the resource lines.

FAQ

Is single-node MinIO good enough for real backups?

It is good enough as a restic target running on a separate machine from the data it protects. It is not good enough as your only copy. A single-drive deployment has zero parity, so there is no second copy inside MinIO, and if that VPS disk loses data the objects are gone. Keep a second target somewhere else, and restore from both at least once so you know the process works.

Why does sha256sum -c fail on MinIO's checksum file?

Because the label after the hash inside that file names the release, minio.RELEASE.2025-09-07T16-13-09Z, while your downloaded file is normally called minio. sha256sum -c looks for a file with the name written inside the checksum file, does not find it, and reports No such file or directory and WARNING: 1 listed file could not be read. The download is fine. Compare the hash strings directly and ignore the label, which carries no security meaning.

Where did the MinIO admin web console go?

MinIO removed the administration features from the community edition console in May 2025, leaving an object browser in the web interface. Buckets and users are now managed with the mc client, using commands such as mc admin user add and mc admin policy attach. That is the supported path in the community edition rather than a workaround, which is why this guide does everything from the command line.

How do I point restic at MinIO as an S3 backend?

Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to a MinIO access key and its secret, then use a repository string of the form s3:https://s3.example.com/restic, where the last path element is the bucket name. Create the bucket first with mc mb, because a key scoped to one bucket has no permission to create buckets. restic encrypts everything with its own repository password before upload, so MinIO stores ciphertext and never sees your files.

Do I have to run MinIO behind nginx?

You need TLS (transport layer security) whenever a client is not on the same machine, because S3 credentials and object data both travel inside the request. A proxy on port 443 with a certificate from certbot is the simplest way to get that, and it keeps certificate renewal away from MinIO. MinIO can also terminate TLS itself if you point --certs-dir at a directory holding public.crt and private.key, but then the service account needs read access to the renewed private key, which is extra work for the same result.

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