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
Great Question (YC W21) Seeks Applied AI Interns: A Deep Dive
As fellow developers, we’re constantly scanning the landscape for companies pushing the boundaries, especially in the rapidly evolving AI space. Great Question, a Y Combinator W21 alumnus, has caught our eye with an
Navigating the Global AI Arena: Beyond Silicon Valley's Borders
The international AI landscape presents unique challenges and opportunities, requiring developers to think beyond traditional tech hubs. Key aspects include adapting AI models to local languages and cultures, navigating the complex global supply chain for critical hardware like semiconductors, and understanding how venture capital assesses these international ventures. Success hinges on deep local market understanding, robust technical solutions for localization, and resilience against logistical hurdles.
Engineering a Solution: Debugging Global Mosquito-Borne Diseases
As developers, we're constantly tasked with solving complex problems, whether it's optimizing a database query or architecting a distributed system. But what if the 'bug' we're trying to fix is biological, with global
Unleashing LLMs: A 10-Year-Old Xeon is All You Need
This article explores how a 10-year-old Intel Xeon E5-2620 v4 server with 128 GB DDR3 RAM and no GPU can run a modern LLM like Gemma 4 26B-A4B at reading speed. It highlights that LLM inference is often memory-bound and showcases deep optimization techniques using `ik_llama.cpp`, including speculative decoding, CPU-aware MoE routing, advanced memory management, and specialized attention kernels. The success demonstrates that granular software control can unlock significant performance on older, abundant-RAM hardware.
Start 5 Fun, Nerdy Hobbies for Cheap Right Now
Discover 5 fun, nerdy hobbies you can start today for cheap, including 3D printing, electronics, smart home automation, and self-hosting, with step-by-step guidance and budget-friendly tips.
Secluso: Building Private Home Security on Raspberry Pi with E2EE
Reclaiming Privacy in Home Security with Secluso For many developers, the allure of smart home technology, including security cameras, is strong. Yet, the widespread reliance on cloud-based services for video storage


