A minimal reproducible repository for 15 bugs in
fakecloud. It is a hello-world SPA on
CloudFront + S3, deployed with CDK to a local fakecloud rather than real AWS -
the smallest stack that hits every one of them. Run the deploy below against an
unpatched fakecloud and it fails; against release/v0.44.11 it serves the site.
Each bug and the branch that fixes it is listed under
The 15 bugs.
app.js CDK app: S3 bucket, CloudFront distribution, ACM certificate
web/index.html the SPA (no build step)
stack.test.js proves the deployed stack and every fix below (npm test)
docker-compose.yml fakecloud, built from a local source checkout
cdk.json { "app": "node app.js" }
- Docker (fakecloud runs in a container, and runs Lambda in sibling containers)
- Node 20+ and the AWS CLI
- A checkout of fakecloud at
../../github/fakecloud, onrelease/v0.44.11
The compose file builds the branch that checkout has open rather than
pulling the published image, because this stack depends on fixes that are not
released yet - so the branch you leave it on is the image you get.
Point build.context at your own checkout if it lives elsewhere, or switch back
to image: ghcr.io/faiscadev/fakecloud:latest once those land.
Getting cdk deploy to work end to end turned up 15 bugs in fakecloud. Each is
listed below with its one-line cause and the branch that fixes it - a branch off
main with a regression test, carrying that fix alone bar the two SSE-KMS
ones, which share a branch because they share a root cause. release/v0.44.11
carries all 15, plus feat/cfn-response-url-internal below, and is what the
compose file builds. The 15th branches off release/v0.44.11 rather than
main, because it builds on two of the others. None are upstream yet.
The first 14 are rebased onto current upstream main and re-checked there.
Eight touch no file upstream has changed since, so their code is byte-identical
and the bug is untouched. The other six overlap on extras.rs, lib.rs or
the docs page, and each was confirmed still broken at the point it fixes: the
DescribeChangeSet stub is still there, fetch_template_from_url still reads
stored bytes without decrypting, and ResponseURL and the
custom_resource_response module do not exist on main at all.
The commit message on each branch has the full explanation and the evidence.
Nothing served the site.
- CloudFront proxied S3 REST origins to real AWS, because a bucket REST endpoint
resolves in real DNS, so the distribution never served the bucket at all.
Fix:
fix/cloudfront-s3-origin-resolution DefaultRootObjectwas parsed and never read, so/reached the origin as/and returned the bucket'sListBucketResultXML instead of the SPA shell. Fix:fix/cloudfront-default-root-object- A bucket with no
BucketNametook the CFN logical id as its physical name, but CDK ids are mixed case and bucket names are not, so virtual-hosted 404'd on objects the bucket visibly held. Fix:fix/cfn-s3-bucket-physical-name CustomErrorResponseswere dropped in provisioning: CFN typesResponseCodeas an integer and the CloudFront API as a string, so every rule failed to deserialize, andErrorCachingMinTTLwas mis-spelled byrename_all. The SPA had no deep-link fallback. Fix:fix/cfn-cloudfront-custom-error-responses
Nothing uploaded the assets. All four broke BucketDeployment, the Lambda
custom resource that copies web/ into the bucket.
- Inline
Code.ZipFileis source text in CFN but an archive in the Lambda API; the provisioner stored the raw text where downstream expects a deployment package. Fix:fix/cfn-lambda-inline-zipfile - Objects in an SSE-KMS bucket are stored as a KMS envelope, and only S3's own
read paths unwrapped it. Every internal reader got the envelope, including the
provisioner hydrating Lambda code from S3.
Fix:
fix/s3-sse-kms-internal-readers - The same envelope reached
fetch_template_from_url. It is base64 ASCII, so it survived UTF-8 decoding and parsed as a template with no resources: the stack reported CREATE_COMPLETE having provisioned nothing. Fix:fix/s3-sse-kms-internal-readers(same branch, second commit) - Function containers got no AWS environment - no region, no credentials - so an
SDK client inside a handler failed before it reached an endpoint, and
BucketDeploymentreported success having copied no files. Fix:fix/lambda-container-aws-endpoint
Custom resources could not report their outcome. Fixing the first of these turned silently-broken deploys into visibly failing ones, which is what exposed the rest.
- A handler that raises returns 200 with the error in the body. The response was
discarded, so a custom resource that did nothing still reached
CREATE_COMPLETE.
Fix:
fix/cfn-custom-resource-failure - The event carried no
ResponseURL, socfn-response- which every CDK handler uses - threw before it could signal. Fix:feat/cfn-custom-resource-response-url - CDK's handler calls
https.requestand builds it from the hostname with no port, so Node defaults to 443 whatever URL we send. The endpoint has to be TLS on 443. Fix:feat/cfn-response-url-tls - That certificate is self-signed, and handlers were told to skip verification
with
NODE_TLS_REJECT_UNAUTHORIZEDandPYTHONHTTPSVERIFY. The Lambda python3.13 runtime ignores the latter, so Python handlers still failed withCERTIFICATE_VERIFY_FAILED. They now get fakecloud's CA instead. Fix:feat/lambda-ca-bundle - CFN stringifies every scalar in
ResourcePropertiesbefore the handler sees it. fakecloud passed real JSON types, soprops.get('Prune', 'true').lower()raised'bool' object has no attribute 'lower'. Fix:fix/cfn-custom-resource-property-strings - None of the above reached
cdk deploy.create_custom_resourcehas two arms, and the changeset path takes the one that queues the invoke and discards its result, so a handler that raised still produced a CREATE_COMPLETE stack. The same gap wrote the terminal status before the queued invokes had run at all, soBucketDeploymentreported success with the bucket still empty -cdk deployreturned 4.7s beforeindex.htmlappeared. Real CloudFormation holds a custom resource CREATE_IN_PROGRESS until it signals. Fix:fix/cfn-changeset-custom-resource-outcomeFound 2026-09-10 bystack.test.js, after the other 14 were already fixed: with a Node handler that throws,CreateStackgave CREATE_FAILED andExecuteChangeSetgave CREATE_COMPLETE. Deploy time went 0.1s -> 10.1s because it now genuinely waits.
cdk deploy and cdk bootstrap hung forever against a stack that already
existed. Before creating a change set the CDK CLI deletes any leftover one and
polls DescribeChangeSet until it 404s. On a miss fakecloud fabricated
CREATE_COMPLETE, so the change set was never gone and the poll never ended.
Fix: fix/cfn-describe-change-set-not-found
Verified fixed 2026-09-10 against the image the compose file builds: with
CDKToolkit and SpaStack already deployed, DescribeChangeSet returns
ChangeSetNotFoundException and make up re-bootstraps and re-deploys in 1.5s.
The Makefile no longer guards npm run bootstrap behind a describe-stacks check.
- Nothing was wrong with reaching the
ResponseURLthrough the host, but it meant fakecloud had to own the host's 443, which is impossible on a machine where anything else already holds it. Function containers now join fakecloud's own network (FAKECLOUD_LAMBDA_NETWORK) and reach the endpoint on its container port instead, so nothing is published. Fix:feat/cfn-response-url-internal
cargo test --workspaceis red on upstreammain:fakecloud-e2e --test apigwv2_websocketfails two tests with$connect lambda should have fired. Reproduced on an unmodified checkout, so it is not ours. Everything else passes.conformance-baseline.jsonrecords cloudformation at 3426 variants; the probe reports 3434, with or without any of these branches. The baseline is stale.PYTHONHTTPSVERIFY=0does nothing in the Lambda python3.13 runtime. It looks like it should work, which is whyfeat/lambda-ca-bundleexists.- A port mapping like
8443:443can never work: the handler dials the host gateway with no port at all, so only the host's own443is reachable. That is whatfeat/cfn-response-url-internalsidesteps rather than fixes. cloudformation_custom_resource_invokes_lambdais red onrelease/v0.44.11.fix/cfn-custom-resource-property-stringsstringifies every scalar but never updated that e2e test, which still assertsResourceProperties.Count == 42and now gets"42". Confirmed failing on an otherwise-unmodified checkout of that branch. A one-line test fix, and it belongs on that branch rather than a new one.- A custom-resource handler killed by the Lambda timeout reads as success. The
killed runtime returns no parseable error body, and
fix/cfn-custom-resource-failuredeliberately does not invent a failure from one. Real CloudFormation fails the stack whenServiceTimeoutexpires. The probes instack.test.jsset an explicitTimeoutto stay clear of it.
docker compose up -d --wait
export AWS_ENDPOINT_URL=http://localhost:4566 \
AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test \
AWS_DEFAULT_REGION=us-east-1
npx cdk bootstrap aws://123456789012/us-east-1
npm run deployThe stack uploads web/ itself; no manual sync is needed.
npm test (stack.test.js) asserts every fix above against the running
emulator - each test names the branch it proves. Measured 2026-09-10:
| image built from | result |
|---|---|
release/v0.44.11 |
12 pass |
release/v0.44.11~1 (the first 14) |
7 fail |
ghcr.io/faiscadev/fakecloud:latest |
11 fail |
Without the 15th fix the six asset and serving tests fail alongside the
changeset one: the deploy returns before BucketDeployment has copied web/,
so the bucket is still empty when the suite reads it. Nothing here waits for
the assets - a wait would hide exactly that.
The distribution answers on fakecloud's main port, routed by the Host header -
there is no separate port to hit:
curl -H "Host: fakecloud-web.localhost" http://localhost:4566/
curl -H "Host: fakecloud-web.localhost" http://localhost:4566/about # deep linkFor a browser, add the domain to /etc/hosts pointing at 127.0.0.1 and visit
http://fakecloud-web.localhost:4566/, so the Host header comes from the URL
and client-side routing works normally.
To reach it by the distribution's own domain instead:
DOMAIN=$(curl -s http://localhost:4566/_fakecloud/cloudfront/distributions \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["distributions"][0]["domainName"])')
curl -H "Host: $DOMAIN" http://localhost:4566/CloudFormation custom resources report success or failure by HTTPS PUT to the
ResponseURL on their event, and fakecloud serves that endpoint over TLS on
443. The port is not negotiable for CDK's Node handlers: cfn-response builds
the request from the URL's hostname with no port, so Node defaults to 443
whatever the URL says. CDK's Python handler does honour the whole URL
(urlopen(Request(responseUrl))), so BucketDeployment alone would be happy on
any port; autoDeleteObjects is the one that pins it.
That 443 used to have to be the host's, published from the container, because
a function container could only reach fakecloud through the host - and on a
machine where anything else already holds 443, it simply could not be given.
FAKECLOUD_LAMBDA_NETWORK in the compose file removes that: it attaches the
function containers to fakecloud's own network, where they reach its container
port 443 directly by DNS. Nothing is published, and the host's 443 is free.
Fix: feat/cfn-response-url-internal
cdk bootstrap needs none of this - the CDKToolkit stack has no custom
resources.
fakecloud-web.localhost cannot be validated or resolved on real AWS, and
CloudFront requires a certificate covering every alternate domain name. To
deploy for real, swap DOMAIN in app.js for a domain you own and use
CertificateValidation.fromDns(zone) with its hosted zone.
npm run destroy # or: docker compose down, which discards all fakecloud state