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

  1. Docker with Compose v2.
  2. An S3 client that accepts a custom endpoint — @aws-sdk/client-s3 3.x, boto3, or the Go SDK.
  3. A port free on the host (9000 for the API, 9001 for the console).

The local topology

Local upload development topology with MinIO Docker Compose runs MinIO, a one-shot mc init container that creates buckets, and your API. The API signs URLs for localhost port 9000 so the browser on the host can reach them, while server-side reads use the internal service name minio port 9000. Two hostnames for the same MinIO browser on the host docker compose network api signs for localhost minio :9000 published to host mc init (one-shot) minio:9000 PUT to localhost:9000 (presigned)
The signature covers the host, so browser-facing URLs must be signed for the host the browser uses.

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 first mc alias set. mc ready local returns success only when the server accepts requests.
  • --ignore-existing. The init container runs on every compose 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 Host header. A URL signed for minio:9000 fails in the browser (unresolvable host) and one rewritten afterwards to localhost:9000 fails 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

CI pipeline using MinIO as a service container The CI job starts MinIO as a service container, runs the init script, runs unit tests against the S3 client, then runs browser end-to-end tests that upload through presigned URLs, and finally tears everything down with no cloud credentials involved. Real S3 protocol, no cloud account start MinIO service container create buckets mc init script API tests sign, head, copy browser E2E Playwright uploads A fresh volume per run means tests never see leftovers; startup adds a few seconds. Keep a smaller nightly suite against real S3 for provider-specific behaviour.
Every pull request exercises the real upload protocol end to end.

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.

Three levels of reset for local storage state Per test, delete the test run's prefix, which takes milliseconds. Per session, restart the stack while keeping the volume. For a full reset, bring the stack down with volumes removed and start again, which takes a few seconds and re-runs the init script. Reset only as much as the situation needs per test mc rm run prefix milliseconds parallel-safe per session compose restart keeps fixtures seconds full reset down -v, up --wait re-runs init + seed clean slate Prefix-per-run isolation is the same habit that keeps production tenants apart.
Unique prefixes per test run make the cheapest reset the usual one.

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.