April 30, 2026 · v1.3.22
1.3.22 closes five gaps that came in from real users in the last 24 hours: the EC2 Instance Metadata Service, a real Cognito PreTokenGeneration Lambda round-trip, browser-based S3 PostObject uploads, end-to-end S3 StorageClass round-tripping, and per-script env vars for init scripts. Three of these (Cognito PreTokenGeneration, S3 PostObject, S3 StorageClass) were filed within hours of each other and ship in the same release because they were all single-day fixes once the AWS contract was nailed down.
A new imds service responds on the gateway port at the standard IMDS URLs: PUT /latest/api/token for the IMDSv2 token, GET /latest/meta-data/iam/security-credentials/ for the role name, GET /latest/meta-data/iam/security-credentials/<role> for the credentials document, plus instance-id, placement/region, placement/availability-zone, and /latest/dynamic/instance-identity/document. The credentials document carries a real ASIA* session-key shape with a SessionToken and 1-hour Expiration, so SDK callers that fall through to the IMDS step of the default credential provider chain (boto3, aws-sdk-go-v2, AWS SDK Java v2) get usable credentials and can sign requests against ministack with no static keys exported.
Both IMDSv1 (token-less GET) and IMDSv2 (PUT-then-GET with X-aws-ec2-metadata-token) are supported. MINISTACK_IMDS_V2_REQUIRED=1 rejects token-less requests, matching real-AWS hop-limit-1 IMDSv2-only instances.
ministack does not bind 169.254.169.254 directly — that link-local address only "just works" on a real EC2 instance because the hypervisor intercepts it; inside a Docker container it is a per-container network-alias concern, not portable. The AWS-blessed path is the SDK escape hatch, which the new service is wired to:
export AWS_EC2_METADATA_SERVICE_ENDPOINT=http://localhost:4566 python -c " import boto3 sess = boto3.Session() # no static keys exported creds = sess.get_credentials() print(creds.method) # 'iam-role' print(creds.access_key[:4]) # 'ASIA' print(bool(creds.token)) # True "
The same env var is honoured by aws-sdk-go-v2, AWS SDK Java v2, and the ec2_metadata_service_endpoint field in ~/.aws/config. Reported by @bimargulies in #434.
Up to 1.3.21, the Cognito access-token claim set in _fake_token was hardcoded; LambdaConfig on a user pool was silently dropped on Create / Update, and the PreTokenGeneration trigger was not invoked. 1.3.22 wires the full AWS round-trip rather than a workaround:
LambdaConfig.PreTokenGenerationConfig (V2_0) and the legacy string LambdaConfig.PreTokenGeneration (V1_0) now persist through CreateUserPool / UpdateUserPool / DescribeUserPool.triggerSource, region, userPoolId, userName, callerContext.{awsSdkVersion, clientId}, request.userAttributes, request.groupConfiguration, request.scopes for V2 / V3, empty response.claimsAndScopeOverrideDetails for the Lambda to fill in).response.claimsAndScopeOverrideDetails.accessTokenGeneration for the access token and idTokenGeneration for the id token. Each section honours claimsToAddOrOverride, claimsToSuppress, and groupOverrideDetails.groupsToOverride; access-token sections also honour scopesToAdd / scopesToSuppress against the scope claim.response.claimsOverrideDetails, id token only.The invocation re-uses the existing _resolve_name_and_qualifier → _get_func_record_for_qualifier → _execute_function chain in lambda_svc.py, so PreTokenGeneration runs through the same Python / Node / Docker / provided.al2023 executor pool as any other Lambda. By default a Lambda invocation that errors out is logged with a warning and the unmodified token is issued, which keeps local-dev auth flows working when an unrelated Lambda is broken; MINISTACK_COGNITO_PRETOKEN_STRICT=1 fails closed the way real AWS does.
Reported by @aahoughton in #533.
A POST to /<bucket>/ with multipart/form-data is now handled. The handler honours key with ${filename} substitution from the file part, Content-Type, x-amz-meta-*, x-amz-storage-class, x-amz-tagging, the object-lock headers, success_action_status (200, 201, or 204; default 204), and success_action_redirect (303 with bucket=, key=, etag= appended). On 201, returns the <PostResponse> XML with Location / Bucket / Key / ETag. Versioning, persistence, multi-tenancy, and s3:ObjectCreated:Post event notifications all flow through the same path as PutObject.
The content-length-range policy condition is enforced — uploads under the minimum return EntityTooSmall (400) and uploads over the maximum return EntityTooLarge (400), matching the AWS error codes. Other policy conditions and the signature field are accepted but not validated, the same lenient stance ministack takes for presigned-URL signing.
boto3's generate_presigned_post works end to end:
import boto3, requests
s3 = boto3.client("s3", endpoint_url="http://localhost:4566", region_name="us-east-1",
aws_access_key_id="test", aws_secret_access_key="test")
s3.create_bucket(Bucket="uploads")
post = s3.generate_presigned_post(
Bucket="uploads",
Key="${filename}",
Conditions=[["content-length-range", 0, 5_000_000]],
)
r = requests.post(post["url"], data=post["fields"],
files={"file": ("photo.png", open("photo.png", "rb").read())})
# r.status_code == 204; ETag + Location in r.headers
Requested by @mattburton in #535.
StorageClass: round-trips end to endObjects written with StorageClass=GLACIER, INTELLIGENT_TIERING, or any other AWS-spec value were previously coming back from GetObject, HeadObject, ListObjects(V2), and ListObjectVersions as STANDARD. The header is now stored on the object record and emitted on the wire (header omitted for the default STANDARD, matching AWS). Same propagation through CopyObject (with optional override via x-amz-storage-class on the request) and through CreateMultipartUpload → CompleteMultipartUpload. The S3 persistence sidecar (.meta.json) carries storage_class too, so an S3_PERSIST=1 restart preserves the class. Unknown values now return InvalidStorageClass (400). Verified one-to-one against botocore/data/s3/2006-03-01/service-2.json. Reported by @JoeHale in #534.
Every .sh / .py run from /docker-entrypoint-initaws.d (boot phase) or /docker-entrypoint-initaws.d/ready.d (ready phase, plus the /etc/localstack/init/{boot,ready}.d compatibility paths) now sees MINISTACK_INIT_SCRIPT_DIR and MINISTACK_INIT_SCRIPT_PATH for the running script, plus MINISTACK_INIT_BOOT_DIR in boot scripts and MINISTACK_INIT_READY_DIR in ready scripts. Scripts can reference sibling files without hardcoding the mount path:
#!/usr/bin/env bash
set -eo pipefail
aws s3 mb s3://my-bucket-local
aws s3 cp "${MINISTACK_INIT_SCRIPT_DIR}/seed-data.json" s3://my-bucket-local/
The per-script variant changes per file, so the pattern keeps working when scripts are organised into subfolders. The phase-level vars are still there for cases where a script genuinely wants the root of the directory tree. Requested by @andreluiznsilva in #520.
# Regular image (Alpine) docker pull ministackorg/ministack:1.3.22 # Full image (Debian + DuckDB + psycopg2 + pymysql) docker pull ministackorg/ministack:1.3.22-full # pip pip install -U ministack
Full changelog: CHANGELOG.md.
Shipped by the MiniStack community. Contributions credited throughout. GitHub · r/ministack