Local AWS · Handbook

MiniStack + StackPort: a local AWS development handbook

Two containers give you a believable AWS on a laptop: MiniStack implements ~80 AWS service APIs behind a , and StackPort is the browser that makes what you built visible. Neither is hard to run — but the failure modes are almost all credential and checksum plumbing, not emulation bugs, and they surface as errors that point at the wrong component. This handbook covers the architecture that explains those errors, setup on macOS and Ubuntu, and a catalogue of every failure hit standing the stack up from scratch.

MiniStack 1.4.16 · StackPort (35 services) · AWS CLI 2.36.21

1 port

4566 for ~80 services

one gateway, six routing signals

in-RAM

default storage

persistence is opt-in

6 failures

hit on a clean install

5 were plumbing, not bugs

35

services StackPort browses

8 with dedicated UIs

Two tools, one job each

The pairing is easy to misread as one product. It is not. MiniStack is an emulator: a Python ASGI application that speaks the AWS wire protocols and answers as though it were AWS. StackPort is a read/write client: a FastAPI backend wrapping boto3, plus a React single-page app, that renders whatever the emulator holds. StackPort has no privileged channel — it is just another SDK caller, subject to exactly the same signing rules as your own code.

That distinction is the single most useful thing to hold onto while debugging, because it predicts where faults live. If the UI shows an error, the question is never immediately is the emulator broken — it is did StackPort manage to sign and send a request at all. On a clean install the answer is often no, and MiniStack never sees the traffic.

MiniStack's pitch is breadth and speed: roughly 80 services on one port, sub-two-second startup, and real backends where a mock would be useless — DuckDB behind Athena, actual Postgres and MySQL containers behind RDS, real Redis behind ElastiCache, and an optional passthrough that turns Bedrock calls into an OpenAI-compatible request against Ollama or vLLM. It is a drop-in for Terraform, CDK, Pulumi and Testcontainers, and deliberately mirrors LocalStack's flag names and health endpoints so existing tooling keeps working.

StackPort's pitch is visibility. Emulators are opaque: you create resources through an SDK and then have no idea what is in there. StackPort browses 35 services, with purpose-built UIs for the eight you poke at most — S3, DynamoDB, Lambda, SQS, IAM, EC2, CloudWatch Logs and Secrets Manager — plus a generic table-and-JSON browser for the other 27. It also runs against LocalStack, Moto, and real AWS accounts, which is why it defaults to allowing writes locally but ships a STACKPORT_ALLOW_WRITES=false switch for anything you would rather not damage.

What neither tool promises is fidelity. An emulator that implements 80 services implements the common paths of 80 services. IAM does not really enforce policies, signatures are never verified, and error shapes drift from real AWS in the corners. Treat it as a fast feedback loop for wiring and shape-checking, not as proof that a deployment will behave.

Division of labour at a glance

RoleThe emulator — implements the AWS APIsThe browser — reads and writes through those APIs
Services80 reported available; 75 routing entries35 browsable
Default port4566 (single port, all services)8080
StackPython ASGI gateway, lazy-loaded handlersFastAPI backend + React/shadcn SPA
CredentialsNever verifies signatures; any key worksMust sign — botocore requires real-looking creds
StateIn-memory by default, opt-in persistenceStateless apart from endpoints.json
Also works withTerraform, CDK, Pulumi, TestcontainersLocalStack, Moto, real AWS
MiniStack implements the APIs; StackPort is a client of them. Sortable — click a header.

How one port serves eighty services

Real AWS gives every service its own hostname. MiniStack collapses all of them onto :4566, which means the very first thing it must do on every request is work out which service you meant. That decision lives in detect_service() in ministack/core/router.py, and it tries six signals in order, returning on the first hit.

The ordering is not cosmetic — it encodes which signals are trustworthy. A X-Amz-Target header is unambiguous, so it wins. The inside the Authorization header is next, because every signed SDK request carries the service's signing name whether or not it sets a target header. Only after those does the router fall back to weaker hints: an Action query parameter, a path pattern, then a Host regex.

Two consequences are worth internalising. First, step six is a catch-all to S3: anything that matches nothing is handed to the S3 handler, mirroring LocalStack. This is why a malformed or unsigned request so often produces a baffling S3 error rather than a helpful one — the router did not fail, it defaulted. Second, because host-pattern matching iterates a dictionary in declaration order and takes the first regex hit, entry order is load-bearing. The source carries an explicit comment about this: dynamodbstreams is declared before dynamodb because the host streams.dynamodb.<region>.amazonaws.com matches both patterns, and without that ordering Streams traffic would be silently misrouted.

Behind the router, handlers are lazy-loaded — a service's module is imported the first time a request selects it. That is what keeps startup under two seconds despite the breadth, and it means an unused service costs nothing but a routing-table entry.

