I needed S3 without AWS — MinIO's console redirected to the wrong URL

I needed S3 without AWS — MinIO's console redirected to the wrong URL

I wanted an S3 endpoint on my VPS for app uploads and backups. MinIO on Ubuntu 24.04 came up, then the console bounced to the wrong host because MINIO_BROWSER_REDIRECT_URL and X-Forwarded-Proto did not match. Here's that wall and the Nginx split for API vs console.

· Updated · 3 min read #self-hosted #open-source #minio #storage #s3 #sysadmin #server-management #deployment #docker #vps

 MinIO self-hosted object storage

Caption: MinIO on my VPS — S3-compatible buckets without sending every backup to a public cloud.

Introduction

I wanted object storage my Laravel apps and backup jobs could speak S3 to — without another AWS bill for files that never need to leave my network. MinIO is that server: change endpoint, keys, and bucket name, keep the SDK.

This is not a blog install. Other apps will assume the bucket is still there after a disk scare. I do not use minioadmin/minioadmin on anything that has a public IP.

On Ubuntu 24.04 the stack is famous for a console that redirects to the wrong URL. Official-style fix: MINIO_BROWSER_REDIRECT_URL=https://console.s3.example.com, restart, and Nginx must forward X-Forwarded-Proto. The other wall is S3 signature errors when the client is not on path-style access or the clock is wrong. I bound 9000/9001 to localhost, split two Nginx vhosts, and created a scoped user so apps never see the root keys.

Why I picked MinIO

  • Anything that talks S3 can usually talk MinIO with a different endpoint.
  • One container is enough for a personal or small-team box if disks and backups are real.
  • Keeping objects next to the apps cuts the round trip I was paying a public cloud for.
  • mc creates buckets, mirrors data, and checks the server from a terminal.
  • I pick the region, firewall, and retention.

It is not a disaster-recovery plan by itself. If the data matters, a second copy lives off this host.

Prerequisites

Hardware:

  • 2 CPU / 2 GB RAM for a small deploy
  • SSD or NVMe
  • Disk for objects, versions, failed multiparts, logs, local archives
  • A second place for backups (another VPS, NAS, or another S3)

Software and accounts:

  • Ubuntu 24.04 LTS with sudo
  • s3.example.com for the API
  • console.s3.example.com for the browser console
  • DNS for both
  • Docker + Compose
  • Nginx + Certbot
  • Password manager for root and app keys

Security notes:

  • No default minioadmin on an internet host.
  • Container ports on 127.0.0.1 only; HTTPS via Nginx.
  • Root account for admin, not for apps.
  • One scoped user/policy per app or backup job.
  • Backup objects + config; restore once before important files.
sudo apt update
sudo apt upgrade -y
sudo apt install -y ca-certificates curl gnupg git ufw nginx certbot python3-certbot-nginx

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status

Installation Guide

Compose, bind-mounted data, two Nginx hosts. API on s3.example.com, console on console.s3.example.com.

1. Install Docker Engine

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
newgrp docker

docker --version
docker compose version

Log out/in if the docker group is not active yet.

2. Create the MinIO Project Directory

sudo mkdir -p /opt/minio/data /opt/minio/backups
sudo chown -R "$USER":"$USER" /opt/minio
chmod 700 /opt/minio/data /opt/minio/backups
cd /opt/minio

data is objects and metadata. backups is local staging — real copies leave the machine.

3. Generate Root Credentials and Environment Settings

cd /opt/minio
MINIO_ROOT_USER_VALUE="minio-root-$(openssl rand -hex 6)"
MINIO_ROOT_PASSWORD_VALUE="$(openssl rand -base64 48 | tr -d '\n')"

cat > .env <<EOF
MINIO_ROOT_USER=${MINIO_ROOT_USER_VALUE}
MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD_VALUE}
MINIO_SERVER_URL=https://s3.example.com
MINIO_BROWSER_REDIRECT_URL=https://console.s3.example.com
EOF

chmod 600 .env
printf 'Stored MinIO root credentials in /opt/minio/.env\n'

Both values in the password manager. Root does not go into application .env files.

