Self-Host S3-Compatible Object Storage with MinIO on Staging
This guide demonstrates how to self-host an S3-compatible object store using MinIO on your staging server. By leveraging Docker Compose and Traefik for HTTPS, you can significantly reduce cloud storage costs while maintaining a production-like environment for development and testing. It covers setup, application configuration, and secure file interactions.

Slash Staging Costs: Self-Hosting S3 with MinIO and Docker Compose
As developers, we know the staging environment is a crucible for new features, automated tests, and experimental code. If your application handles file uploads—think user avatars, document PDFs, or media—each interaction in staging likely incurs a real cost on managed object storage services like AWS S3, Cloudflare R2, or Hetzner Object Storage. While these costs are justified in production for their robust features like replication and high availability, they quickly accumulate in staging environments due to frequent database resets, thousands of test uploads, and developer experimentation. This article will show you how to self-host an S3-compatible object store using MinIO on your staging server, leveraging Docker Compose and Traefik to save hundreds of dollars monthly, all while maintaining a production-like testing environment.
The Problem: Unnecessary Staging Storage Costs
Consider the typical staging workflow:
- Automated end-to-end tests generating numerous dummy files.
- Nightly database resets leaving orphaned objects.
- Developers iterating on features, often re-uploading the same files.
- Accumulation of test data that's rarely cleaned up.
These activities, while essential for development quality, translate directly into storage, request, and egress charges on cloud platforms. The goal is to eliminate this waste without compromising the fidelity of your staging environment.
The Solution: MinIO – An S3-Compatible Alternative
MinIO is a free, open-source object storage server that implements the Amazon S3 API. This S3 compatibility is key: your application code, using the standard AWS SDK, can interact with MinIO precisely as it would with AWS S3, Cloudflare R2, or other S3-compatible services. The only difference becomes an environment variable pointing to your self-hosted MinIO instance instead of a managed cloud endpoint. This setup provides identical code paths across environments, zero storage bills on staging, and a valuable fallback option.
Architecture: Production vs. Staging
A common and cost-effective approach is to segregate storage:
- Production: Utilizes managed cloud object storage (AWS S3, Cloudflare R2, Hetzner Object Storage) for scalability, durability, and managed backups.
- Staging / Development: Leverages a self-hosted MinIO instance, typically running in Docker on a VPS.
This architecture ensures your application code remains consistent, only varying the S3_ENDPOINT and credential environment variables.

