A reusable AWS SAM template that deploys a static website (apex + optional
www) behind CloudFront with HTTPS, a custom domain, and a private S3
origin — using S3 + CloudFront + ACM + Route 53. No servers, no OAI (the
deprecated predecessor to Origin Access Control), no click-ops.
The template itself is fully parameterized — clone it, point it at your own domain, and deploy. See docs/ARCHITECTURE.md for the diagram, the OAC circular-dependency fix, and the reasoning behind each parameter and condition. See docs/CUTOVER-GOTCHAS.md for a real DNS/alias gotcha hit while cutting a domain over onto a distribution shaped like this one.
| This repo (SAM) | CDK | Terraform | Click-ops | |
|---|---|---|---|---|
| Source of truth | One YAML file, readable top to bottom | TypeScript/Python that generates CloudFormation | HCL, own state file | Console clicks, undocumented |
| Drift detection | Native (aws cloudformation detect-stack-drift) |
Same (compiles to CloudFormation) | Requires terraform plan + state file discipline |
None |
| Extra tooling | AWS CLI + SAM CLI only | Node/Python + CDK CLI | Terraform CLI + a state backend | None, which is the problem |
| Best for | A small, forkable reference stack like this one | Large multi-stack apps with real programming-language logic | Multi-cloud or org-wide infra-as-code | Never, for anything you want to reproduce |
For a single-purpose stack like this — one bucket, one distribution, one
cert, a handful of DNS records — a plain CloudFormation/SAM template is the
least machinery that still gives you repeatability, drift detection, and a
one-command sam delete teardown. CDK and Terraform both earn their
overhead at larger scale; this isn't that.
- An AWS account, and the AWS SAM CLI installed
(
sam --version; see AWS's install docs). - A domain you own, with an existing Route 53 public hosted zone for it.
This template does not create the hosted zone — bring your own.
- If your domain is registered elsewhere (e.g. Namecheap) and you're creating the hosted zone in Route 53 for the first time: create the hosted zone first, note the four NS records Route 53 gives you, then log into your registrar (Namecheap, etc.) and replace its default nameservers with those four. NS propagation can take anywhere from minutes to ~48 hours. ACM's DNS validation (below) will not succeed until the NS swap has propagated.
- If the zone is already delegated to Route 53, skip this step.
Lint the template before deploying or tagging a release:
bash tests/validate.shThis runs sam validate --lint and exits non-zero on any error. (See
"Verify" below for post-deploy checks against the live stack.)
This template must be deployed to us-east-1. CloudFront only accepts ACM
certificates issued in us-east-1, and this template creates the
certificate in whatever region you deploy the stack to — deploying elsewhere
will fail fast against a template Rules check rather than failing later
during certificate creation.
sam build
sam deploy --guided--guided will prompt for:
| Parameter | Example | Notes |
|---|---|---|
SourceDomain |
example.com |
apex domain |
HostedZoneId |
Z0123456789ABCDEFGHIJ (find via aws route53 list-hosted-zones) |
must already exist |
IncludeWww |
true |
also serves www.example.com |
IndexDocument |
index.html |
default root object |
ErrorDocument |
error.html |
served on a missing object |
ErrorResponseCode |
404 |
set to 200 (with ErrorDocument=index.html) only for a single-page app that handles routing client-side |
Confirm the region prompt is us-east-1. Answers are saved to
samconfig.toml (gitignored — see samconfig.toml.example for the format if
you'd rather write it by hand and skip --guided on subsequent deploys).
The stack takes a few minutes, mostly waiting on ACM DNS validation and CloudFront distribution propagation.
sam deploy provisions infrastructure only — it does not upload site/ to
S3 (see docs/ARCHITECTURE.md
for why). After the stack is up:
# Grab the bucket name and distribution ID from stack outputs
BUCKET=$(aws cloudformation describe-stacks --stack-name aws-sam-static-website \
--query "Stacks[0].Outputs[?OutputKey=='WebsiteBucketName'].OutputValue" --output text)
DIST_ID=$(aws cloudformation describe-stacks --stack-name aws-sam-static-website \
--query "Stacks[0].Outputs[?OutputKey=='DistributionId'].OutputValue" --output text)
# Upload content
aws s3 sync site/ "s3://$BUCKET/" --delete
# Invalidate the edge cache so the new content is served immediately
aws cloudfront create-invalidation --distribution-id "$DIST_ID" --paths "/*"Repeat this two-command sequence any time site content changes — no
sam deploy required unless you're changing infrastructure.
Once the stack is CREATE_COMPLETE and DNS has propagated:
# DNS: confirm the apex and www resolve to a CloudFront edge
dig +short sourcedomain.com
dig +short www.sourcedomain.com
# HTTP: confirm HTTPS, HTTP/2, and a 200
curl -I https://sourcedomain.com
# TLS: confirm the cert covers apex + www and isn't expired
curl -vI https://sourcedomain.com 2>&1 | grep -A2 "subject:\|expire"You should see HTTP/2 200. If DNS hasn't propagated yet but you want to
confirm the distribution itself is working, curl -I the
DistributionDomainName stack output directly (e.g.
https://d123abcxyz.cloudfront.net) — note this will 403 unless the
domain's alias is already attached, since the origin only serves the
aliased hostnames' cert.
Roughly $0.50–1/month for a low-traffic site:
- Route 53 hosted zone: $0.50/month (if you don't already have one for other records in this zone).
- S3: storage + requests for a small static site are pennies.
- CloudFront:
PriceClass_100data transfer/requests — negligible at personal-site volume, largely covered by the always-free tier. - ACM certificate: free.
If you already have a Route 53 hosted zone for this domain (e.g. it also hosts email records), this stack adds close to nothing to your bill.
# Empty the bucket first - CloudFormation will not delete a non-empty bucket
BUCKET=$(aws cloudformation describe-stacks --stack-name aws-sam-static-website \
--query "Stacks[0].Outputs[?OutputKey=='WebsiteBucketName'].OutputValue" --output text)
aws s3 rm "s3://$BUCKET/" --recursive
sam deleteThis removes the CloudFront distribution, S3 bucket, ACM certificate, and the four Route 53 record sets this stack created. It does not delete the Route 53 hosted zone itself (BYO, not owned by this stack).
This template only deploys to us-east-1 — see "Deploy" above and
docs/ARCHITECTURE.md for why.
No secrets, ARNs, or account IDs are committed. samconfig.toml (which
would contain your real HostedZoneId and domain names) is gitignored —
samconfig.toml.example shows the format with placeholder values.