Some services then reach outward. RDS, DSQL, ElastiCache, EKS, OpenSearch and Lambda can each spawn real Docker containers, which is why the compose file mounts /var/run/docker.sock. Those containers publish on host ports allocated from a per-service base — RDS_BASE_PORT=15432, ELASTICACHE_BASE_PORT=16379, DSQL_BASE_PORT=25432 — incrementing per instance. When MiniStack itself runs in a container and needs to talk to those, set DOCKER_NETWORK to your compose network so names resolve.

The six routing signals, in order

1X-Amz-Target headerDynamoDB_20120810.PutItemMost reliable signal. Used by every JSON-protocol service. Matched by target prefix.
2Authorization credential scopeCredential=test/20260812/us-east-1/sqs/aws4_requestThe 4th slash-separated field of the SigV4 credential is the signing name. How boto3 traffic routes when there is no target header.
3Action query parameter?Action=CreateQueueThe old AWS query protocol — SQS, SNS, IAM, STS, CloudWatch.
4URL path pattern/2015-03-31/functions/my-fn/invocationsDate-prefixed REST paths. Lets unsigned clients (curl, the Lambda runtime API) route correctly.
5Host header patternsqs.us-east-1.amazonaws.comRegex against the Host header. Entry order matters here — dynamodbstreams is declared before dynamodb so Streams traffic is not swallowed.
6Default → S3GET /my-bucket/key.txtAnything unmatched is S3, mirroring LocalStack. This is why a malformed request often surfaces as a confusing S3 error.
detect_service() in ministack/core/router.py — first hit wins and returns immediately.

Standing it up on macOS and Ubuntu

The fastest correct path is one compose file holding both containers plus Redis, because it removes the two things people get wrong by hand: the credentials StackPort needs, and the hostname it uses to reach the emulator. Inside a compose network, StackPort addresses MiniStack as http://ministack:4566 — no host.docker.internal involved, which is the detail that makes the same file work identically on both platforms.

Prerequisites differ only in how you obtain them. On macOS you need Docker Desktop (the engine runs in a Linux VM — there is no native option) and the AWS CLI, most easily brew install awscli. On Ubuntu you can run native Docker Engine from apt, which avoids the VM entirely; install the AWS CLI with the official script rather than apt, because apt install awscli still ships v1 and this handbook assumes v2 behaviour throughout.

One Ubuntu-specific trap: after installing Docker Engine your user is not in the docker group, so every command needs sudo until you run sudo usermod -aG docker $USER and start a new login session. A second: if you ever depart from compose and wire containers together by hand, host.docker.internal does not resolve on native Engine the way it does on Docker Desktop — you need extra_hosts: ["host.docker.internal:host-gateway"], or better, keep using service names.

Verify with the health endpoint rather than by opening the UI, so you are testing one thing at a time. curl http://localhost:4566/_ministack/health returns a JSON map of every service to available, plus version, edition and the status of any startup scripts. The edition field is worth reading: the light image omits optional native dependencies, and that omission is the direct cause of the checksum failure in §7. LocalStack-compatible aliases /_localstack/health and /health answer the same way, so existing wait-for-it scripts keep working.

Only once the emulator answers should you open StackPort at http://localhost:8080. If the dashboard populates, both halves and the credentials between them are correct.

One compose file for both platforms

