Start a fresh LocalStack 63,725 with one addition: its gateway answers CORS checks itself and rejects unknown origins, so the website's origin goes into EXTRA_CORS_ALLOWED_ORIGINS for Full Stack Deploy's API calls:
SITE=http://booknest-web-000000000000.s3-website.localhost.localstack.cloud:31566
docker rm -f -v l1-localstack >/dev/null
docker run -d --name l1-localstack -p 127.0.0.1:31566:4566 \
-e MAIN_CONTAINER_NAME=l1-localstack -e EXTRA_CORS_ALLOWED_ORIGINS=$SITE \
-v /var/run/docker.sock:/var/run/docker.sock localstack/localstack:4.13.1 >/dev/null
until curl -fs localhost:31566/_localstack/health >/dev/null; do sleep 2; done
cd ~/v5-ch7/booknest && cfn-lint infra/booknest-web.yaml
aws cloudformation deploy --stack-name booknest-web --template-file infra/booknest-web.yaml \
--tags app=booknest | tail -1Successfully created/updated stack - booknest-web
Then ask EC2 24 and S3 24 themselves, not CloudFormation 24 :
VPC=$(aws ec2 describe-vpcs --filters Name=tag:Name,Values=booknest-vpc \
--query 'Vpcs[0].VpcId' --output text)
aws ec2 describe-route-tables --filters Name=vpc-id,Values=$VPC --output text \
--query 'RouteTables[].Routes[].[DestinationCidrBlock || DestinationPrefixListId,GatewayId]'
aws ec2 describe-security-groups --filters Name=vpc-id,Values=$VPC --output text --query \
'SecurityGroups[].[Description,length(IpPermissions),IpPermissionsEgress[0].IpProtocol]'
aws s3api get-public-access-block --bucket booknest-web-000000000000 --output text10.20.0.0/16 local 10.20.0.0/16 local 0.0.0.0/0 None 10.20.0.0/16 local pl-22fbdba0f346abeb7 vpce-052978e89a1b23b34 default VPC security group 0 -1 API functions, nothing in, HTTPS out only 0 -1 Load balancer tier, HTTPS in from anywhere 0 -1 PUBLICACCESSBLOCKCONFIGURATION True True True True
The route tables and the endpoint route are real (the first local route is the main table's). Three things differ from the template: the default route has no target (4.13.1's route provider drops GatewayId), the web group has no ingress rule, and every group keeps allow-all egress (-1). The public access switches are all on, yet anonymous reads work below. On AWS 24 all three match the template. Upload a static snapshot and fetch it:
jq .books db/seed.json > web/books.json
echo '{"booksUrl": "books.json"}' > web/config.json
aws s3 sync web/ s3://booknest-web-000000000000/ --only-show-errors
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' $SITE/
curl -s -w ' %{http_code}\n' $SITE/no-such-page200 text/html <!doctype html><title>Not found</title><h1>BookNest: page not found</h1> 404
A missing key returns the error document with status 404. Headless Chrome 1 shows the page:

The generated web/books.json and web/config.json are git-ignored; the commit adds the template and the two HTML files.