Running MinIO for Local Upload Development
Run MinIO in Docker Compose on port 9000 with a one-shot mc container that creates your buckets and sets their policies, point your S3 client at http://localhost:9000 with forcePathStyle: true and the MinIO root credentials, and make sure the host in presigned URLs is one the browser can reach — sign with a client configured for localhost:9000, not the Compose service name minio:9000. MinIO answers CORS preflights for any origin by default, so browser PUTs work immediately; the same Compose file then runs your integration tests in CI without cloud credentials.
Testing upload code against a real cloud bucket is slow, needs credentials on every laptop, costs money in CI and leaves test objects behind. MinIO implements the S3 API closely enough that presigned PUTs, multipart uploads, checksums and bucket notifications behave like S3 for development purposes, and it starts in a couple of seconds. This page belongs to direct-to-cloud upload patterns in backend validation and cloud storage architecture; the code it tests is usually from S3 presigned URL workflows or S3 multipart upload orchestration.
When to use this approach
- Your app uploads directly from the browser to S3 or an S3-compatible store (including R2).
- Developers need to run the full upload flow offline or without per-person cloud access.
- CI runs end-to-end tests that upload and read files.
Prerequisites
- Docker with Compose v2.
- An S3 client that accepts a custom endpoint —
@aws-sdk/client-s33.x, boto3, or the Go SDK. - A port free on the host (9000 for the API, 9001 for the console).
The local topology
Implementation
compose.yaml:
services:
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: devuser
MINIO_ROOT_PASSWORD: devpassword123
ports: ["9000:9000", "9001:9001"]
volumes: ["minio-data:/data"]
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 2s
retries: 30
minio-init:
image: minio/mc:RELEASE.2025-04-16T18-13-26Z
depends_on: { minio: { condition: service_healthy } }
entrypoint: >
sh -c "
mc alias set local http://minio:9000 devuser devpassword123 &&
mc mb --ignore-existing local/uploads-unscanned local/uploads-clean &&
mc anonymous set none local/uploads-unscanned &&
mc anonymous set download local/uploads-clean &&
mc ilm rule add --expire-days 1 local/uploads-unscanned &&
echo buckets ready"
restart: "no"
api:
build: .
depends_on: { minio-init: { condition: service_completed_successfully } }
environment:
S3_ENDPOINT_INTERNAL: http://minio:9000
S3_ENDPOINT_PUBLIC: http://localhost:9000
S3_ACCESS_KEY: devuser
S3_SECRET_KEY: devpassword123
ports: ["3000:3000"]
volumes:
minio-data:
Two S3 clients in the API — one for signing browser URLs, one for server-side access:
import { S3Client, PutObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const base = {
region: process.env.S3_REGION ?? "us-east-1",
forcePathStyle: Boolean(process.env.S3_ENDPOINT_PUBLIC), // MinIO needs path style; AWS does not
credentials: process.env.S3_ACCESS_KEY
? { accessKeyId: process.env.S3_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_KEY! }
: undefined, // AWS: fall back to the default chain
};
// Signs URLs the browser will use.
export const signer = new S3Client({ ...base, endpoint: process.env.S3_ENDPOINT_PUBLIC });
// Talks to storage from inside the network.
export const s3 = new S3Client({ ...base, endpoint: process.env.S3_ENDPOINT_INTERNAL });
export async function presignPut(key: string, contentType: string) {
return getSignedUrl(signer, new PutObjectCommand({ Bucket: "uploads-unscanned", Key: key, ContentType: contentType }), { expiresIn: 600 });
}
export async function confirm(key: string) {
const head = await s3.send(new HeadObjectCommand({ Bucket: "uploads-unscanned", Key: key }));
return { size: head.ContentLength, type: head.ContentType, etag: head.ETag };
}
Line-by-line on the decisions that matter
- Pinned image tags. MinIO releases often and occasionally changes defaults. A pinned tag keeps every developer and CI run on the same behaviour; upgrade deliberately.
- Health check plus
service_healthy. The init container would otherwise race MinIO’s startup and fail on the firstmc alias set.mc ready localreturns success only when the server accepts requests. --ignore-existing. The init container runs on everycompose up. Idempotent commands let it succeed whether the volume is fresh or reused.- Lifecycle rule in the init script. Local unscanned uploads expire after a day, mirroring the production rule from setting up S3 lifecycle rules for temporary uploads, so developers notice early if code depends on objects surviving.
- Two endpoints. SigV4 signs the
Hostheader. A URL signed forminio:9000fails in the browser (unresolvable host) and one rewritten afterwards tolocalhost:9000fails the signature check. Signing with a client configured for the public host is the only reliable fix. - Credentials only when set. In production on AWS the variables are absent and the SDK uses its default credential chain; the same code runs in both places.
Testing in CI
In GitHub Actions, run the same Compose file with docker compose up -d --wait before tests, or declare MinIO as a job service and run the mc commands in a setup step. The browser end-to-end test is the valuable one: it proves that the URL your API signs, the headers your front end sends, and the CORS behaviour all line up, which unit tests with mocked S3 clients never check.
MinIO is not S3, and it is worth a nightly job against a real bucket for behaviour that differs: IAM policy evaluation, bucket-owner-enforced ownership, S3 event payloads, specific error codes and checksum algorithms added recently to S3. Keep that suite small and focused on those edges.
Differences worth knowing
MinIO’s CORS behaviour differs from S3’s: it allows all origins by default and does not support per-bucket CORS configuration in the same way, so a CORS bug in your production bucket configuration will not show up locally. Test the production CORS rules separately, for example with the curl preflight approach in debugging CORS with curl preflight requests.
Bucket notifications exist in MinIO — to webhooks, Kafka, NATS, Redis and others — but the event format is S3-like rather than identical, and they are configured through mc event add rather than bucket notification configuration. If your production pipeline depends on EventBridge or SQS events, simulate them in tests by calling your event handler directly with a recorded payload.
Seeding fixtures and resetting state
Most upload bugs appear with specific files: a HEIC photo from an iPhone, a video with its moov atom at the end, a 2 GB file that needs multipart, a zero-byte file, a filename with emoji. Keep these in a fixtures/uploads/ directory and have a script mirror them into a fixtures bucket with mc mirror, so every developer can reproduce a processing issue against the same inputs. Large fixtures belong in Git LFS or a download step rather than the repository proper.
Resetting should be one command. docker compose down -v && docker compose up -d --wait gives a clean store in seconds; for faster resets between test cases, delete objects by prefix (mc rm --recursive --force local/uploads-unscanned/test-run-42/) and give each test run its own prefix so parallel tests never collide. Avoid resetting by recreating buckets inside tests — bucket creation is slow in S3 and teaches code habits that fail in production, where buckets are provisioned by infrastructure code.
Configuration gotchas
SignatureDoesNotMatch from the browser only. The URL was signed for the internal hostname, or a proxy changed the Host header. Sign with the public endpoint client.
The specified bucket does not exist on first run. The API started before the init container finished. Use service_completed_successfully on the dependency, or add a retry on startup.
Uploads work but files vanish after docker compose down. Without a named volume the data is in the container layer. The named volume above persists; docker compose down -v deliberately wipes it.
Virtual-hosted style URLs fail. MinIO supports virtual-hosted style only with MINIO_DOMAIN set and wildcard DNS. forcePathStyle: true is simpler locally.
Verification
docker compose up -d --wait
docker compose logs minio-init | tail -1 # buckets ready
URL=$(curl -s localhost:3000/api/uploads -H 'content-type: application/json' -d '{"contentType":"image/png"}' | jq -r .url)
echo "$URL" | grep -q '^http://localhost:9000/' && echo "signed for the public host"
curl -s -X PUT "$URL" -H 'Content-Type: image/png' --data-binary @test.png -w '%{http_code}\n' # 200
docker compose exec minio mc ls local/uploads-unscanned
Frequently Asked Questions
Is MinIO’s licence a problem for development use?
MinIO server is AGPLv3. Running it unmodified as a local development and CI service does not require releasing your application code; review the licence yourself if you plan to distribute or host it.
Can LocalStack replace MinIO?
LocalStack emulates S3 plus Lambda, SQS and EventBridge, which helps when you test event pipelines locally. For pure upload development MinIO is lighter and closer to a real object store.
Can I use the MinIO console to inspect uploads?
Yes. The console on port 9001 shows buckets, objects, metadata and lifecycle rules, and lets you download or delete objects by hand. It is the quickest way to confirm that an upload landed with the content type and metadata your code intended, before you write a test that asserts it.
Should developers share one MinIO?
No; one per developer via Compose keeps state isolated and avoids signature and CORS confusion from shared hostnames.