This setup offers a cheap staging environment, production-like testing, and reduces vendor lock-in by using the S3 protocol as a universal interface.
Example Environment Variables:
xml S3_ENDPOINT= S3_REGION= S3_ACCESS_KEY= S3_SECRET_KEY= S3_BUCKET=
By simply switching these values, your application seamlessly targets a different storage backend.
Prerequisites
Before you begin, ensure you have:
- A Linux VPS (e.g., Hetzner, DigitalOcean) with a public IP.
- Two A records pointing to your staging server's IP (e.g.,
minio-staging.domain.com,minio-console-staging.domain.com). - Docker and Docker Compose v2 installed.
- Traefik v2 configured as a reverse proxy with Let's Encrypt.
- Ports
80and443open on your firewall. - Approximately 10 GB of free disk space for MinIO data.
If Docker isn't installed, you can use:
bash curl -fsSL https://get.docker.com | sh sudo apt-get install -y docker-compose-plugin docker --version && docker compose version
Step-by-Step Implementation
1. DNS Configuration
In your DNS provider, create two A records pointing to your staging server's public IP. For Cloudflare, ensure minio-staging.domain.com is set to DNS only (gray cloud) to avoid upload size limits and S3 header stripping. The console subdomain can remain proxied.
plaintext minio-staging.domain.com A 203.0.113.45 minio-console-staging.domain.com A 203.0.113.45
2. Running MinIO with Docker Compose
Add the following service to your docker-compose.staging.yml. Crucially, MINIO_SERVER_URL and MINIO_BROWSER_REDIRECT_URL must be set to the public HTTPS domains clients will use to ensure correctly signed URLs.
yaml
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} - 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
Bring up the MinIO service:
bash docker compose -f docker-compose.staging.yml up -d minio docker compose -f docker-compose.staging.yml logs -f minio
3. Exposing MinIO with HTTPS via Traefik
Traefik will handle TLS termination and expose MinIO over HTTPS using Let's Encrypt. Add these labels to your minio service within the docker-compose.staging.yml:
yaml 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"
Ensure your Traefik configuration (traefik.staging.yml) includes web and websecure entry points and a letsencrypt certificate resolver:
yaml 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
After restarting Traefik, verify HTTPS access to https://minio-staging.domain.com.
4. Bucket and Access Key Management
Use the mc (MinIO client) CLI, available inside the MinIO container, to manage buckets and users. First, connect mc:
bash
docker exec -it minio-staging
mc alias set local http://localhost:9000 admin change-me-please
Create a bucket for your application:
bash docker exec -it minio-staging mc mb local/domain-files-staging
Set a bucket policy (private, download, or public). private is recommended for sensitive documents, requiring presigned URLs for access. download allows public reads but no listing.
bash
Example: Private policy (recommended)
docker exec -it minio-staging
mc anonymous set none local/domain-files-staging
Crucially, create a dedicated, least-privilege user for your application instead of using the root credentials. Attach a policy allowing s3:* actions only on your specific bucket.
bash
docker exec -it minio-staging mc admin user add local
domain-app a-long-random-secret-key
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 domain-app and a-long-random-secret-key as your S3_ACCESS_KEY and S3_SECRET_KEY.
5. Application Configuration
Configure your application to use MinIO's endpoint and credentials in staging, and your production service's in production. The S3_FORCE_PATH_STYLE=true setting is vital for both MinIO and other S3-compatible providers like R2/Hetzner, preventing virtual-host style requests that might not resolve correctly.
staging.env:
env
---- 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
production.env (example for Cloudflare R2):
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
Your S3 client in code remains identical:
javascript // 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;
6. Uploading Files & Presigned URLs
MinIO supports standard S3 upload methods. For user-initiated uploads, presigned URLs are the recommended secure pattern. A presigned URL allows a client (e.g., a browser) to directly PUT or GET an object from MinIO for a limited time without exposing your S3_SECRET_KEY.
Presigned PUT (for uploads from browser): Your backend signs a URL, the browser then uploads the file directly to MinIO. This keeps file data off your API server.
javascript // 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 };
}
Presigned GET (for downloads from browser): Similar to PUT, your backend signs a URL for a secure, temporary download link.
javascript export async function presignDownload(key, expiresIn = 60 * 10) { const cmd = new GetObjectCommand({ Bucket: BUCKET, Key: key }); return getSignedUrl(s3, cmd, { expiresIn }); }
Why Presigned URLs? They eliminate the need to proxy large files through your application server, reducing CPU/RAM load and increasing throughput. Your application still performs authorization checks before signing the URL.
7. Public URLs (for specific use cases)
For truly public assets, where the bucket policy allows anonymous reads, the URL pattern is https://minio-staging.domain.com/<bucket>/<key>. For instance: https://minio-staging.domain.com/domain-files-staging/users/42/avatar.png.
javascript
export function publicUrl(key) {
return ${process.env.S3_PUBLIC_ENDPOINT}/${BUCKET}/${key};
}
Remember, for private documents, always use presignDownload(key) to enforce authorization and link expiry.
Security and Maintenance
MinIO offers robust features for security and lifecycle management:
-
CORS: Configure CORS rules on your bucket to allow uploads from your frontend origins. bash 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 -
Lifecycle Management: Automatically expire old test files (e.g., after 30 days) to prevent staging storage bloat. bash docker exec -it minio-staging
mc ilm rule add --expire-days 30 local/domain-files-staging -
Encryption at Rest: Enable server-side encryption for data at rest. bash docker exec -it minio-staging
mc encrypt set sse-s3 local/domain-files-staging -
Hard Rules: Never use default root credentials in production. Restrict console access. Rotate application access keys regularly.
Backups and Monitoring
While self-hosting reduces costs, it shifts responsibility for durability and backups. For staging, a simple mc mirror cron job can periodically sync your MinIO data to a cheap cold storage provider like Backblaze B2 or another S3 endpoint.
bash
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
MinIO also exposes Prometheus metrics at /minio/v2/metrics/cluster for integration with your monitoring stack.
FAQ
Q: Why is MINIO_SERVER_URL crucial for presigned URLs?
A: Without MINIO_SERVER_URL set to the public HTTPS domain, MinIO will sign presigned URLs using its internal Docker hostname (e.g., http://minio:9000), causing verification failures when clients attempt to access the public domain.
Q: What is S3_FORCE_PATH_STYLE=true and why is it important for MinIO and services like Cloudflare R2?
A: S3_FORCE_PATH_STYLE=true tells the S3 SDK to use path-style URLs (e.g., https://minio-staging.domain.com/bucket/key) instead of virtual-host style URLs (e.g., https://bucket.minio-staging.domain.com). MinIO and some S3-compatible services don't support virtual-host style access, so enabling path style ensures correct resolution.
Q: Why should I use presigned URLs for browser uploads instead of proxying through my API server?
A: Presigned URLs allow direct uploads from the browser to MinIO, bypassing your API server entirely. This significantly reduces your API's CPU and RAM load, improves throughput by leveraging MinIO's direct network interface, and avoids consuming your application's bandwidth, leading to a more scalable and cost-efficient architecture for file handling.
Related articles
Build Your Own Local NMT App with React Native and QVAC
This article explores how Neural Machine Translation (NMT), powered by the Transformer architecture, revolutionized translation by understanding context. We then delve into QVAC, a local-first AI development platform, and its Bergamot engine, enabling private, on-device translation. Learn to set up a React Native app with QVAC and manage model lifecycles for efficient local translation.
Unpacking Roman Concrete's Durability: Carbonation and Self-Healing
The Enduring Legacy: Roman Concrete's Millennia-Long Stand As software developers, we're familiar with the ephemeral nature of technology; systems evolve, frameworks deprecate, and codebases undergo constant
PayPal in Microservices: NestJS, gRPC, and Docker Blueprint
Integrating payment logic directly into every microservice within a distributed system often leads to significant challenges. Scattering PayPal API calls across services like user-service, order-service, or
How to Reclaim 22GB on Your Samsung Phone Without Deleting Important
Learn to effectively free up significant storage space on your Samsung phone by emptying trash, removing duplicates, archiving apps, clearing caches, and managing offline files in just a few steps, without sacrificing your essential data.
Demystifying Dijkstra's Algorithm: The Shortest Path Pioneer
Explore Dijkstra's Algorithm, the foundational pathfinding technique conceived by Edsger W. Dijkstra. This guide explains how it solves shortest path problems using graphs, nodes, edges, and weights. Learn its greedy approach and the critical role of data structures like adjacency lists and priority queues in its efficient Python implementation.
AWS Leadership Shift: What It Means for Compute and AI/ML
Dave Brown, a key figure in AWS's EC2 and AI/ML growth, is departing. His successor, Dave Treadwell, brings extensive experience from Microsoft and Amazon's eCommerce Foundation, potentially signaling new directions for core cloud services and AI innovation.





