Self-Hosting MinIO Object Storage on a VPS (with Public URL, TLS, and Cross-Server Backup)
A complete, reproducible guide to running MinIO as S3-compatible object storage on a public VPS, serving files under your own subdomain over HTTPS, keeping the admin console private, connecting a backend, and backing data up to a second server.
Written from a real setup. Every gotcha we hit along the way is documented at the end so future-me can skip the pain.
Table of Contents
Architecture Overview
Part 1 — Install & Run MinIO
Part 2 — Public Access via Nginx + TLS
Part 3 — Buckets, Keys & Serving Files
Part 4 — Keeping the Console Private (SSH Tunnel)
Part 5 — Connecting a Backend
Part 6 — Backup / Sync to Another Server
Part 7 — Troubleshooting Log (Real Problems We Hit)
Quick Command Reference
Architecture Overview
Internet
│
│ HTTPS :443
▼
┌───────────────────────┐
│ Nginx (reverse proxy)│ files.abhishekg.com.np
│ + Let's Encrypt TLS │
└───────────┬───────────┘
│ proxy_pass → 127.0.0.1:9000
▼
┌───────────────────────┐
│ MinIO server │
│ API 127.0.0.1:9000 (S3) ← only Nginx reaches this
│ Console 127.0.0.1:9001 ← only SSH tunnel reaches this
│ Data /mnt/data/minio │
└───────────────────────┘
Key principle: MinIO binds only to 127.0.0.1. The only public door is Nginx on port 443. The console is never exposed to the internet — you reach it through an SSH tunnel.
Part 1 — Install & Run MinIO
1.1 Install the binary
wget https://dl.min.io/server/minio/release/linux-amd64/minio -O /usr/local/bin/minio
chmod +x /usr/local/bin/minio1.2 Create a dedicated (non-root) user and data directory
Never run MinIO as root.
useradd -r minio-user -s /sbin/nologin
mkdir -p /mnt/data/minio
chown -R minio-user:minio-user /mnt/data/minio1.3 Environment configuration
Generate a strong root password first:
openssl rand -base64 32Then write the environment file. Note both API and console are bound to 127.0.0.1 — this is what keeps them off the public internet.
cat > /etc/default/minio << 'EOF'
MINIO_VOLUMES="/mnt/data/minio"
MINIO_OPTS="--console-address 127.0.0.1:9001 --address 127.0.0.1:9000"
MINIO_ROOT_USER=admin
MINIO_ROOT_PASSWORD=YOUR_LONG_RANDOM_PASSWORD
MINIO_SERVER_URL=https://files.abhishekg.com.np
EOFMINIO_ROOT_USER/MINIO_ROOT_PASSWORD— admin credentials. Create separate service-account keys for the backend later; don't hand it these.MINIO_SERVER_URL— tells MinIO its public address (used for signed URLs, etc.).
1.4 systemd service
cat > /etc/systemd/system/minio.service << 'EOF'
[Unit]
Description=MinIO
After=network-online.target
Wants=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
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now minio
systemctl status minio1.5 Start / stop / status reference
systemctl start minio # start
systemctl stop minio # stop
systemctl restart minio # restart (after any config change)
systemctl status minio # check state
systemctl enable minio # auto-start on boot
systemctl disable minio # don't auto-start on boot
journalctl -u minio -n 50 --no-pager # last 50 log lines1.6 Health check (local)
curl http://127.0.0.1:9000/minio/health/live -IExpect HTTP/1.1 200 OK.
Part 2 — Public Access via Nginx + TLS
2.1 Firewall
Open only SSH + HTTP + HTTPS. Never open 9000/9001 publicly.
ufw allow 22
ufw allow 80
ufw allow 443
ufw enable
ufw status2.2 DNS
Point an A record for your subdomain at the VPS IP:
files.abhishekg.com.np A 203.0.113.45Verify (from anywhere):
dig files.abhishekg.com.np +short # → 203.0.113.452.3 Install Nginx + Certbot
apt update && apt install -y nginx certbot python3-certbot-nginx2.4 Nginx config (final, with TLS)
/etc/nginx/sites-available/minio:
# /etc/nginx/sites-available/minio
server {
server_name files.abhishekg.com.np;
ignore_invalid_headers off;
client_max_body_size 0; # allow unlimited upload size
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_send_timeout 300; # long uploads
proxy_read_timeout 300; # long downloads
proxy_http_version 1.1;
chunked_transfer_encoding off;
proxy_pass http://127.0.0.1:9000;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/files.abhishekg.com.np/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/files.abhishekg.com.np/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = files.abhishekg.com.np) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name files.abhishekg.com.np;
return 404; # managed by Certbot
}
client_max_body_size 0;= no upload size cap. The three*_timeout 300;lines prevent Nginx from cutting off slow large-file transfers (default is 60s).
2.5 Enable site + issue certificate
ln -s /etc/nginx/sites-available/minio /etc/nginx/sites-enabled/
nginx -t # must say "syntax is ok" / "test is successful"
systemctl reload nginx
certbot --nginx -d files.abhishekg.com.npCertbot rewrites the config to add the listen 443 ssl block and the HTTP→HTTPS redirect (shown above), and sets up auto-renewal.
2.6 Verify the full public chain
curl -I https://files.abhishekg.com.np/minio/health/liveExpect HTTP/1.1 200 OK with Server: nginx. That single response proves DNS + TLS + Nginx + MinIO are all wired correctly end to end.
⚠️ The bare root URL showing "nothing" is NORMAL.
https://files.abhishekg.com.np/returns empty /AccessDeniedbecause the S3 API has nothing at/. It is an API, not a website. Test with a real object path instead (below).
Part 3 — Buckets, Keys & Serving Files
3.1 Install the MinIO client (mc)
wget https://dl.min.io/client/mc/release/linux-amd64/mc -O /usr/local/bin/mc
chmod +x /usr/local/bin/mc3.2 Add an alias (local, over loopback)
mc alias set local http://127.0.0.1:9000 admin YOUR_ROOT_PASSWORD
mc admin info local # verify connection3.3 Create a bucket and a test file
echo "hello" > /tmp/test.txt
mc mb local/uploads
mc cp /tmp/test.txt local/uploads/test.txt3.4 Public vs private buckets
Public (direct URLs — for genuinely public assets):
mc anonymous set download local/uploadsNow anyone can fetch:
https://files.abhishekg.com.np/uploads/test.txtOpening that in a browser and seeing hello is your end-to-end proof.
Make a bucket private again:
mc anonymous set none local/uploadsFor sensitive data, default to private buckets + presigned URLs generated by your backend. Nothing is publicly listable or guessable that way.
3.5 Create a scoped service account for the backend
Don't give the backend your root/admin credentials. Mint dedicated keys:
mc admin user svcacct add local adminThis prints an Access Key and Secret Key — those go in the backend config. You can rotate/revoke them without touching your admin login.
mc admin user svcacct list local admin # list keys
mc admin user svcacct remove local <ACCESS_KEY> # revoke onePart 4 — Keeping the Console Private (SSH Tunnel)
The console is bound to 127.0.0.1:9001, so it's unreachable from the internet regardless of firewall — nothing outside the box can connect to it. Confirm from a different machine (not the VPS):
curl -I --connect-timeout 5 http://203.0.113.45:9001 # should refuse / time out
curl -I --connect-timeout 5 http://203.0.113.45:9000 # should refuse / time outBoth should fail. To actually use the console, tunnel in over SSH from your laptop:
ssh -L 9001:127.0.0.1:9001 root@203.0.113.45This forwards your laptop's localhost:9001 → the VPS's 127.0.0.1:9001 through the encrypted SSH connection. Then browse to:
http://localhost:9001Log in with admin / root password. Full console, nothing public.
Variants:
ssh -N -L 9001:127.0.0.1:9001 root@203.0.113.45 # tunnel only, no shell (Ctrl+C to close)
ssh -fN -L 9001:127.0.0.1:9001 root@203.0.113.45 # background itClosing the tunnel: the tunnel lives and dies with the SSH session. exit, Ctrl+D, or closing the terminal ends it — the forwarded port disappears, nothing lingers. For a backgrounded (-f) tunnel, kill it manually:
ps aux | grep "9001:127.0.0.1:9001"
kill <PID>Why not expose the console on its own subdomain? You can (Nginx block →
127.0.0.1:9001+ certbot + optionalallow <your-ip>; deny all;), but that puts an admin login on the public internet. The SSH tunnel exposes nothing new and is the recommended approach. Theallow/denyIP-restriction block is only relevant if you deliberately choose the public-subdomain route — it is NOT added to the mainfiles.…server block, which must serve the whole internet.
Part 5 — Connecting a Backend
Any S3 SDK works. Config values:
endpoint: https://files.abhishekg.com.np
region: us-east-1 # MinIO ignores it, but SDKs require a value
accessKey: <service account key from Part 3.5>
secretKey: <service account secret>
bucket: uploads
forcePathStyle: true # CRITICAL for MinIOforcePathStyle: true(a.k.a.s3ForcePathStyle) is the setting people most often miss. Without it the SDK tries virtual-host style (uploads.files.abhishekg.com.np) and fails.If the backend runs on the same VPS, point it at
http://127.0.0.1:9000instead — faster, no TLS overhead, stays internal.
File-serving model:
Public files (logos, public documents) → public bucket, direct URLs like https://files.abhishekg.com.np/uploads/file.jpg
Private files (sensitive) → private bucket, backend generates time-limited presigned URLs via the S3 SDK.
Part 6 — Backup / Sync to Another Server
Goal: copy data from a source MinIO to a destination MinIO. The tool is mc mirror. It is idempotent — re-running only copies new/changed files, so there's no "hard sync." Run it any time to catch the destination up.
Direction & where to run it matters. Run
mcon the machine that can reach the other one. A box behind NAT/LAN (e.g.192.168.1.x) can reach a public VPS, but the public VPS usually cannot reach back into the LAN. So run the mirror from the LAN box, pushing up.
6.1 Add both aliases (on the source machine)
# source: local MinIO on this machine
mc alias set src http://127.0.0.1:9000 LOCAL_ACCESS_KEY LOCAL_SECRET_KEY
# destination: the public VPS, over HTTPS (encrypted, clean — no raw IPs/ports)
mc alias set dst https://files.abhishekg.com.np VPS_ACCESS_KEY VPS_SECRET_KEY
mc admin info src # verify
mc ls dst # verify (lists buckets or returns empty cleanly)6.2 Create the destination bucket & mirror
mc mb dst/uploads # ok if it already exists
mc mirror --dry-run src/uploads dst/uploads # preview — copies nothing
mc mirror --overwrite src/uploads dst/uploads # real runUseful flags:
Flag Effect --overwrite Update files that changed on the source. --remove Also delete on the destination what's gone from source (exact 1:1 replica — destructive, be careful). --preserve Keep metadata. --dry-run Show what would happen, change nothing.
Whole-server instead of one bucket:
mc mirror --overwrite src dst6.3 Verify the copy
mc ls --recursive src/uploads | wc -l
mc ls --recursive dst/uploads | wc -l # same count = complete
mc du src/uploads
mc du dst/uploads # matching sizes = complete6.4 Automate (optional) — cron nightly sync
Cron cannot re-add aliases, so this requires the aliases to stay configured.
crontab -e0 2 * * * /usr/local/bin/mc mirror --overwrite src/uploads dst/uploads >> /var/log/minio-sync.log 2>&16.5 Security cleanup after a one-off backup
mc stores alias credentials in plaintext in ~/.mc/config.json. If the machine you ran the backup from isn't a permanent, trusted backup runner, remove the aliases afterward — especially dst, which holds the VPS keys:
mc alias remove dst
mc alias remove srcThere is no persistent connection between the two servers to close — mc mirror opens HTTPS requests, transfers, and closes them itself. The only cleanup is the cached credentials above.
Decision: Manual (re-add aliases → mirror → remove them) for max security, or Automatic (keep aliases + cron) for zero effort. Cron needs the aliases to persist, so it implies keeping them.
Part 7 — Troubleshooting Log (Real Problems We Hit)
Every one of these actually happened during setup. Documented so they're a lookup, not a re-debug.
7.1 "The public URL shows nothing / blank page"
Symptom: Browsing https://files.abhishekg.com.np/ returns empty or AccessDenied.
Cause: Not a bug. The S3 API has nothing at the root path /. It's an API, not a website.
Fix: Test a real object path instead: https://files.abhishekg.com.np/uploads/test.txt. If that serves the file, you're fine. Confirm the chain with:
curl -I https://files.abhishekg.com.np/minio/health/live # → 200 OK7.2 mc alias set → "signature does not match" / "credentials" error
Symptom:
mc: <ERROR> ... The request signature we calculated does not match ...Causes & fixes:
Wrong access key or secret → use the real MinIO keys.
Secret key too short — MinIO requires the secret to be ≥ 8 characters. A secret like
dadais rejected on first use.Note:
mc alias setmay report "Added successfully" without validating. It only actually authenticates on the first real request (mc ls,mc admin info). Always verify withmc admin info <alias>before relying on it.
7.3 mc mb → "you already own it"
Symptom:
mc: <ERROR> Unable to make bucket ... you already own it.Cause: The bucket already exists (created earlier or by another run).
Fix: Harmless — ignore it and proceed. The bucket is there.
7.4 Console reachable on the public IP (:9001)
Symptom: curl -I http://203.0.113.45:9001 returns 200 OK from "MinIO Console".
Cause: MinIO was binding the console to all interfaces (0.0.0.0), or the check was run from the VPS itself (loopback always answers — that does NOT prove public exposure; re-test from another machine).
Fix: Bind console (and API) to loopback in /etc/default/minio:
MINIO_OPTS="--console-address 127.0.0.1:9001 --address 127.0.0.1:9000"
systemctl restart minioThen reach the console via SSH tunnel (Part 4). Re-verify from a different machine that :9000 and :9001 refuse/time out.
7.5 DNS: mc alias set dst https://... → "lookup … i/o timeout"
Symptom (on the backup source box):
mc: <ERROR> ... Get "https://files.abhishekg.com.np": dial tcp: lookup ... i/o timeout.Cause: The machine's local DNS resolver failed to resolve the target domain. The source box (operating on a 192.168.1.x local network with a gateway router at 192.168.1.1) had its /etc/resolv.conf configured with unresponsive upstream DNS nameservers (such as 192.0.2.10 / 192.0.2.20) automatically pushed via local DHCP.
Diagnosis sequence:
cat /etc/resolv.conf # what resolvers are set
ls -l /etc/resolv.conf # is it a systemd-resolved symlink?
ping -c 2 8.8.8.8 # does the box have internet at all?
dig @8.8.8.8 files.abhishekg.com.np +short # does an explicit good resolver work?
dig files.abhishekg.com.np +short # does the DEFAULT resolver work?The tell: ping 8.8.8.8 worked and dig @8.8.8.8 … returned 103.69.127.45, but plain dig … returned nothing → internet fine, default DNS broken.
Fix (systemd-resolved managed — the symlink was /etc/resolv.conf -> /run/systemd/resolve/resolv.conf):
ip route | grep default # find the interface (was: eth0)
# quick test:
resolvectl dns eth0 8.8.8.8 1.1.1.1
# permanent:
nano /etc/systemd/resolved.confUnder [Resolve]:
[Resolve]
DNS=8.8.8.8 1.1.1.1
FallbackDNS=8.8.4.4
Domains=~.Domains=~. forces resolved to use these servers for all domains, overriding DHCP-supplied ones. Then:
systemctl restart systemd-resolved
resolvectl status # confirm 8.8.8.8
dig files.abhishekg.com.np +short # → 203.0.113.45Nuclear option if DHCP keeps re-injecting bad DNS — make resolv.conf a static, immutable file:
rm /etc/resolv.conf
echo "nameserver 8.8.8.8" > /etc/resolv.conf
echo "nameserver 1.1.1.1" >> /etc/resolv.conf
chattr +i /etc/resolv.conf # lock it; nothing can overwrite
# to edit later: chattr -i /etc/resolv.conf7.6 resolvectl dns didn't seem to take effect
Symptom: After resolvectl dns eth0 8.8.8.8, plain dig still returned nothing while dig @8.8.8.8 worked.
Cause: DHCP-provided DNS was still winning for default lookups; the per-link setting wasn't overriding it.
Fix: Set Domains=~. in resolved.conf (forces these resolvers for everything), or use the immutable resolv.conf approach in 7.5.
7.7 Bandwidth / large-file worries through Nginx
Context: Mirrored multi-GB files (a 10.8 GB PDF, 12 GB image) up through Nginx without issue.
Why it worked: client_max_body_size 0; (no cap) plus the proxy_*_timeout 300; lines. If a very large transfer ever stalls, raise those timeouts — the default 60s is what would cut it off.
Quick Command Reference
Service control (VPS):
systemctl {start|stop|restart|status} minio
systemctl {start|stop|restart|reload} nginx
journalctl -u minio -n 50 --no-pagerHealth checks:
curl -I http://127.0.0.1:9000/minio/health/live # local
curl -I https://files.abhishekg.com.np/minio/health/live # public chain
dig files.abhishekg.com.np +short # DNS → 103.69.127.45Buckets & files:
mc alias set local http://127.0.0.1:9000 admin PASSWORD
mc mb local/uploads
mc cp file.txt local/uploads/
mc anonymous set download local/uploads # make public
mc anonymous set none local/uploads # make private
mc admin user svcacct add local admin # backend keysConsole (from laptop):
ssh -L 9001:127.0.0.1:9001 root@103.69.127.45 # then browse http://localhost:9001Backup (from source box):
mc alias set src http://127.0.0.1:9000 KEY SECRET
mc alias set dst https://files.abhishekg.com.np KEY SECRET
mc mirror --dry-run src/uploads dst/uploads
mc mirror --overwrite src/uploads dst/uploads
mc alias remove dst && mc alias remove src # cleanup (plaintext creds!)DNS repair (systemd-resolved):
resolvectl dns eth0 8.8.8.8 1.1.1.1
resolvectl status
# permanent: set DNS= and Domains=~. in /etc/systemd/resolved.conf, then restartTagged
Written by
Abhishek Ghimire
Writes here about engineering, technology, and the things worth building.