Replacing S3 With MinIO in Local Development
Uploads work against MinIO from the API, but the browser's direct upload with a presigned URL fails with SignatureDoesNotMatch, or the presigned link opens http://minio:9000/..., a hostname the browser cannot resolve. MinIO is an excellent local S3 — fast, S3-compatible, with a real console and persistent volumes — but presigned URLs expose the one place where "inside the Docker network" and "in the browser" disagree. This page sets MinIO up as the S3 endpoint for a Compose stack and fixes presigned URLs properly. It sits under emulating cloud services locally.
MinIO is the better choice over a general AWS emulator when S3 is the only AWS service the application uses, when tests move large objects or multipart uploads, or when developers want to browse stored files in a UI.
Diagnostic
Generate a presigned URL from inside the API container and try it from the host, the way a browser would:
#!/usr/bin/env bash
set -euo pipefail
url=$(docker compose exec -T api node -e "
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const s3 = new S3Client({ forcePathStyle: true });
getSignedUrl(s3, new PutObjectCommand({ Bucket: 'uploads', Key: 'probe.txt' }), { expiresIn: 300 }).then(console.log);")
echo "$url" | cut -c1-80
curl -sS -X PUT --data 'hello' "$url" -o /dev/null -w '%{http_code}\n' || true
curl -sS -X PUT --data 'hello' "${url/minio:9000/localhost:9000}" | grep -o '<Code>[^<]*' || true
Expected bad output:
http://minio:9000/uploads/probe.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Cr
curl: (6) Could not resolve host: minio
000
<Code>SignatureDoesNotMatch
The URL names the internal host, which fails to resolve outside Docker. Rewriting the host to localhost fixes resolution but breaks the signature, because the Host header is part of what was signed.
Root cause
A SigV4 presigned URL is an HMAC over the method, path, query parameters and a set of signed headers that always includes Host. The API's S3 client is configured with AWS_ENDPOINT_URL=http://minio:9000 because that is how containers reach MinIO, so it signs Host: minio:9000. The browser cannot resolve minio, and if anything rewrites the host, MinIO recomputes the signature with the new Host value, gets a different HMAC and rejects the request with SignatureDoesNotMatch. The fix is not to rewrite URLs but to sign them for a hostname that both the browser and MinIO agree on.
The same split shows up in any S3-compatible setup, including LocalStack and real S3 behind a CDN or VPC endpoint: the address used for server-to-server calls and the address clients use are different, and presigning must use the client-facing one. Production usually hides this because both paths use the same public S3 hostname; locally the difference is explicit, which makes it a useful place to get the pattern right once.
Resolution
- Run MinIO behind the local proxy on a public-facing hostname, plus keep the internal name for server-side calls:
services:
minio:
image: quay.io/minio/minio:RELEASE.2024-08-29T01-40-52Z
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin-local
MINIO_DOMAIN: s3.localhost
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
retries: 20
labels:
- traefik.enable=true
- traefik.http.routers.s3.rule=Host(`s3.localhost`)
- traefik.http.routers.s3.entrypoints=websecure
- traefik.http.routers.s3.tls=true
- traefik.http.services.s3.loadbalancer.server.port=9000
volumes:
minio-data:
- Create buckets and a CORS policy on startup with a one-shot
mccontainer:
services:
minio-init:
image: quay.io/minio/mc:RELEASE.2024-08-26T10-49-58Z
depends_on:
minio:
condition: service_healthy
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
mc alias set local http://minio:9000 minioadmin minioadmin-local
mc mb --ignore-existing local/uploads
mc anonymous set none local/uploads
echo "buckets ready"
restart: "no"
MinIO applies CORS server-wide rather than per bucket; setting MINIO_API_CORS_ALLOW_ORIGIN=https://app.localhost on the server restricts cross-origin uploads to the frontend's origin.
- Use two S3 clients: one internal, one for presigning. The presigner uses the public endpoint; server-side calls keep the fast internal path:
const { S3Client } = require('@aws-sdk/client-s3');
const internal = new S3Client({ endpoint: process.env.S3_INTERNAL_ENDPOINT, forcePathStyle: true });
const presign = new S3Client({ endpoint: process.env.S3_PUBLIC_ENDPOINT, forcePathStyle: true });
module.exports = { internal, presign };
services:
api:
environment:
S3_INTERNAL_ENDPOINT: http://minio:9000
S3_PUBLIC_ENDPOINT: https://s3.localhost
AWS_ACCESS_KEY_ID: minioadmin
AWS_SECRET_ACCESS_KEY: minioadmin-local
AWS_REGION: us-east-1
In production both variables are unset and the SDK uses the regional S3 endpoint, so the two-client split costs nothing there. Signing happens locally in the SDK without contacting MinIO, so the presign client never needs to reach s3.localhost from inside the container.
Expected output
$ curl -sS -X PUT --data 'hello' "$url" -o /dev/null -w '%{http_code}\n'
200
$ docker compose exec -T minio-init mc ls local/uploads
[2026-09-18 09:20:11 UTC] 5B STANDARD probe.txt
The URL is https://s3.localhost/uploads/probe.txt?..., the upload returns 200 through the proxy, and the object appears in the bucket and in the MinIO console at http://localhost:9001 if the console port is published.
Prevention
Test a presigned round trip in CI. Generate a URL, upload through it with
curl, and download it again; this is the one path unit tests with mocked S3 never cover.Pin MinIO release tags. MinIO publishes frequently and has changed defaults (such as console behaviour and deprecated gateway modes) between releases. Pin both
minioandmcand update them together.Keep the root credentials local-only.
minioadmin-localis a development credential; do not reuse the value anywhere else and do not store real data in the local MinIO.
Platform caveats
Apple Silicon (ARM64): MinIO and
mcimages are multi-arch and run natively.
macOS (Docker Desktop): keep MinIO's data in a named volume, not a bind mount; bind-mounted object storage is much slower and can hit file-locking issues under virtiofs.
WSL2: browsers on Windows resolve
s3.localhostto loopback and reach the proxy through Docker Desktop's port forwarding. If uploads hang, confirm ports 80 and 443 are forwarded withnetstat -ano | findstr :443on the Windows side.
Rollback
#!/usr/bin/env bash
set -euo pipefail
docker compose rm -sfv minio minio-init
docker volume rm "$(basename "$PWD")_minio-data" 2>/dev/null || true
git restore compose.yaml
Removing the volume deletes every local object, which is intended: local object storage should be reproducible from seed scripts.
Frequently Asked Questions
Why not just rewrite minio:9000 to localhost:9000 in the URL?
The host is part of the signed data. Changing it after signing makes MinIO compute a different signature and reject the request with SignatureDoesNotMatch. Sign the URL for the host the browser will use.
Should I use MinIO or LocalStack for S3?
Use MinIO when S3 is the only AWS dependency, when large or multipart uploads matter, or when a browsing console is useful. Use LocalStack when the application also needs SQS, SNS or other AWS services, so one emulator covers them all.
Does MinIO support virtual-hosted bucket URLs locally?
Yes, when MINIO_DOMAIN is set and the bucket subdomains resolve, for example uploads.s3.localhost. Path-style is simpler locally because it needs no wildcard certificate or DNS for each bucket.
How do I seed test files into MinIO?
Add mc cp --recursive /seed/ local/uploads/ to the init container and mount a seed directory into it. Keep seed files small and deterministic so tests produce the same results on every machine.