# docker-compose.yml — both halves, one command.
services:
  ministack:
    image: ministackorg/ministack:latest
    ports: ["4566:4566"]
    environment:
      - GATEWAY_PORT=4566
      - S3_PERSIST=0          # 1 to keep objects across restarts
      - REDIS_HOST=redis
    volumes:
      - ./data/s3:/tmp/ministack-data/s3
      - /var/run/docker.sock:/var/run/docker.sock
    depends_on:
      redis: { condition: service_healthy }
    healthcheck:
      test: ["CMD", "python", "-c",
             "import urllib.request; urllib.request.urlopen('http://localhost:4566/_ministack/health')"]
      interval: 10s
      timeout: 3s
      retries: 3

  stackport:
    image: davireis/stackport:latest
    ports: ["8080:8080"]
    profiles: ["ui"]          # opt-in: docker compose --profile ui up -d
    environment:
      # Compose network name — no host.docker.internal, so this file is
      # identical on macOS and Ubuntu.
      - AWS_ENDPOINT_URL=http://ministack:4566
      - AWS_REGION=us-east-1
      - AWS_ACCESS_KEY_ID=test      # required: botocore signs before sending
      - AWS_SECRET_ACCESS_KEY=test
    depends_on:
      ministack: { condition: service_healthy }

  redis:
    image: redis:7-alpine
    ports: ["127.0.0.1:6379:6379"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      retries: 5

Platform setup

# 1. Docker Desktop (the engine runs in a Linux VM; no native option)
brew install --cask docker
open -a Docker

# 2. AWS CLI v2
brew install awscli
aws --version          # aws-cli/2.36.21 Python/3.14.6 Darwin/... arm64

# 3. Bring up the stack
docker compose --profile ui up -d

# 4. Verify the emulator BEFORE opening the UI
curl -s http://localhost:4566/_ministack/health | python3 -m json.tool | head
open http://localhost:8080

Where the two platforms genuinely differ

Docker runtimeDocker Desktop only — the engine runs inside a Linux VM.Native Docker Engine via apt, or Docker Desktop. No VM in the native case.
Where images and volumes liveInside one sparse disk image: ~/Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw. Not browsable in Finder.Directly on disk under /var/lib/docker (root-owned). Named volumes are real directories you can inspect.
host.docker.internalProvided automatically by Docker Desktop.Not defined by default on native Engine — add extra_hosts: ["host.docker.internal:host-gateway"], or just use the compose service name.
AWS CLI installbrew install awscli, or the official install.sh / AWSCLIV2.pkg.curl -fsSL https://awscli.amazonaws.com/v2/install.sh | bash, or snap install aws-cli --classic. Avoid apt's awscli — it is v1.
Docker socket permissionsHandled by Docker Desktop; no group juggling.Add yourself to the docker group (sudo usermod -aG docker $USER) and re-login, or every command needs sudo.
Bind-mount performanceCrosses the VM boundary via virtiofs — noticeably slower for many small files.Native filesystem speed.
Docker-in-Docker services/var/run/docker.sock mount works, but nested containers publish ports on the VM, reachable via localhost.Same socket mount; nested containers publish directly on the host.
Everything else about the workflow is identical.

Pointing the AWS CLI at it

There are three ways to aim the CLI at a local endpoint, and the commonly documented one is now the worst. Passing --endpoint-url on every command works but is tedious; exporting AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_DEFAULT_REGION avoids a profile but leaks into every shell you forget about.

The better option, available since AWS CLI 2.13, is to put inside the profile so no flag or variable is needed at all. A complete working profile has four parts: the endpoint, a region, dummy credentials, and two settings that exist purely to work around modern-CLI defaults.

The first workaround is checksums, and it is the one that will bite you. AWS CLI ≥ 2.23 defaults request_checksum_calculation to when_supported, which picks for S3 uploads. MiniStack rejects it — the light image does not bundle the native dependency — with InvalidRequest: Checksum algorithm not supported in this ministack build: CRC64NVME. Setting request_checksum_calculation = when_required omits the checksum unless an API mandates one, and uploads start working. Note that reads and every other service are unaffected, so the symptom looks weirdly specific to s3 cp.

The second is addressing style. Set addressing_style = path, because virtual-hosted-style would resolve my-bucket.localhost, and there is no wildcard DNS for localhost. MiniStack does implement extraction for clients that use it, but is what its own tooling assumes.

Decide deliberately whether this becomes your [default] profile. Making it the default means bare aws s3 ls just works, and on a machine with no real AWS credentials that is also a safety win — a stray destructive command cannot reach a real account. The moment you add real credentials, put them in a named profile so bare commands keep hitting localhost. Two smaller gotchas worth naming: the config files are INI read by the CLI, so source ~/.aws/config accomplishes nothing, and aws s3 cp requires exactly one side to carry the s3:// scheme — aws s3 cp file my-bucket fails with ParamValidation because the CLI sees two local paths.

The repository also ships an wrapper that exports dummy credentials and appends --endpoint-url. It is convenient, but a wrapper that predates the checksum default will fail on uploads for exactly the reason above unless it also exports AWS_REQUEST_CHECKSUM_CALCULATION — worth checking before you trust it.

A profile that needs no flags

# ~/.aws/config
[default]
endpoint_url = http://localhost:4566     # CLI >= 2.13: no --endpoint-url needed
region = us-east-1
output = json

# CLI >= 2.23 defaults to CRC64NVME, which MiniStack rejects.
# "when_required" omits the checksum unless an API mandates one.
request_checksum_calculation = when_required
response_checksum_validation = when_required

# localhost has no wildcard DNS, so bucket.localhost cannot resolve.
s3 =
    addressing_style = path

# ~/.aws/credentials
# [default]
# aws_access_key_id = test
# aws_secret_access_key = test
#   NOTE: a 12-digit key becomes the account id in every ARN.
# With endpoint_url in the profile, nothing extra is needed:
aws sts get-caller-identity      # -> Account "000000000000"
aws s3 mb s3://my-bucket
aws s3 cp ./file.txt s3://my-bucket/          # exactly one side needs s3://
aws s3 cp s3://my-bucket/file.txt -           # "-" means stdout
aws s3 cp ./dir s3://my-bucket/dir/ --recursive
aws dynamodb list-tables

# Common mistakes:
#   aws s3 cp file my-bucket   -> ParamValidation (no s3:// scheme)
#   source ~/.aws/config       -> does nothing; these are INI, not shell

StackPort, and the credential trap

StackPort is a single container: a FastAPI backend that builds cached boto3 clients, a thread-safe TTL cache, a WebSocket feeding the live dashboard, and a React SPA served as static files from the same process. Routes are grouped per service under /api/..., with /ws carrying dashboard updates. A healthy handshake on /ws answers 101 Switching Protocols.

The trap is that AWS_ENDPOINT_URL alone is not enough. Point the container at MiniStack with only that variable and every call fails with botocore.exceptions.NoCredentialsError: Unable to locate credentials, surfacing in the browser as a bare 500 on /api/s3/buckets. The reason is worth being precise about: botocore fails while signing, before opening a socket. No request reaches MiniStack, so nothing appears in its logs, and the emulator looks guilty while being entirely uninvolved.

It also cannot borrow your credentials. A container does not see the host's ~/.aws, so the profile you configured for the CLI is invisible to it. Either pass AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY directly — any values work, since MiniStack never verifies signatures — or mount ~/.aws:/root/.aws and set AWS_PROFILE. Upstream's own example compose file passes test/test explicitly, which is the strongest hint about the intended usage.

Beyond the basics, the configuration surface is mostly about scope and safety. STACKPORT_ENDPOINTS registers several targets in one string (local=http://localhost:4566,nprod=, where an empty value means real AWS), and per-endpoint auth supports SSO, AssumeRole and static keys from the Settings UI — when an SSO token expires you get a 401 telling you to run aws sso login. STACKPORT_SERVICES trims the probe list so the dashboard stops checking services you do not use, and STACKPORT_ALLOW_WRITES=false turns the whole thing read-only, which is the setting to reach for the moment an endpoint points anywhere real. Endpoints added through the UI persist to endpoints.json under STACKPORT_DATA_DIR, so mount it if you want them to survive.

There is also a CLI in the same package — stackport status, list, describe, export, each accepting --endpoint and --region and emitting JSON, CSV or tables. For scripted inspection it is usually faster than the UI, and it makes the tool useful in CI where nobody is looking at a browser.

Configuration surface

AWS_ENDPOINT_URL(unset)ConnectionThe emulator to browse. Unset means real AWS.
AWS_ACCESS_KEY_ID(unset)ConnectionRequired in practice against MiniStack. Unset falls back to the boto3 credential chain, which inside a container finds nothing.
AWS_SECRET_ACCESS_KEY(unset)ConnectionPair to the access key. Any value works — MiniStack never verifies signatures.
AWS_REGIONus-east-1ConnectionRegion used for every client it constructs.
AWS_PROFILE(unset)ConnectionNamed profile from ~/.aws/credentials, supporting SSO, AssumeRole and credential_process. In Docker you must mount ~/.aws to use it.
STACKPORT_ENDPOINTS(unset)ConnectionRegister several targets at once, e.g. local=http://localhost:4566,nprod= — an empty value means real AWS.
STACKPORT_PORT8080ServerPort the web server binds.
STACKPORT_ALLOW_WRITEStrueServerGate on POST/PUT/DELETE. Set false when pointing at anything you care about.
STACKPORT_SERVICESall 35ServerComma-separated allow-list, to stop probing services you do not use.
STACKPORT_S3_MAX_UPLOAD_MB100ServerPer-object upload ceiling in MiB.
STACKPORT_PROBE_TIMEOUT5TuningSeconds before a service probe is called dead.
STACKPORT_PROBE_WORKERS10TuningConcurrency for the probe loop that feeds the dashboard.
STACKPORT_CACHE_TTL5TuningSeconds that service stats stay cached.
STACKPORT_DATA_DIR~/.stackportTuningWhere endpoints.json is persisted. Mount it to keep endpoints you add through the UI.
LOG_LEVELINFOTuningDEBUG also logs the health checks.
StackPort environment variables. Sort by Group to separate connection from tuning.

Debugging it

# A 500 from the UI: read StackPort's own logs first.
docker logs --tail 40 stackport
#   botocore.exceptions.NoCredentialsError: Unable to locate credentials
#   ^ signing failed inside the container; MiniStack never saw the request,
#     which is why its logs are silent.

# Confirm credentials are the only missing piece, without recreating anything:
docker exec -e AWS_ACCESS_KEY_ID=test -e AWS_SECRET_ACCESS_KEY=test \
  -e AWS_DEFAULT_REGION=us-east-1 stackport python -c \
  "import boto3,os; print(boto3.client('s3', \
   endpoint_url=os.environ['AWS_ENDPOINT_URL']).list_buckets()['Buckets'])"

# Verify the socket separately — healthy is 101 Switching Protocols:
curl -s -o /dev/null -w '%{http_code}\n' \
  -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  http://localhost:8080/ws

Where your data actually is

MiniStack is in-memory first. S3_PERSIST defaults to 0, and the S3 module says so in its own docstring: storage is in-memory, optionally backed by S3_DATA_DIR. Objects live in the Python process heap, so stopping the container — not deleting it, merely stopping it — frees them. The tell is unambiguous: a compose file can mount ./data/s3 and that directory stays completely empty, because nothing was ever written to it.

Flip S3_PERSIST=1 and objects are written through to S3_DATA_DIR, laid out account-scoped as <account_id>/<bucket>/<key> — so a bucket created under the default account lands in data/s3/000000000000/<bucket>/. The path resolution is hardened against traversal: keys containing .. are rejected rather than allowed to escape the data directory. Persistence is per-subsystem, with PERSIST_STATE for general service state and RDS_PERSIST / DSQL_PERSIST switching those containers from tmpfs to Docker named volumes.

The trade-off runs the other way too. Persisted data survives docker compose down and the /_ministack/reset control endpoint, so stale fixtures leak between test runs and eventually produce failures that make no sense. Either clear the directory deliberately, or leave persistence off and treat every start as clean — the default exists for a reason.

For the container filesystem itself, the layers are worth separating. A like ./data/s3:/tmp/ministack-data/s3 maps to a real directory you can open in your editor. Named volumes and each container's writable layer live under Docker's own root, which docker info reports as /var/lib/docker. On Ubuntu that is a genuine host path. On macOS it is a path inside the Linux VM, and the whole VM is one sparse disk image at ~/Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw — which will happily report a 460 GB apparent size while occupying under a gigabyte, because it allocates lazily. Only bind mounts escape that image into somewhere Finder can reach.

The practical rule: if you want to read, grep or edit emulator data with ordinary tools, it must be on a bind mount, and persistence must be switched on. Everything else is either RAM or inside a VM image.

# S3_PERSIST=0 (default) — nothing on disk, ./data/s3 stays empty:
$ ls data/s3
$                                   # objects are in the process heap

# S3_PERSIST=1 — account-scoped layout appears:
data/s3/000000000000/my-bucket/key.txt
#       ^ account id  ^ bucket   ^ key

# Where Docker itself keeps things:
#   bind mount   ./data/s3            -> a real host directory (both platforms)
#   named volume /var/lib/docker/...  -> real on Ubuntu; inside the VM on macOS
#
# macOS: the entire engine lives in one sparse image —
#   ~/Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw
#   (may report ~460 GB apparent size while using well under 1 GB)

MiniStack configuration worth knowing

GATEWAY_PORT4566CorePort the single gateway listens on. EDGE_PORT is accepted as a LocalStack-compatible alias.
MINISTACK_HOSTlocalhostCoreHostname baked into returned URLs — SQS queue URLs, SNS subscriptions, API Gateway endpoints. Set it when clients reach MiniStack by another name.
MINISTACK_REGIONus-east-1CoreRegion reported in ARNs and responses when the request does not carry one.
MINISTACK_ACCOUNT_ID000000000000CoreFallback account id. Overridden per-request whenever the access key is 12 digits.
LOG_LEVELINFOCoreDEBUG surfaces per-request routing decisions — the fastest way to see which handler claimed a request.
S3_PERSIST0State0 keeps every object in process memory, lost on restart. 1 writes through to disk.
S3_DATA_DIR/tmp/ministack-data/s3StateWhere persisted objects land, laid out as <account_id>/<bucket>/<key>.
PERSIST_STATE0StatePersist non-S3 service state across restarts.
STATE_DIR/tmp/ministack-stateStateDirectory for the persisted state files.
RDS_PERSIST0StateUse Docker named volumes for RDS containers instead of tmpfs, so databases survive.
DSQL_PERSIST0StateSame idea for Aurora DSQL backend containers.
DOCKER_NETWORK(unset)DockerNetwork for every container-backed service — RDS, DSQL, EKS, ElastiCache, Lambda. Set it to your compose network so containers can reach each other. Replaces LAMBDA_DOCKER_NETWORK.
LAMBDA_EXECUTORlocalDockerlocal runs Lambda in a subprocess; docker runs it in a container. provided runtimes and image packages always use Docker.
LAMBDA_STRICT0DockerAWS-fidelity mode: every invoke runs in a container via the AWS RIE image, and missing Docker fails loudly as Runtime.DockerUnavailable instead of silently degrading.
RDS_BASE_PORT15432PortsFirst host port handed out to RDS containers; each instance takes the next one.
ELASTICACHE_BASE_PORT16379PortsFirst host port for ElastiCache containers.
DSQL_BASE_PORT25432PortsFirst host port for Aurora DSQL cluster proxies, one per cluster.
USE_SSL0TransportServe HTTPS. With no cert supplied, a self-signed one is generated under ${TMPDIR}/ministack-tls/ and cached across restarts.
ATHENA_ENGINEautoReal backendsauto, duckdb, or mock — whether Athena runs real SQL through DuckDB.
SMTP_HOST(unset)Real backendsPoint at e.g. mailhog:1025 and SES actually delivers mail. Unset keeps messages in memory, readable via the internal API.
MINISTACK_BEDROCK_PROXY_URL(unset)Real backendsAn OpenAI-compatible /chat/completions endpoint (Ollama, vLLM, llama.cpp). Bedrock calls are translated out and back, so you get real completions through the Bedrock API.
The env vars you actually reach for, out of a ~50-entry table. Sort by Group.

The failure catalogue

Six distinct failures showed up while bringing this stack up from nothing on macOS. Five were configuration plumbing and one was a genuine packaging gap in a helper script. None were emulation bugs — which is the pattern worth expecting.

What makes them expensive is misattribution. The checksum rejection names an algorithm you never chose. The StackPort 500 implicates the emulator when the emulator was never contacted. The NoCredentials error on a bare command appears after you have already configured a working profile. In each case the error text is accurate about the mechanism and misleading about the cause, so the useful habit is to ask which component actually emitted it before changing anything.

Two of them share a root: modern AWS SDK defaults moving faster than emulators. CRC64NVME checksums became the default in botocore 1.36, and any emulator that has not added the native dependency now rejects uploads from a stock, up-to-date CLI. Expect more of this shape, and expect the fix to be a client-side default rather than an emulator patch.

The table below is sorted by where each failure surfaces. Every symptom string is quoted from an actual run.

aws s3 cp, any uploadInvalidRequest: Checksum algorithm not supported in this ministack build: CRC64NVMEAWS CLI ≥ 2.23 (botocore ≥ 1.36) defaults request_checksum_calculation to when_supported, which selects CRC64NVME. MiniStack supports SHA256, SHA1 and CRC32 only — CRC32C and CRC64NVME need native deps it does not bundle.Set request_checksum_calculation = when_required in the profile, or AWS_REQUEST_CHECKSUM_CALCULATION=when_required in the environment.
StackPort /api/s3/bucketsNoCredentialsError: Unable to locate credentials (HTTP 500 from the UI)The container was given AWS_ENDPOINT_URL but no credentials. botocore fails at signing time, before any request reaches MiniStack — so the emulator looks broken when it is fine. Your host ~/.aws is not visible inside the container.Pass AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_REGION to the container. Any values work.
bare aws s3 lsNoCredentials: Unable to locate credentials … run "aws login"Credentials existed only under a named profile. A bare command reads the default profile, which did not exist.export AWS_PROFILE=local, or define a [default] profile. Note that config files are INI read by the CLI — sourcing them in a shell does nothing.
aws s3 cp file my-bucketParamValidation: Invalid argument typeThe destination lacked the s3:// scheme, so the CLI saw two local paths. Exactly one side of a cp must be an S3 URI.aws s3 cp ./file s3://my-bucket/ — and confirm the local file actually exists.
any serviceBuckets and objects vanish after a restartS3_PERSIST defaults to 0, so objects live in the process heap. Stopping the container frees the heap. The bind mount stays empty because nothing was ever written.Set S3_PERSIST=1 (and PERSIST_STATE=1 for other services) if data should outlive the process.
StackPort /wsWebSocket closed before the connection is establishedAlmost always a symptom, not a cause: the dashboard's first fetch failed, React tore the component down, and the socket died mid-handshake. A healthy /ws answers 101 Switching Protocols.Fix the failing REST call first, then recheck the socket.
Every failure hit bringing this stack up from scratch. Symptom strings are quoted from real runs.

Running it day to day

MiniStack exposes a small control plane under /_ministack/ that is more useful than restarting containers. GET health reports per-service availability, version, edition and startup-script status. POST reset wipes all state without a restart, and POST reset?init=1 re-runs the startup scripts afterwards — the fast way to return to a known fixture between test runs. POST config adjusts settings live. Two inspection endpoints save real time: GET ses/messages reads the emails SES captured in memory, and GET sqs/messages peeks at a queue without consuming, both filterable by account.

Startup scripts are how you get repeatable fixtures. MiniStack honours LocalStack's directories — /etc/localstack/init and /docker-entrypoint-initaws.d — so mounting a folder of shell scripts there provisions your buckets, tables and queues on boot, and reset?init=1 replays them.

Multi-tenancy needs no configuration and is easy to trigger by accident. If AWS_ACCESS_KEY_ID is 12 digits, that value becomes the account id in every ARN; anything non-numeric like test falls back to MINISTACK_ACCOUNT_ID, default 000000000000. So switching your dummy key from test to 111111111111 silently moves you into a different tenant with its own resources — occasionally exactly what you want for isolation tests, occasionally a very confusing empty bucket list. Regions work the same way, taken from the request's credential scope, so --region eu-west-1 and --region us-east-1 address genuinely separate resource sets.

For stopping, distinguish the two verbs. docker compose stop halts containers but keeps them, so start resumes quickly; docker compose down removes them and needs a full up. Either way, with persistence off, the data is gone — the containers surviving is not the same as the state surviving. If StackPort was launched by hand with docker run rather than through compose, compose commands will not touch it and it must be stopped by name; folding it into the compose file behind an opt-in profile is what makes one command handle everything.

Finally, keep the emulator's own test suite in mind as documentation. The repository ships some 1,400 tests across the services, and when you are unsure whether a behaviour is supported, the test for it is a faster and more honest answer than the README.

# Health: per-service availability, version, edition, init-script status
curl http://localhost:4566/_ministack/health
curl http://localhost:4566/_localstack/health      # LocalStack-compatible alias

# Wipe all state without restarting; init=1 replays the startup scripts
curl -X POST http://localhost:4566/_ministack/reset
curl -X POST "http://localhost:4566/_ministack/reset?init=1"

# Inspect what the emulator captured
curl http://localhost:4566/_ministack/ses/messages
curl "http://localhost:4566/_ministack/sqs/messages?account=000000000000"

# Stop: "stop" keeps containers, "down" removes them.
# With persistence off, BOTH lose your data.
docker compose stop
docker compose --profile ui down

Glossary

Every dotted-underlined term on this page links back here. Hover any of them in context, or browse the full definitions below.

Single-port gateway

Real AWS gives each service its own hostname; MiniStack collapses them onto one port and recovers the intended service from request metadata. The upside is trivial client configuration — one endpoint_url for everything. The cost is that service detection becomes a guess with a fallback, which is why unmatched requests land on S3.

SigV4

The client derives a signing key from its secret and stamps an Authorization header containing a credential string, the signed-header list, and a signature. Crucially, signing happens entirely client-side and requires credentials to exist before any socket is opened — which is why a missing key produces a local NoCredentialsError rather than a server-side 403.

Credential scope

Formatted Credential=<key>/<date>/<region>/<service>/aws4_request. MiniStack reads the fourth field — the signing name — as its second-choice routing signal, and the region field as the request's region. This is how signed traffic routes correctly even without an X-Amz-Target header, and how multi-region isolation works with no extra configuration.

CRC64NVME

From botocore 1.36 / AWS CLI 2.23, request_checksum_calculation defaults to when_supported and selects CRC64NVME for S3 uploads. It requires a native dependency the light MiniStack image does not bundle, so PutObject fails with InvalidRequest. Setting when_required omits the checksum entirely; SHA256, SHA1 and CRC32 are the supported alternatives.

Path-style addressing

The form local emulators need, because the alternative puts the bucket in the hostname and localhost has no wildcard DNS to resolve bucket.localhost. Set addressing_style = path in the profile. AWS deprecated path-style for real S3, which is why modern SDKs no longer choose it by default.

Virtual-hosted-style

The default for real S3. MiniStack does implement extraction of the bucket from the Host header for clients that insist on it, treating a bare base host with no leading label as path-style. It still fails against localhost for DNS reasons rather than emulator ones.

endpoint_url (profile setting)

Supported since AWS CLI 2.13. With endpoint_url in the profile, plain commands like aws s3 ls reach the emulator with no --endpoint-url and no environment variables. Most local-AWS documentation predates this and still shows the flag repeated on every line.

awslocal

Exports test credentials, a region, optionally a CA bundle for TLS mode, then execs aws --endpoint-url=<local>. Convenient, but because it sets its own environment it bypasses your profile — so profile-level fixes (notably the checksum setting) do not reach it unless the wrapper exports them too.

Probe loop

A worker pool calls a cheap list operation per service, caches the result for STACKPORT_CACHE_TTL seconds, and pushes updates over the /ws WebSocket. Tune it with STACKPORT_PROBE_WORKERS and STACKPORT_PROBE_TIMEOUT, or narrow it with STACKPORT_SERVICES so it stops probing services you never use.

Bind mount

The only Docker storage type that stays visible to ordinary host tools. Named volumes and container writable layers live under Docker's root — a real path on Linux, but a path inside the VM disk image on macOS. If you want to grep emulator data, it has to be a bind mount and persistence has to be enabled.

tmpfs

Fast and automatically clean, but capped and volatile — RDS_TMPFS_SIZE defaults to 256m, which larger fixtures will exhaust. Setting RDS_PERSIST=1 or DSQL_PERSIST=1 switches those containers to Docker named volumes that grow dynamically and survive restarts.

Runtime Interface Emulator

With LAMBDA_EXECUTOR=docker or LAMBDA_STRICT=1, invocations run in a container through the AWS RIE image instead of a subprocess. Strict mode also stops the silent fallback: missing Docker surfaces as Runtime.DockerUnavailable rather than quietly degrading to in-process execution.

host-gateway

Docker Desktop defines host.docker.internal automatically; native Docker Engine does not. Adding extra_hosts: ["host.docker.internal:host-gateway"] restores it. Using compose service names instead avoids the whole difference, which is why a compose-based setup ports cleanly between macOS and Ubuntu.

Edition (light vs full)

Reported by /_ministack/health. The light image omits libraries that lack musllinux wheels, and that omission is exactly why CRC32C and CRC64NVME checksums are unavailable. Reading this field first turns a confusing rejection into an expected one.

FAQ

It occupies the same slot and deliberately mirrors LocalStack's conventions — the same 4566 port, EDGE_PORT alias, /_localstack/health endpoint, startup-script directories, and the same default-to-S3 routing behaviour — so most tooling written for LocalStack works unchanged. Whether it replaces it depends on which services you lean on and how much fidelity you need in the corners.

Because S3_PERSIST defaults to 0 and objects live in the process heap. Stopping the container frees them. The giveaway is that a mounted ./data/s3 stays empty — nothing was ever written. Set S3_PERSIST=1 if data should outlive the process, and remember persisted data then survives both compose down and /_ministack/reset.

Uploads attach a checksum; reads do not. AWS CLI ≥ 2.23 defaults to CRC64NVME, which the light image cannot compute, so PutObject is rejected while list, get and every other service succeed. Set request_checksum_calculation = when_required.

The container's own logs. A NoCredentialsError traceback there means botocore failed while signing and no request ever left the container, so MiniStack's logs are silent by design. Pass AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to StackPort — it cannot see your host's ~/.aws.

On a machine with no real AWS credentials, yes — bare commands work and a stray destructive command physically cannot reach a real account. The moment you add real credentials, put them in a named profile. Inverting that means an unqualified command silently targets production.

No. Signatures are never verified, IAM does not enforce policies, and error shapes diverge in the corners. It is a fast loop for wiring, shapes and integration plumbing — not a substitute for deploying to a real account.

A 12-digit AWS_ACCESS_KEY_ID is interpreted as the account id, so changing your dummy key from test to something like 111111111111 moves you into a separate tenant with its own resources. Non-numeric keys fall back to MINISTACK_ACCOUNT_ID, default 000000000000.

MiniStack itself can run as a plain Python process, and Lambda defaults to a subprocess executor precisely so the base install needs no Docker. But the container-backed services — real RDS, ElastiCache, EKS, DSQL, OpenSearch data planes — require it, and StackPort's usual distribution is a container (there is also a pip package).

References

The works the paper builds on — every entry links to the cited paper.

  1. [1]

    MiniStack contributors (2026). MiniStack — 60+ AWS services on a single port. GitHub · ministackorg/ministack

  2. [2]

    Davi Reis Vieira (2026). StackPort — universal AWS resource browser for LocalStack, MiniStack, Moto and real AWS. GitHub · DaviReisVieira/stackport

  3. [3]

    Davi Reis Vieira (2026). StackPort + MiniStack example compose file. GitHub · examples/docker-compose.yml

  4. [4]

    Davi Reis Vieira (2026). StackPort — project site. stackport.cloud

  5. [5]

    Davi Reis Vieira (2026). davireis/stackport container image. Docker Hub

  6. [6]

    Amazon Web Services (2026). Installing or updating to the latest version of the AWS CLI. AWS CLI v2 User Guide

  7. [7]

    Amazon Web Services (2026). Checking object integrity in Amazon S3. Amazon S3 User Guide

  8. [8]

    Amazon Web Services (2026). Virtual hosting of buckets. Amazon S3 User Guide

  9. [9]

    Amazon Web Services (2026). Signing AWS API requests (Signature Version 4). AWS General Reference

  10. [10]

    Docker Inc. (2026). Networking — accessing the host from a container. Docker Engine documentation

  11. [11]

    Docker Inc. (2026). Install Docker Engine on Ubuntu. Docker documentation

  12. [12]

    Docker Inc. (2026). Compose profiles. Docker Compose documentation

Cite This Paper

@misc{ministack_stackport_handbook,
  title        = {MiniStack + StackPort: a local AWS development handbook},
  howpublished = {Interactive handbook},
  note         = {MiniStack 1.4.16, StackPort, AWS CLI 2.36.21; verified on macOS 26 (arm64)},
  year         = {2026}
}