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.

Internal Host vs Public Host in a Presigned URL Comparison of a presigned URL signed for the internal MinIO host against one signed for the public host. Internal Host vs Public Host in a Presigned URL signed for minio:9000 signed for s3.localhost works container to container works from the browser browser cannot resolve browser resolves via proxy host rewrite breaks signature no rewrite needed
The signature covers the host, so the URL must be signed for the name the browser will use.

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

  1. 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:
  1. Create buckets and a CORS policy on startup with a one-shot mc container:
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.

  1. 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.

Presigned Upload Through the Proxy Flow of a browser upload using a URL signed for the public host and routed through the proxy to MinIO. Presigned Upload Through the Proxy API presigns host s3.localhost browser PUTs https://s3.localhost proxy routes to minio:9000 MinIO verifies signature ok
Browser and MinIO both see Host s3.localhost, so the signature verifies.

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

  1. 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.

  2. Pin MinIO release tags. MinIO publishes frequently and has changed defaults (such as console behaviour and deprecated gateway modes) between releases. Pin both minio and mc and update them together.

  3. Keep the root credentials local-only. minioadmin-local is a development credential; do not reuse the value anywhere else and do not store real data in the local MinIO.

Life of a Presigned Upload Timeline of a presigned upload from signing to object stored, with the step that fails when the host is wrong. Life of a Presigned Upload t0 API signs for s3.localhost t1 URL sent to browser t2 browser PUT via proxy t3 MinIO checks signature t4 object stored
A wrong signing host fails only at the last step, which is why the round-trip test matters.

Platform caveats

Apple Silicon (ARM64): MinIO and mc images 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.localhost to loopback and reach the proxy through Docker Desktop's port forwarding. If uploads hang, confirm ports 80 and 443 are forwarded with netstat -ano | findstr :443 on 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.