4. Create the Docker Compose File

services:
  minio:
    image: quay.io/minio/minio:latest
    container_name: minio
    restart: unless-stopped
    command: server /data --console-address ":9001"
    env_file:
      - .env
    volumes:
      - ./data:/data
    ports:
      - "127.0.0.1:9000:9000"
      - "127.0.0.1:9001:9001"
cd /opt/minio
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --since=2m minio

9000 = S3 API, 9001 = console, both localhost.

 MinIO Docker stack architecture

Caption: Nginx on 443 for API and console; MinIO stays on localhost with /opt/minio/data.

5. Configure Nginx for the S3 API

/etc/nginx/sites-available/s3.example.com:

server {
    listen 80;
    listen [::]:80;
    server_name s3.example.com;

    client_max_body_size 0;
    proxy_buffering off;

    location / {
        proxy_http_version 1.1;
        proxy_set_header Host $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_pass http://127.0.0.1:9000;
    }
}

6. Configure Nginx for the MinIO Console

/etc/nginx/sites-available/console.s3.example.com:

server {
    listen 80;
    listen [::]:80;
    server_name console.s3.example.com;

    location / {
        proxy_http_version 1.1;
        proxy_set_header Host $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_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_pass http://127.0.0.1:9001;
    }
}
sudo ln -s /etc/nginx/sites-available/s3.example.com /etc/nginx/sites-enabled/
sudo ln -s /etc/nginx/sites-available/console.s3.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d s3.example.com -d console.s3.example.com

Then https://console.s3.example.com with root credentials from .env.

Configuration

Install and Configure the MinIO Client

curl --progress-bar -L https://dl.min.io/client/mc/release/linux-amd64/mc \
  --create-dirs \
  -o "$HOME/minio-binaries/mc"
chmod +x "$HOME/minio-binaries/mc"
sudo install -m 0755 "$HOME/minio-binaries/mc" /usr/local/bin/mc
mc --version
cd /opt/minio
set -a
. ./.env
set +a

mc alias set local https://s3.example.com "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"
mc admin info local

If mc admin info local fails: DNS, HTTPS, Nginx logs, root keys, container still up.

Create a Bucket and Enable Versioning

mc mb local/app-backups
mc version enable local/app-backups
mc ls local

Versioning saves me from a bad overwrite. It also fills the disk if I never look at mc du.

Create a Least-Privilege Application User

cd /opt/minio
cat > app-backups-rw.json <<'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetBucketLocation",
        "s3:ListBucket",
        "s3:ListBucketMultipartUploads"
      ],
      "Resource": ["arn:aws:s3:::app-backups"]
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:AbortMultipartUpload",
        "s3:DeleteObject",
        "s3:GetObject",
        "s3:ListMultipartUploadParts",
        "s3:PutObject"
      ],
      "Resource": ["arn:aws:s3:::app-backups/*"]
    }
  ]
}
EOF

APP_SECRET="$(openssl rand -base64 32 | tr -d '\n')"
printf 'Application access key: app-backups\n'
printf 'Application secret key: %s\n' "$APP_SECRET"

mc admin user add local app-backups "$APP_SECRET"
mc admin policy create local app-backups-rw app-backups-rw.json
mc admin policy attach local app-backups-rw --user app-backups

Store the secret immediately. Apps typically need:

S3_ENDPOINT=https://s3.example.com
S3_REGION=us-east-1
S3_BUCKET=app-backups
S3_ACCESS_KEY=app-backups
S3_SECRET_KEY=paste-the-generated-secret
S3_PATH_STYLE_ENDPOINT=true

Path-style matters on self-hosted MinIO when I do not have wildcard TLS and bucket subdomains.

 MinIO access control flow

Caption: Apps get a scoped user. Root stays in the password manager.

Usage

printf "hello minio\n" > /tmp/minio-test.txt
mc cp /tmp/minio-test.txt local/app-backups/healthchecks/minio-test.txt
mc stat local/app-backups/healthchecks/minio-test.txt
mc cp local/app-backups/healthchecks/minio-test.txt /tmp/minio-test-download.txt
diff /tmp/minio-test.txt /tmp/minio-test-download.txt
mc rm local/app-backups/healthchecks/minio-test.txt

