SSD Nodes Learn Hosting plans →
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: a verified source build, 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 an S3 SDK at your own server, change one endpoint setting, and for everyday object reads, writes, listings and presigned links that is all it takes. Compatible is not identical, though. MinIO does not implement Amazon's object ACLs, for example, and its maintainers have said they have no intention to, so an application that depends on ACL behaviour needs more than a new endpoint. This guide builds a single node on Ubuntu 24.04: a binary compiled from verified source, 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 in a standard bucket there is no partial write and no rename. Amazon's S3 Express One Zone directory buckets are the exception, with append and rename calls of their own, but that is one storage class and nothing in this guide relies on it. Backup tools like the standard 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 October 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 says the community edition is distributed as source code only, and since April 2026 the minio/minio repository has been archived under the notice THIS REPOSITORY IS NO LONGER MAINTAINED. The old download server has stopped serving the community files as well. Checked on 20 October 2026, every dl.min.io path this guide used to download from, the server binary, the client binary and their checksum files, answers 410 Gone, which is a deliberate removal rather than an outage. The only mc binary still on that server is the AIStor Client under /aistor/mc/release/, which belongs to the commercial line. The download page on min.io now lists AIStor Server, AIStor Client, AIStor SDK and a few AIStor add-ons, plus a free AIStor tier it describes as aimed at developers, researchers and small organisations running standalone deployments, and no community build.

The source is still there, and it is newer than the last binary. The final community release is RELEASE.2025-10-15T17-29-55Z, published on 16 October 2025 as a security release: it fixes a privilege escalation through a session policy bypass in service accounts and STS, and the release notes advise all users to upgrade immediately. The last pre-compiled binary was the September 2025 build, so it never received that fix. This guide compiles the October release, and because the repository is archived, it is also the last fix the community edition will get.

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.

Install the MinIO binary and verify the download

MinIO is written in Go, and the vendor README builds it with a single go install command that needs Go 1.24 or newer. Install Go from the official tarball first. On 20 October 2026 the current stable release on go.dev/dl is go1.27.3, released on 19 October 2026 with a security fix to the crypto/x509 package, and the hash below is the one published on that page for the linux-amd64 archive. If you pick a newer Go, take its hash from the same page. 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 unpacking a 404 page and wondering why it is not an archive.

cd /tmp
GOVER=go1.27.3
curl -fsSL "https://go.dev/dl/$GOVER.linux-amd64.tar.gz" -o go.tar.gz

Compare the two hashes, and compare only the hashes.

published=65efbc80fdc1fa2cd2631cad5b481777a68e8725f34e89f50fc6ac714e379b40
downloaded=$(sha256sum go.tar.gz | awk '{print $1}')
[ "$published" = "$downloaded" ] && echo "checksum ok"

Be clear about what this check proves. 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 rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go.tar.gz
export PATH=$PATH:/usr/local/go/bin
go version

Now build the final community release as your normal user. go install checks every module it downloads, MinIO's own source included, against Go's public checksum database at sum.golang.org, so a tampered or swapped module stops the build with a security error instead of producing a binary.

go install github.com/minio/minio@RELEASE.2025-10-15T17-29-55Z
sudo install -o root -g root -m 755 ~/go/bin/minio /usr/local/bin/minio
minio --version
go version -m /usr/local/bin/minio | head -n 3

minio --version prints DEVELOPMENT.GOGET where a release name used to be, because go install does not apply the build flags the vendor used to stamp its own binaries. go version -m reads the module information Go embeds in every binary, and its mod github.com/minio/minio line names the commit you built. A Permission denied here means the mode is wrong, and command not found means /usr/local/bin is not on your PATH. If the VPS is short on memory for the compile, run the same go install on another Linux machine of the same architecture and copy the binary across.

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.

dl.min.io still serves an mc binary, but not the community one. The old community path, https://dl.min.io/client/mc/release/linux-amd64/mc, answers 410 Gone. What MinIO offers there now is the AIStor Client at https://dl.min.io/aistor/mc/release/linux-amd64/mc, which its AIStor CLI reference points to and describes as having exclusive functionality for licensed AIStor Server deployments. That reference says nothing about using it against a community server, so this guide stays with the community client. The minio/mc repository has been archived since July 2026, so build its last release, RELEASE.2025-08-13T08-35-41Z, with the same Go toolchain. The checksum database covers this download too.

go install github.com/minio/mc@RELEASE.2025-08-13T08-35-41Z
sudo install -o root -g root -m 755 ~/go/bin/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 per-link revoke button. The signature stays valid until it expires or the key that signed it is removed or changed, and for a link signed with the root credentials that means rotating them, so a short expiry is the practical control. 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, so there is no parity and no second copy inside the deployment, and the durability of every object is the durability of one VPS disk. MinIO's own documentation, which has lived only as source in the minio/docs repository since the hosted community docs were taken down in October 2025, describes a standalone deployment as meant for early development and evaluation and says MinIO does not recommend it in production. Object locking can be switched on for a bucket, but on one VPS it only stops deletion through the S3 API. Anyone with root on the machine can still remove the files on disk, 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 code is the other honest reason. As of 20 October 2026 the community edition is archived and no longer maintained, and its last release, RELEASE.2025-10-15T17-29-55Z, is the one this guide compiles. Nothing after it will be fixed, so running it means accepting that, or moving to MinIO's commercial AIStor line or to another self-hosted store, which self-hosted MinIO alternatives compares.

One boundary is worth stating because it comes up often. Object storage is not a database. In a standard bucket every write replaces a whole object, and even the append call Amazon added for S3 Express One Zone only adds data to the end, 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 signed S3 request carries a timestamp, and MinIO, like Amazon S3, rejects a header-signed request whose timestamp is more than 15 minutes away from its own clock. Presigned links are checked against their expiry instead. 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?

You will only meet that file in a copy saved earlier, because dl.min.io answered 410 Gone for every community binary and checksum file when checked on 20 October 2026. The label after the hash inside that file names the release, such as minio.RELEASE.2025-09-07T16-13-09Z, while the 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 intended 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 without it every object crosses the network in plain text, along with the access key ID and the request signature. The secret key itself never leaves the client, since S3 signing only uses it locally to compute that signature, but anyone on the path can still read your data and replay a captured request while its timestamp is valid. A proxy on port 443 with a certificate from certbot is the simplest way to get TLS, 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.

Was this article useful?