MiniStack is a free, open-source, MIT-licensed drop-in replacement for LocalStack — 21 AWS services on a single port, no account, no license key, no telemetry.
v1.0.4 is out today. This post covers everything that landed across the v1.0.2–v1.0.4 wave: the big features, the architectural decisions behind them, and the fixes that got us to a clean 379-test run.
docker run -p 4566:4566 nahuelnucera/ministack
Why this exists
LocalStack moved its core services behind a paid plan. If you were using LocalStack Community for local dev and CI/CD, you now hit a paywall for S3, SQS, Lambda, and most things that matter.
MiniStack covers the same surface — and goes further in a few areas — at $0, MIT licensed, forever. The full comparison is on ministack.org, but the short version: same developer experience, 150 MB image vs ~1 GB, ~30 MB RAM at idle vs ~500 MB, ~2 s startup vs 15–30 s.
What shipped in v1.0.2–v1.0.4
API Gateway HTTP API v2 — full control plane + data plane
This was the biggest missing piece. v1.0.2 delivered the complete API Gateway v2 implementation.
Control plane covers the full resource tree:
- APIs:
CreateApi,GetApi,GetApis,UpdateApi,DeleteApi - Routes: full CRUD, including
{param}path parameter placeholders and{proxy+}greedy matching - Integrations:
AWS_PROXY(Lambda) andHTTP_PROXY(arbitrary HTTP backends) - Stages, Deployments, Authorizers (JWT + Lambda), Tags
Data plane is the part most emulators skip. In MiniStack, execute-api requests are dispatched by host header — {apiId}.execute-api.localhost — before normal service routing. That means your actual API calls hit the right Lambda function with a proper payload format 2.0 envelope:
import boto3
apigw = boto3.client("apigatewayv2", endpoint_url="http://localhost:4566",
aws_access_key_id="test", aws_secret_access_key="test",
region_name="us-east-1")
api = apigw.create_api(Name="my-api", ProtocolType="HTTP")
api_id = api["ApiId"]
apigw.create_route(ApiId=api_id, RouteKey="GET /hello")
apigw.create_integration(
ApiId=api_id,
IntegrationType="AWS_PROXY",
IntegrationUri=f"arn:aws:lambda:us-east-1:000000000000:function:my-func",
PayloadFormatVersion="2.0",
)
import requests
resp = requests.get(f"http://{api_id}.execute-api.localhost:4566/hello")
print(resp.json())
Route matching supports path parameters and {proxy+} greedy segments. There was a subtle bug in the initial implementation: re.escape was applied before {param} substitution, which meant every parameterised route silently fell through to a 404. Fixed in v1.0.2.
Authorizers ship with full CRUD — JWT and Lambda authorizer types, with identitySource stored and returned as an array of strings matching the AWS spec (was incorrectly a single string in the initial cut).
Lambda warm/cold start worker pool
Prior to v1.0.2, every Lambda invocation spawned a fresh subprocess. That's fine for correctness but misses a whole class of bugs that only appear when your handler module is imported once and invoked repeatedly.
core/lambda_runtime.py now maintains a persistent Python subprocess per function. The handler module is imported on first invocation (cold start), and subsequent calls reuse the warm worker without re-importing. Workers respawn automatically on crash.
This matters for three reasons:
- Correctness — module-level state (caches, connection pools, counters) behaves the same as in real Lambda
- Speed — warm invocations are significantly faster in CI
- Bug surface — you'll catch issues with global state that a fresh-subprocess model would mask
import boto3
_client = None
def handler(event, context):
global _client
if _client is None:
_client = boto3.client("s3", endpoint_url="http://localhost:4566",
aws_access_key_id="test", aws_secret_access_key="test",
region_name="us-east-1")
return {"buckets": [b["Name"] for b in _client.list_buckets()["Buckets"]]}
In MiniStack, that _client initialization happens exactly once per function, just like AWS.
SNS → SQS fanout and SNS → Lambda dispatch
sqs protocol subscriptions now deliver messages directly to SQS queues with the standard SNS JSON notification envelope. lambda protocol subscriptions invoke the Lambda function via _execute_function() with a Records[].Sns event structure. Both were stubs in v1.0.1 — SNS→SQS was wired in v1.0.2, SNS→Lambda was a no-op that got fixed in the same release.
sns = client("sns")
sqs = client("sqs")
topic = sns.create_topic(Name="events")["TopicArn"]
queue = sqs.create_queue(QueueName="consumer")
queue_url = queue["QueueUrl"]
queue_arn = sqs.get_queue_attributes(
QueueUrl=queue_url, AttributeNames=["QueueArn"]
)["Attributes"]["QueueArn"]
sns.subscribe(TopicArn=topic, Protocol="sqs", Endpoint=queue_arn)
sns.publish(TopicArn=topic, Message="hello fanout")
msgs = sqs.receive_message(QueueUrl=queue_url)
body = json.loads(msgs["Messages"][0]["Body"])
print(body["Message"]) # hello fanout
Fanout is synchronous within the same process — consistent with how LocalStack handles it and good enough for unit/integration testing.
SQS → Lambda event source mapping
Full ESM lifecycle: CreateEventSourceMapping, DeleteEventSourceMapping, GetEventSourceMapping, ListEventSourceMappings, UpdateEventSourceMapping.
A background poller picks up messages from the configured SQS queue and delivers them to the target Lambda as batched events. Batch size and enabled state are configurable.
lam = client("lambda")
sqs = client("sqs")
queue_url = sqs.create_queue(QueueName="trigger-queue")["QueueUrl"]
queue_arn = sqs.get_queue_attributes(
QueueUrl=queue_url, AttributeNames=["QueueArn"]
)["Attributes"]["QueueArn"]
lam.create_event_source_mapping(
EventSourceArn=queue_arn,
FunctionName="my-processor",
BatchSize=10,
Enabled=True,
)
sqs.send_message(QueueUrl=queue_url, MessageBody=json.dumps({"event": "order_placed"}))
State persistence
PERSIST_STATE=1 enables atomic state snapshots to disk. Writes go to a temp file first, then rename — so a crash mid-write doesn't corrupt the saved state. STATE_DIR controls where (default /tmp/ministack-state).
API Gateway state is fully persisted across container restarts. The persistence framework is designed so other services can adopt the same get_state() / load_persisted_state() pattern incrementally.
The reset endpoint (POST /_ministack/reset) clears in-memory state and, when PERSIST_STATE=1, also deletes STATE_DIR/*.json and S3_DATA_DIR contents — so a restart doesn't reload stale state. Also used in the test suite as a session-scoped autouse fixture to keep tests idempotent across repeated runs.
DynamoDB TTL enforcement
TTL was previously stored — TimeToLiveSpecification accepted and returned without error — but never acted on. Items just sat there forever. A background daemon thread (dynamodb-ttl-reaper) now scans all tables every 60 seconds and deletes items whose TTL attribute value is ≤ current epoch time.
Lambda Function URLs
Full CRUD: CreateFunctionUrlConfig, GetFunctionUrlConfig, UpdateFunctionUrlConfig, DeleteFunctionUrlConfig, ListFunctionUrlConfigs. State persisted in _function_urls. Was a 404 stub before.
SQS queue URL portability (v1.0.4)
QueueUrl values now read MINISTACK_HOST and GATEWAY_PORT env vars instead of hardcoding localhost:4566. This was causing silent failures in CI environments where MiniStack runs as a service container with a network alias.
services:
ministack:
image: nahuelnucera/ministack
environment:
MINISTACK_HOST: ministack
GATEWAY_PORT: 4566
app:
environment:
AWS_ENDPOINT_URL: http://ministack:4566
Before this fix, queue URLs returned http://localhost:4566/... regardless of configuration, and any SQS operation using that URL from another container would fail.
Bug fixes worth calling out
Several of these look fine in smoke tests but break real workloads.
execute-api credential scope misrouted to lambda — In core/router.py, the credential scope for execute-api requests was mapped to lambda instead of apigateway. Any data plane request was dispatched to the Lambda handler instead of the API Gateway data plane. Fixed in v1.0.2.
routeKey always "$default" in Lambda proxy events — The routeKey field was hardcoded to "$default" regardless of which route was matched. If your handler branched on event["routeKey"] — common in single-function routers — every request hit the default branch. Fixed to reflect the actual matched route key (e.g. "GET /users/{id}").
identitySource wrong type on Authorizers — Stored and returned as a plain string instead of an array of strings. boto3 raised a shape validation error on the response. Now returns ["$request.header.Authorization"] as the AWS spec requires.
DeleteFunctionUrlConfig returning {} body on 204 — boto3 expects an empty body on 204. The non-empty body caused a RemoteDisconnected exception. Fixed to return a true empty 204.
/_ministack/reset not cleaning disk — When PERSIST_STATE=1, reset cleared in-memory state but left STATE_DIR/*.json and S3_DATA_DIR intact. On the next restart, old state was reloaded. Fixed to delete persisted files as well.
Test idempotency — Tests with hardcoded resource names failed on a second run because the resources already existed. Fixed by wiring POST /_ministack/reset into a session-scoped autouse fixture in conftest.py.
Test suite: 54 → 379
v1.0.1 shipped with 54 integration tests. v1.0.4 has 379, all passing against both the in-process server and the Docker image.
The growth came from:
- 10 new tests covering previously untested paths: health endpoint, STS
GetSessionToken, DynamoDB TTL, Lambda warm start, API Gateway execute-api Lambda proxy,$defaultcatch-all, path parameter matching, 404 on missing route, EventBridge → Lambda dispatch - 25 new tests for operations added post-v1.0.1: Kinesis shard split/merge, SSM label/tag operations, CloudWatch Logs retention/subscription/metric filters/Insights, CloudWatch composite alarms, EventBridge archives/permissions, DynamoDB
UpdateTable, S3 versioning/encryption/lifecycle/CORS/ACL, Athena workgroup and batch operations - Full coverage of all v1.0.2 features: API Gateway control + data plane, SNS→SQS fanout, SQS→Lambda ESM, Lambda warm start, persistence, reset
Tests run against the Docker image — no mocking, no monkeypatching, real boto3 calls over the wire.
What's still on the roadmap
- API Gateway REST API (v1)
- Cognito — user pools, sign-up/sign-in
- Route53 — hosted zones, record sets
- ACM — certificate management
- Firehose
- Virtual-hosted style S3 (
bucket.localhost:4566routing)
Getting started
docker run -p 4566:4566 nahuelnucera/ministack
# With persistence and Redis sidecar
git clone https://github.com/Nahuel990/ministack
cd ministack
docker compose up -d
curl http://localhost:4566/_localstack/health
Works with boto3, AWS CLI, Terraform, CDK, Pulumi. Point endpoint_url at http://localhost:4566, credentials test/test, and you're done.
GitHub: github.com/Nahuel990/ministack
Docker Hub: hub.docker.com/r/nahuelnucera/ministack
Site: ministack.org
Stars, issues, and PRs welcome. Each service is a single self-contained Python file — adding a new one is straightforward.
Top comments (0)