Then I upload a small file through the real app and confirm it in the console and mc ls.

Checklist I actually run:

  1. docker compose ps is up.
  2. mc admin info local returns info.
  3. mc ls local/app-backups lists the bucket.
  4. Upload/download round trip works.
  5. Nginx logs show HTTPS, not public 9000/9001.
  6. Disk has room for versions and backups.

Screenshots and Visuals

Original diagrams: hero, Docker/Nginx path, root vs app keys, local archive vs off-server copy.

 MinIO backup and lifecycle plan

Caption: Local tar is for a bad Tuesday. Off-server copy is for a dead VPS.

Where it broke

On a lab Ubuntu 24.04 box this is the failure MinIO-behind-Nginx is famous for.

1. Console redirects to the wrong URL

I opened https://console.s3.example.com and landed on localhost, HTTP, or the API host. Docs-shaped fix:

MINIO_BROWSER_REDIRECT_URL=https://console.s3.example.com

Restart:

cd /opt/minio
docker compose up -d

Nginx on the console vhost must send X-Forwarded-Proto. Without it, MinIO thinks the scheme is http and the redirect loop starts.

2. S3 clients fail with signature errors

mc worked; the Laravel (or other) client did not. Endpoint must be exactly https://s3.example.com, path-style on, clocks in sync, region what the app expects (us-east-1 is what I put in the example env). Using root keys in the app when the policy is on app-backups is a different failure — I test with the same access key the app has.

Large uploads dying: client_max_body_size 0 on the API vhost, then Nginx error log for timeouts. Disk growth: mc du local/app-backups and versioning. Empty data after a restart: Compose not run from /opt/minio, so ./data:/data is the wrong directory.

Troubleshooting

  • Cert renewals break the vhosts: sudo nginx -t, reload, confirm both hostnames are still in the server blocks.
  • mc alias set works, apps fail: app keys + bucket policy + object path, not the root alias.

Scaling, Securing, and Next Steps

First limit is usually disk durability, not CPU. I watch disk health, free space, I/O wait, backup success. If MinIO becomes the dependency for everything, I want dedicated disks or a distributed setup — not a bigger random volume on the same VPS.

Mirror a bucket off-box:

mc alias set backup https://s3.backup-provider.example ACCESS_KEY SECRET_KEY
mc mirror --overwrite local/app-backups backup/app-backups

Local archive (brief stop):

cd /opt/minio
docker compose stop minio
tar -C /opt/minio/data -czf "/opt/minio/backups/minio-data-$(date +%F).tar.gz" .
docker compose up -d
rsync -avh /opt/minio/backups/ backup-user@backup.example.com:/srv/backups/minio/

After it works:

  • Console behind VPN or extra auth.
  • One bucket + one user per app.
  • Rotate keys when people or CI change.
  • Monitor disk, container, Nginx, certs.
  • Pin a MinIO image tag.
  • Restore into a second MinIO before I call backups "done."

Conclusion

MinIO is running on my Ubuntu 24.04 VPS: API at https://s3.example.com, console at https://console.s3.example.com, ports on localhost, versioned app-backups bucket, scoped user for apps. Root keys stay in .env and a password manager.

Next I would connect one non-critical app, restore a data tarball once, and document the exact mc alias + policy steps. The console redirect was MINIO_BROWSER_REDIRECT_URL and forwarded proto — not "S3 is hard."

Did you hit the same wall?

I got stuck on the MinIO console redirecting to the wrong URL until MINIO_BROWSER_REDIRECT_URL and X-Forwarded-Proto matched HTTPS. Did you hit the same thing, or a different one — signature errors, path-style, client_max_body_size? Tell me in the comments. I read them.

Need this done on your server?

I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan

References

Share:

Get new posts in your inbox

No spam. One short email per new article — practical PHP, Laravel, devops, and AI-assisted workflows.

Comments

Powered by GitHub Discussions via Giscus. A free GitHub account is required.