This article is a complete copy‑paste guide to running MinIO behind Traefik with HTTPS, custom domains, and pre-signed upload/download URLs — using only Docker Compose.
Your production will keep using a managed S3 / Cloudflare R2 / Hetzner Object Storage, while every staging upload, download, and pre-signed URL goes to your own server for free.
Table of Contents
1. Why Self‑Host Object Storage on Staging?
If your app handles documents — PDFs, profile pictures, application transcripts, recordings — every test upload your QA team makes costs real money on AWS S3, Cloudflare R2, or Hetzner Object Storage. The price isn't huge per file, but staging is where you:
run automated end‑to‑end tests that upload thousands of dummy files,
reset databases nightly (which leaves orphan objects behind),
let developers experiment with broken code that re‑uploads the same files,
and hold months of test data nobody ever deletes.
In production those costs are justified. Managed storage gives you replication, availability, and someone else's pager. In staging, those costs are pure waste.
MinIO is a free, open‑source, S3‑compatible object server. Same API, same SDKs, same presigned URLs, same mc/aws s3 CLIs — but running on your own VPS, billed at $0 per gigabyte. Point your staging app at MinIO, point your production app at S3/R2, and the only thing that changes is an environment variable.
The result: identical code paths in both environments, zero storage bill on staging, and a nice fallback if your cloud provider ever has an outage.
2. The Architecture: Production vs. Staging
In real-world applications, you usually don’t want your development or staging environment writing directly to production storage.
A common and cost-effective setup is:
Production: managed cloud object storage
Staging / Development: self-hosted S3-compatible storage
The good part is that your application code doesn't need to change.
As long as both services are S3-compatible, the same SDK and upload logic work everywhere. Only the environment variables differ.
High-Level Architecture
The above diagram illustrates how the same application can communicate with different storage providers depending on the deployment environment.
In the production environment, uploads are stored in a managed object storage service such as AWS S3, Cloudflare R2, or Hetzner Object Storage. These services handle durability, scalability, backups, and infrastructure management.
In the staging environment, uploads are directed to a self-hosted MinIO instance running inside Docker on a VPS. MinIO implements the S3 API, making it behave similarly to production storage while keeping costs low.
Because both storage systems are S3-compatible, the application uses the same upload logic in every environment. The only difference is the configuration provided through environment variables.
Why This Architecture Is Useful
This setup gives you:
A cheap staging environment
Production-like testing
Zero storage vendor lock-in
The ability to switch providers without rewriting application code
Because both environments speak the S3 protocol, your upload logic remains identical.
Example Environment Variables
Your application only reads environment variables like these:
S3_ENDPOINT=
S3_REGION=
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_BUCKET=
Switch the values, and the exact same application now uploads files to a different backend.
Production Storage Example
In production, you typically use managed object storage providers such as:
AWS S3
Cloudflare R2
Hetzner Object Storage
Example:
S3_ENDPOINT=https://<region>.r2.cloudflarestorage.com
The benefits are that it's highly scalable, globally available, durable, has managed backups, and doesn't have infrastructure maintenance.
Staging Environment Example
For staging, a lightweight self-hosted MinIO container is often enough.
Next.js App
↓
MinIO Container (inside Docker on VPS)
Example domains:
| Service | Domain | Internal Port |
|---|---|---|
| MinIO S3 API | minio-staging.domain.com |
9000 |
| MinIO Web Console | minio-console-staging.domain.com |
9001 |
This allows you to:
Test uploads safely
Avoid production storage costs
Reproduce production-like behavior locally
3. Prerequisites
You'll need:
A Linux VPS (Hetzner, DigitalOcean, Contabo, OVH — anything with a public IP).
Two A records pointing at that IP (we'll register them next).
Docker + Docker Compose v2.
Traefik v2 in front, with Let's Encrypt configured (any reverse proxy works – the labels below are Traefik's flavor).
Open ports
80and443on the firewall for Let's Encrypt + HTTPS.~10 GB free disk for the MinIO data volume to start.
If Docker isn't installed:
curl -fsSL https://get.docker.com | sh
sudo apt-get install -y docker-compose-plugin
docker --version && docker compose version
4. Step 1 — DNS: Point Your Domains to the Staging Server
In your DNS provider (Cloudflare, Route 53, Namecheap, and so on), create two A records pointing at your staging server's public IP:
minio-staging.domain.com A 203.0.113.45
minio-console-staging.domain.com A 203.0.113.45
If you use Cloudflare, set the proxy status to DNS only (gray cloud) for minio-staging.*. Cloudflare's free plan caps uploads at 100 MB, and you don't want it stripping S3 signing headers. The console subdomain can stay proxied if you want a WAF in front of it.
Wait a minute and verify:
dig +short minio-staging.domain.com
# 203.0.113.45
5. Step 2 — Run MinIO with Docker Compose
Add this service to your staging compose file (docker-compose.staging.yml). MinIO is just one container — the disk is mounted as a Docker volume so data survives upgrades.
# docker-compose.staging.yml
networks:
proxy:
external: true
name: proxy
internal:
name: internal
volumes:
minio-data:
services:
minio:
image: minio/minio:latest
container_name: minio-staging
restart: unless-stopped
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-admin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-change-me-please}
# Tell MinIO which public domain to sign URLs with
- MINIO_SERVER_URL=https://minio-staging.domain.com
- MINIO_BROWSER_REDIRECT_URL=https://minio-console-staging.domain.com
command: server /data --console-address ":9001"
volumes:
- minio-data:/data
networks:
- proxy
- internal
ports:
- "9000:9000" # S3 API
- "9001:9001" # Web console
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
Two things deserve attention:
MINIO_SERVER_URLis the secret sauce. Without it, MinIO signs presigned URLs using its internal hostname (http://minio:9000), which then fails verification when the browser hits the public domain. Set it to the exact HTTPS URL clients will use.MINIO_BROWSER_REDIRECT_URLdoes the same for the web console (login redirects, OIDC callbacks, and so on).
Bring it up:
docker compose -f docker-compose.staging.yml up -d minio
docker compose -f docker-compose.staging.yml logs -f minio
You should see API: http://... and Console: http://... lines.
6. Step 3 — Expose MinIO over HTTPS with Traefik
We don't expose ports 9000/9001 to the world directly — Traefik does that for us, terminating TLS with a free Let's Encrypt certificate.
Add these labels to the minio service:
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
# ---- S3 API (port 9000) ----
- "traefik.http.routers.minio-staging.rule=Host(`minio-staging.domain.com`)"
- "traefik.http.routers.minio-staging.entrypoints=websecure"
- "traefik.http.routers.minio-staging.tls.certresolver=letsencrypt"
- "traefik.http.routers.minio-staging.service=minio-staging"
- "traefik.http.services.minio-staging.loadbalancer.server.port=9000"
# ---- Web Console (port 9001) ----
- "traefik.http.routers.minio-console-staging.rule=Host(`minio-console-staging.domain.com`)"
- "traefik.http.routers.minio-console-staging.entrypoints=websecure"
- "traefik.http.routers.minio-console-staging.tls.certresolver=letsencrypt"
- "traefik.http.routers.minio-console-staging.service=minio-console-staging"
- "traefik.http.services.minio-console-staging.loadbalancer.server.port=9001"
You also need an entrypoint for :443 and a certificatesresolver named letsencrypt. Here's the minimum Traefik config (traefik.staging.yml):
api:
dashboard: true
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
certificatesResolvers:
letsencrypt:
acme:
httpChallenge:
entryPoint: web
email: admin@domain.com
storage: /etc/traefik/acme.json
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
network: proxy
Restart and watch the cert get issued:
docker compose -f docker-compose.staging.yml up -d
docker compose -f docker-compose.staging.yml logs -f traefik | grep -i acme
Sanity check from your laptop:
curl -I https://minio-staging.domain.com/minio/health/live
# HTTP/2 200
You can now log in to the web console at https://minio-console-staging.domain.com with admin / change-me-please.
Important upload size tweak: if you're behind Cloudflare or NGINX in front of Traefik, raise the request body limit. Traefik itself has no default limit, but Cloudflare's free plan refuses anything over 100 MB. For self‑hosted edge proxies, set client_max_body_size 0; (NGINX) or the equivalent.
7. Step 4 — Create the Bucket and Access Keys
Anything that speaks S3 can talk to MinIO. The easiest tool is mc (the official MinIO client), shipped inside the same image.
7.1 Connect mc to your server
docker exec -it minio-staging \
mc alias set local http://localhost:9000 admin change-me-please
7.2 Create a bucket
docker exec -it minio-staging mc mb local/domain-files-staging
7.3 Choose a bucket policy
You have three choices, so just pick based on what you store:
| Policy | When to use |
|---|---|
private (default) |
Anything sensitive — student transcripts, contracts, internal docs. Reads only via presigned URL. |
download |
Public read, no listing. Good for CDN‑style assets like avatars. |
public |
Anyone can read AND list. Use only for truly public content. |
Set one:
# Private (recommended for documents)
docker exec -it minio-staging \
mc anonymous set none local/domain-files-staging
# OR public read for static assets only:
docker exec -it minio-staging \
mc anonymous set download local/domain-files-staging
7.4 Create a dedicated app user (don't use root keys!)
The admin account can wipe everything. Make a least‑privilege user for your app:
docker exec -it minio-staging mc admin user add local \
domain-app a-long-random-secret-key
# Attach the built-in read/write policy, scoped to one bucket via JSON:
cat > /tmp/policy.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:*"],
"Resource": [
"arn:aws:s3:::domain-files-staging",
"arn:aws:s3:::domain-files-staging/*"
]
}
]
}
EOF
docker cp /tmp/policy.json minio-staging:/tmp/policy.json
docker exec -it minio-staging \
mc admin policy create local domain-rw /tmp/policy.json
docker exec -it minio-staging \
mc admin policy attach local domain-rw --user domain-app
Save those two values — they are your S3_ACCESS_KEY and S3_SECRET_KEY.
8. Step 5 — Configure Your App to Use MinIO on Staging Only
The trick to "MinIO in staging, real S3 in prod" is to use the same S3 client in your code and only swap the env vars.
Your staging.env (loaded by your staging compose stack):
# ---- Staging: self-hosted MinIO ----
STORAGE_ENABLED=true
S3_ENDPOINT=https://minio-staging.domain.com
S3_PUBLIC_ENDPOINT=https://minio-staging.domain.com
S3_BUCKET=domain-files-staging
S3_ACCESS_KEY=domain-app
S3_SECRET_KEY=a-long-random-secret-key
S3_REGION=us-east-1
S3_FORCE_PATH_STYLE=true
Your production.env:
# ---- Production: Cloudflare R2 ----
STORAGE_ENABLED=true
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
S3_PUBLIC_ENDPOINT=https://files.domain.com
S3_BUCKET=domain-files
S3_ACCESS_KEY=<r2-access-key>
S3_SECRET_KEY=<r2-secret-key>
S3_REGION=auto
S3_FORCE_PATH_STYLE=true
S3_FORCE_PATH_STYLE=true is critical for both MinIO and R2/Hetzner. Without it, the SDK tries https://bucket.minio-staging.domain.com (virtual‑host style), which won't resolve.
Now in your application code (Node.js example using AWS SDK v3):
// src/lib/s3.js
import { S3Client } from "@aws-sdk/client-s3";
export const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY,
secretAccessKey: process.env.S3_SECRET_KEY,
},
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
});
export const BUCKET = process.env.S3_BUCKET;
export const PUBLIC_ENDPOINT = process.env.S3_PUBLIC_ENDPOINT;
The same s3 instance now talks to MinIO on staging and to R2 in production with no code change.
9. Step 6 — Upload Files (3 Ways)
9.1 From a server (best for trusted backends)
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { s3, BUCKET } from "./lib/s3.js";
import { readFile } from "node:fs/promises";
export async function uploadDocument(localPath, key, contentType) {
const Body = await readFile(localPath);
await s3.send(new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body,
ContentType: contentType,
// Optional: per-object metadata, useful for audits
Metadata: { uploadedBy: "system", env: process.env.NODE_ENV },
}));
return key;
}
9.2 With the mc CLI (good for one‑off uploads / migrations)
mc alias set staging https://minio-staging.domain.com domain-app a-long-random-secret-key
mc cp ./report.pdf staging/domain-files-staging/reports/2026/report.pdf
mc ls staging/domain-files-staging --recursive
9.3 Directly from the browser via a presigned PUT URL
The recommended pattern for user uploads is: the file goes from the browser to MinIO with zero bytes touching your API server.
We'll cover this in detail next.
10. Step 7 — Generate Presigned URLs (PUT and GET)
A presigned URL is a regular HTTPS URL with a time‑limited signature in the query string. Anyone with the URL can do exactly the action it was signed for (PUT this object, or GET that object) for the next N minutes — and nothing else.
This is what makes "users upload directly to storage" safe.
10.1 Presigned PUT (for uploads)
// src/lib/presign.js
import { PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { s3, BUCKET } from "./s3.js";
import { randomUUID } from "node:crypto";
export async function presignUpload({ filename, contentType, userId }) {
const key = `users/\({userId}/\){randomUUID()}-${filename}`;
const cmd = new PutObjectCommand({
Bucket: BUCKET,
Key: key,
ContentType: contentType,
});
const uploadUrl = await getSignedUrl(s3, cmd, { expiresIn: 60 * 5 }); // 5 min
return { uploadUrl, key };
}
Wire it to your API:
// POST /api/uploads/presign
app.post("/api/uploads/presign", requireAuth, async (req, res) => {
const { filename, contentType } = req.body;
const result = await presignUpload({
filename,
contentType,
userId: req.user.id,
});
res.json(result); // { uploadUrl, key }
});
The browser uploads straight to MinIO:
// In your frontend
async function uploadFile(file) {
const { uploadUrl, key } = await fetch("/api/uploads/presign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, contentType: file.type }),
}).then(r => r.json());
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
// Persist `key` in your DB so you can retrieve it later
await fetch("/api/documents", {
method: "POST",
body: JSON.stringify({ key, originalName: file.name }),
});
}
The Content-Type you send during PUT must match the one you signed with, or MinIO will reject the request with SignatureDoesNotMatch. This catches everyone the first time.
10.2 Presigned GET (for downloads)
Same idea, but with GetObjectCommand:
export async function presignDownload(key, expiresIn = 60 * 10) {
const cmd = new GetObjectCommand({ Bucket: BUCKET, Key: key });
return getSignedUrl(s3, cmd, { expiresIn });
}
A typical "view document" endpoint:
app.get("/api/documents/:id/url", requireAuth, async (req, res) => {
const doc = await db.documents.findById(req.params.id);
if (!doc || !canUserSee(req.user, doc)) return res.sendStatus(403);
const url = await presignDownload(doc.key, 600);
res.json({ url });
});
The frontend just opens that URL — the file streams from MinIO directly to the user.
10.3 Why presigned URLs beat "proxy through the API"
| Proxy through API | Presigned URL | |
|---|---|---|
| Bytes through your app | All of them | Zero |
| API CPU/RAM cost | High | None |
| Throughput limit | Your API | MinIO's NIC |
| Auth check | Your code | Your code (still — check before signing) |
11. Step 8 — Get Public URLs for Documents
Sometimes you want a permanent, unauthenticated URL — for example public profile pictures.
If the bucket policy allows anonymous reads (mc anonymous set download …), the public URL pattern is:
https://minio-staging.domain.com/<bucket>/<key>
So users/42/avatar.png becomes:
https://minio-staging.domain.com/domain-files-staging/users/42/avatar.png
In code:
export function publicUrl(key) {
return `\({process.env.S3_PUBLIC_ENDPOINT}/\){BUCKET}/${key}`;
}
For private buckets (most documents), don't use public URLs at all — always go through presignDownload(key) so you can re‑check authorization on every request and expire links.
12. Step 9 — Lock Down CORS, Lifecycle, and Security
12.1 Allow your frontend origins (CORS)
Browser uploads need CORS rules on the bucket. Drop this JSON via mc:
cat > /tmp/cors.json <<'EOF'
{
"CORSRules": [
{
"AllowedOrigins": [
"https://crm-staging.domain.com",
"http://localhost:3000"
],
"AllowedMethods": ["GET", "PUT", "POST", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]
}
EOF
docker cp /tmp/cors.json minio-staging:/tmp/cors.json
docker exec -it minio-staging \
mc cors set local/domain-files-staging /tmp/cors.json
12.2 Auto‑delete old test files (lifecycle)
Staging accumulates junk. Tell MinIO to expire anything older than 30 days:
docker exec -it minio-staging \
mc ilm rule add --expire-days 30 local/domain-files-staging
12.3 Encrypt at rest
docker exec -it minio-staging \
mc encrypt set sse-s3 local/domain-files-staging
12.4 Hard rules
Never ship
MINIO_ROOT_USER=admin/MINIO_ROOT_PASSWORD=admin123to a server reachable from the internet. Generate strong values and store them in your secret manager.The root account should be used only by
mc admin, never by your app. The app uses a scoped IAM user (Step 7.4).Keep the console subdomain behind an IP allow‑list or basic auth via Traefik middleware if it's truly public.
Rotate the app access keys at least every 90 days.
13. Step 10 — Backups and Monitoring
13.1 Backups: mirror to a cheap cold bucket weekly
Set up a tiny cron job that uses mc mirror to push to Backblaze B2, R2, or another cheap S3 endpoint:
mc alias set b2 https://s3.us-east-005.backblazeb2.com \(B2_KEY \)B2_SECRET
mc mirror --overwrite --remove \
staging/domain-files-staging \
b2/domain-staging-backup
Even at $6/TB/month this is essentially free for staging volumes.
13.2 Monitoring with Prometheus
MinIO exposes Prometheus metrics out of the box at /minio/v2/metrics/cluster. Scrape with:
scrape_configs:
- job_name: minio
metrics_path: /minio/v2/metrics/cluster
scheme: https
static_configs:
- targets: ["minio-staging.domain.com"]
If you have Grafana, import dashboard ID 13502 for an instant overview (capacity, request rates, latency, error counts).
14. Troubleshooting Cheat Sheet
| Symptom | Likely cause | Fix |
|---|---|---|
SignatureDoesNotMatch on presigned PUT |
Browser sent a different Content-Type than what was signed |
Send the exact same Content-Type header during PUT |
| Presigned URL works locally but not in browser | MINIO_SERVER_URL not set, so URLs are signed for minio:9000 |
Set MINIO_SERVER_URL=https://minio-staging.domain.com and restart |
403 SignatureDoesNotMatch after going through Cloudflare |
Cloudflare strips/modifies headers | Set the DNS record to DNS‑only (gray cloud) |
NoSuchBucket |
App pointing at the wrong endpoint or bucket | Re‑check S3_ENDPOINT and S3_BUCKET in env |
| Browser CORS preflight fails | No CORS rule on the bucket | Apply the CORS JSON from §12.1 |
| Upload works for small files, fails at 100 MB | Cloudflare free plan body limit | Use Cloudflare paid plan, or skip CF proxy |
x509: certificate signed by unknown authority from your app |
App container doesn't trust Let's Encrypt | Update CA bundle (apt install ca-certificates) or use HTTP inside the Docker network |
Web console redirects to http://minio:9001/login |
MINIO_BROWSER_REDIRECT_URL missing |
Set it to https://minio-console-staging.domain.com |
Useful diagnostics:
# Check MinIO health
curl -I https://minio-staging.domain.com/minio/health/live
# List all objects in a bucket
docker exec -it minio-staging mc ls --recursive local/domain-files-staging
# Tail MinIO logs
docker compose -f docker-compose.staging.yml logs -f minio
# Decode a presigned URL to see what it was signed for
echo "<paste url>" | tr '&' '\n'
15. Wrapping Up
Here's what you have now:
A free, S3‑compatible object store running on your own staging server.
Real HTTPS on a real domain (
https://minio-staging.domain.com), thanks to Traefik + Let's Encrypt.A scoped, least‑privilege application user — root keys stay locked away.
The same exact code paths in staging and production. Switching between MinIO / R2 / Hetzner / AWS S3 is a four‑variable change in the env file.
Presigned PUT URLs so users upload straight to storage, bypassing your API.
Presigned GET URLs so private documents are short‑lived and authorization‑gated.
Lifecycle rules that nuke old test files automatically.
Optional weekly mirror to a cold backup bucket.
Production keeps running on managed storage where the SLA matters. Staging now costs you exactly $0 per month per gigabyte uploaded — and you can finally stop telling QA to "delete the test files when you're done."
Further Reading
If this guide saved your team a few dollars, share it with another team that's still uploading test PDFs to a $90/month S3 bucket. Happy shipping.