Deploy a secure, scalable static website using S3, CloudFront, and Route53 in minutes.
- Fast Setup: Deploy a production-ready static website in under 10 minutes.
- Cost-Effective: Leverages AWS Free Tier-eligible services where possible.
- Secure: Private S3 bucket (Origin Access Control + Public Access Block + encryption + TLS-only), HTTPS enforced (TLS 1.2+), and security response headers.
- Works for SPAs and prerendered sites: pick
spaorstatic; each gets the routing behavior it actually needs. - Customizable: Parameters for CSP, HSTS, TLS policy, price class, and an optional WAF.
graph TD
U[User] --> A
A[Browser/Mobile] -->|DNS Request| B(Route53)
B -->|DNS Resolution| A
A -->|HTTPS Request| C(CloudFront)
C -->|Fetch Static Files ~ OAC/SigV4| D(S3 Bucket ~ private)
D -->|Static Content| C
C -->|Deliver Content ~ HTTPS| A
A -->|HTTP Request| E[HTTPS Enforced]
E -->|Redirect| A
- An AWS account and a named AWS CLI profile
- A Route53 public hosted zone for your domain
aws,jq, andbash. On macOS:brew install awscli jq
# 1. Certificate (always us-east-1 - the script enforces this)
./create-certificate.sh # or ./create-certificate-with-wildcard.sh
# 2. Website stack (any region - put it near your users)
./create-static-website.sh
# 3. Upload your site (the previous step prints this exact command)
./deploy-content.sh ./dist <bucket> <distribution-id> <profile> <region>Each script prompts for what it needs, looks up the hosted zone itself, validates your input against AWS, and shows a summary before doing anything.
aws cloudformation deploy \
--stack-name example-com-certificate \
--template-file certificate-with-wildcard.yml \
--parameter-overrides DomainName=example.com HostedZoneId=Z1UVA2VESUQ1UN \
--region us-east-1 --profile example
aws cloudformation deploy \
--stack-name example-com-static-website \
--template-file static-website.yml \
--parameter-overrides \
AppDomainName=www.example.com \
HostedZoneId=Z1UVA2VESUQ1UN \
CertificateARN=arn:aws:acm:us-east-1:123456789012:certificate/00000000-0000-0000-0000-000000000000 \
SiteType=static \
--no-fail-on-empty-changeset \
--region us-west-2 --profile exampleTo find your hosted zone ID by hand:
aws route53 list-hosted-zones-by-name --profile=example |
jq -r '.HostedZones[] | select(.Name=="example.com.") | .Id'spa |
static |
|
|---|---|---|
| For | React/Vue/Svelte with a client-side router | Hugo, Jekyll, Astro, plain HTML |
| Missing page | serves /index.html with 200 |
returns a real 404 |
/about/ |
handled by your router | rewritten to /about/index.html |
static adds a CloudFront Function that appends index.html to URLs ending in
/. It deliberately does not rewrite extensionless URLs, so files like
/LICENSE, /robots and /CNAME are still served as themselves.
static mode rewrites only URIs ending in a slash, so /reference/ works
but /reference 404s. List such routes in DirectoryRedirects to have them 301
to the canonical trailing-slash URL:
--parameter-overrides SiteType=static DirectoryRedirects=/reference,/guideThere is deliberately no "redirect anything without a dot" rule — that would
break legitimate extensionless objects like /LICENSE, /robots or /CNAME.
Both modes grant CloudFront s3:ListBucket. Without it S3 answers 403 for a
missing key rather than 404, so a static site could never return a real 404 and a
genuine permissions failure would be indistinguishable from a typo'd URL.
| Parameter | Default | Notes |
|---|---|---|
AppDomainName |
— | The site's FQDN, e.g. www.example.com |
HostedZoneId |
— | Route53 zone that owns the domain |
CertificateARN |
— | ACM cert, must be us-east-1 |
SiteType |
spa |
spa or static — see above |
DirectoryRedirects |
(empty) | Exact paths to 301 to their trailing-slash form, e.g. /reference |
PriceClass |
PriceClass_100 |
Cost decision; _All for global reach |
MinimumProtocolVersion |
TLSv1.2_2025 |
TLSv1.3_2025 drops TLS 1.2 entirely |
ContentSecurityPolicy |
restrictive subset | Empty string omits the header |
PermissionsPolicy |
(empty) | e.g. camera=(), microphone=() |
HstsIncludeSubdomains |
false |
Read the HSTS note below |
HstsPreload |
false |
Read the HSTS note below |
WebACLArn |
(empty) | From waf-us-east-1.yml, if you want one |
ProjectTag |
(empty) | Adds a Project tag to billable resources |
BucketName |
(empty) | Explicit bucket name. Empty = CloudFormation generates one — see below |
The bucket is fully private: Public Access Block (all four flags), ACLs
disabled (BucketOwnerEnforced), default SSE-S3 encryption, versioning on, and a
bucket policy that denies all non-TLS requests. CloudFront is the only reader,
via Origin Access Control scoped by AWS:SourceArn to this distribution
alone. CloudFront enforces HTTPS (redirect-to-https, TLS 1.2+, sni-only) and
attaches a response-headers policy.
X-XSS-Protection is deliberately not sent — it is deprecated, modern
browsers ignore it, and the legacy auditor it enabled introduced its own
vulnerabilities.
base-uri 'self'; object-src 'none'; frame-ancestors 'none' restricts nothing
about scripts, styles, or images, so it is safe for the large majority of static
sites. It will break a site that uses a cross-origin <base>, serves
<object>/<embed> content, or expects to be embedded in a partner's iframe.
Override the ContentSecurityPolicy parameter, or set it to "" to omit it.
Both HSTS options default to false on purpose.
HstsIncludeSubdomainsis the one that bites. On an apex domain it forces HTTPS on every subdomain — including internal hosts that may have no certificate — from the moment a browser sees the header.HstsPreloaddoes nothing on its own; someone must submit the domain at hstspreload.org. Removing the directive later does not remove a domain browsers have already shipped in their preload list.
The max-age is two years, which is what preload submission requires.
Access logging and WAF live in separate stacks (see below). There is no origin failover and no geo restriction.
By default CloudFormation generates the bucket name, because a globally-unique
name someone else already holds fails in a way you cannot fix. Set BucketName
only when a predictable name genuinely matters:
--parameter-overrides BucketName=example-com-siteAvoid dots. The S3 virtual-hosted-style wildcard certificate
(*.s3.<region>.amazonaws.com) matches only single-label bucket names, so
site.example.com.s3.us-west-2.amazonaws.com may fail TLS validation on the OAC
origin connection. Use site-example-com instead — with OAC the bucket name is
never user-visible anyway.
The bucket uses DeletionPolicy: RetainExceptOnCreate, so a failed create does
not orphan the bucket (which would make every retry fail on the now-taken name),
while UpdateReplacePolicy: Retain still protects live content.
To make a BucketName change fail rather than silently replace the bucket
with an empty one, apply the included stack policy once after deploying:
aws cloudformation set-stack-policy --stack-name <stack> \
--stack-policy-body file://stack-policy-protect-bucket.json --profile exampleaws cloudformation deploy has no stack-policy flag, so this is a separate call.
It persists across later deploys. A deliberate rename can still be done with a
one-time --stack-policy-during-update-body override on update-stack.
deploy-content.sh classifies files by extension: HTML/JSON/XML/TXT are treated
as mutable entry points, revalidated on every request and removed when the
build stops emitting them.
That is wrong for anything published under a URL that encodes a version and is
pinned by third parties — an API spec at /v/8665/openapi.json, for instance.
It is a .json file, so the mutable rules would serve it max-age=0 and delete
it the moment the version bumps, breaking every consumer who pinned it.
Paths listed in PROTECTED_PREFIXES (default v/) are instead uploaded
immutable, never deleted, and never invalidated:
PROTECTED_PREFIXES="v/ releases/" ./deploy-content.sh ./dist <bucket> <dist-id>URL semantics, not file extension, decide cache and delete policy.
github-oidc-deploy-role.yml creates a role a GitHub Actions workflow can assume
with no long-lived AWS keys. It can publish to one bucket and invalidate one
distribution — nothing else.
aws cloudformation deploy --stack-name example-com-deploy-role \
--template-file github-oidc-deploy-role.yml \
--parameter-overrides GitHubOrg=myorg GitHubRepo=my-site \
BucketName=example-com-site DistributionId=E123ABC \
RoleName=example-com-gha-deploy \
--capabilities CAPABILITY_NAMED_IAM --profile exampleIt assumes the GitHub OIDC provider already exists in the account — that provider is account-global and creating a second one errors. Check first:
aws iam list-open-id-connect-providers \
--query "OpenIDConnectProviderList[?contains(Arn,'githubusercontent')]"Some GitHub orgs issue immutable subject claims, which embed numeric org and repo IDs so a deleted-and-recreated repo cannot inherit the old trust:
repo:myorg@19309466/myrepo@1342119138:ref:refs/heads/main
rather than the classic repo:myorg/myrepo:ref:refs/heads/main. A trust policy
written for the classic form silently fails to match, and the only symptom is an
opaque Not authorized to perform sts:AssumeRoleWithWebIdentity.
Check which form your repo issues before deploying:
gh api /repos/OWNER/REPO/actions/oidc/customization/sub --jq .sub_claim_prefixIf it comes back with @-suffixed IDs, pass them:
--parameter-overrides ... \
GitHubOrgId=$(gh api /repos/OWNER/REPO --jq '.owner.id') \
GitHubRepoId=$(gh api /repos/OWNER/REPO --jq '.id')The TrustedSubject stack output always shows the exact subject the policy
expects — compare it against the token if assumption fails.
Trust is pinned to one exact ref (GitHubRef, default refs/heads/main) with
StringEquals, and the parameter rejects wildcards. repo:org/repo:* would let
any branch — including one pushed by anyone with write access — publish to
production. Because any workflow on the trusted branch can assume the role,
branch protection is part of this security boundary, not separate from it.
In the workflow, grant id-token: write and pass the role ARN:
permissions:
id-token: write
contents: readPoints an old hostname at a new URL with a 301, keeping bookmarks, inbound links and search results working. It serves no content — a viewer-request CloudFront Function answers every request, so the origin is never reached.
aws cloudformation deploy --stack-name old-example-com-redirect \
--template-file redirect-site.yml \
--parameter-overrides \
SourceDomain=old.example.com \
RedirectTarget='https://new.example.com/#' \
CertificateARN=arn:aws:acm:us-east-1:123456789012:certificate/... \
RedirectStatus=302 \
--profile exampleStart with RedirectStatus=302. Browsers cache a 301 aggressively and stop
re-requesting the old URL, so a wrong target is effectively permanent for anyone
who already followed it. Switch to 301 once verified.
End the target with # unless you want old fragments carried over. With no
fragment in the Location header, browsers append the original one, so
old.example.com/#/deep/link lands as https://new.example.com/#/deep/link —
which can confuse the new site's own hash routing. A trailing # is an empty
fragment and discards it.
ViewerProtocolPolicy is allow-all, not redirect-to-https. CloudFront
applies the protocol redirect before running the viewer-request function, so
redirect-to-https would cost an HTTP visitor two hops — http→https on the old
host, then on to the new site. Retired hostnames are exactly where inbound links
are still http://. With allow-all the function answers both schemes in one
hop and always redirects to an https:// destination, so nothing is served over
plaintext either way.
The origin is example.invalid. A distribution requires an origin even when
one is never contacted. .invalid is reserved by RFC 2606 and can never be
registered, so this fails closed: if the function association were ever
removed, the origin fetch errors rather than reaching a host somebody else
controls.
The hostname being retired usually already resolves, often via a CNAME the stack
does not own. AWS::Route53::RecordSetGroup will neither adopt nor delete a
pre-existing record: it would try to create the alias alongside the CNAME and
fail with InvalidChangeBatch, because Route53 will not hold a CNAME and an A
record at the same name.
Deploy the stack, test against the distribution's own *.cloudfront.net name,
then cut over with a single change batch — Route53 applies one atomically:
aws route53 change-resource-record-sets --hosted-zone-id ZXXXXXXXXXXXX --profile example \
--change-batch '{"Changes":[
{"Action":"DELETE","ResourceRecordSet":{
"Name":"old.example.com.","Type":"CNAME","TTL":300,
"ResourceRecords":[{"Value":"<exact current value>"}]}},
{"Action":"CREATE","ResourceRecordSet":{
"Name":"old.example.com.","Type":"A",
"AliasTarget":{"HostedZoneId":"Z2FDTNDATAQYW2","DNSName":"dXXXX.cloudfront.net.","EvaluateTargetHealth":false}}},
{"Action":"CREATE","ResourceRecordSet":{
"Name":"old.example.com.","Type":"AAAA",
"AliasTarget":{"HostedZoneId":"Z2FDTNDATAQYW2","DNSName":"dXXXX.cloudfront.net.","EvaluateTargetHealth":false}}}
]}'The DELETE must match the existing record's name, type, TTL and value exactly or the whole batch is rejected. Check the old record's TTL first — it governs how long stragglers keep hitting the old target.
aws cloudformation deploy --stack-name example-com-logs \
--template-file logging-us-east-1.yml \
--parameter-overrides DistributionArn=<DistributionArn output> \
--region us-east-1 --profile exampleUses CloudFront standard logging v2, which grants access by bucket policy rather than ACLs — ACLs are disabled by default on buckets created since April 2023. Logs go to a dedicated bucket, never the origin bucket.
aws cloudformation deploy --stack-name example-com-waf \
--template-file waf-us-east-1.yml \
--region us-east-1 --profile example
# then redeploy the website stack with WebACLArn=<WebACLArn output>Consider whether you need this. On a GET/HEAD-only static site with an S3 origin, the AWS managed rule groups mostly guard against injection classes this stack cannot suffer — there is no application server and no database. A rate-based rule counts every asset request, so one visitor loading 40 files counts 40 times and everyone behind a corporate NAT shares a counter. Shield Standard already protects CloudFront for free. Budget well above the ~$5/month base: rules and requests are billed on top.
Both must be created in us-east-1 — WAFv2 because Scope: CLOUDFRONT requires
it, and CloudFront log delivery because the CloudWatch Logs API only accepts it
there, even for cross-region destinations. The website stack is intentionally
region-flexible so your S3 origin can sit near your users. Folding these in
behind an Enable... flag would produce a template that deploys fine everywhere
and then fails only for users who turn the flag on outside us-east-1.
Three breaking changes. New deployments are unaffected.
-
DomainName→HostedZoneId. The template took a root domain name and resolved the zone by name, which is ambiguous when a private and a public zone share a domain. Pass the zone ID instead. -
The bucket name is now generated by default. It used to be
AppDomainName. S3 bucket names are globally unique across all AWS accounts, so owning a domain gives you no claim on the matching bucket name — deployments failed for reasons users could not fix. With OAC the origin is addressed by its regional domain name, so the bucket name was never user-visible anyway.⚠️ ChangingBucketNamereplaces the bucket. Do not apply this to a live stack without migrating content first:aws s3 sync s3://old-bucket s3://new-bucket
The old bucket has
DeletionPolicy: Retain, so it is kept, not deleted. -
SPA mode no longer rewrites 403. Only 404 is served
/index.html. Becauses3:ListBucketis now granted, a 403 means a real authorization failure rather than a missing file — masking it as a 200 page hid broken bucket policies and OAC misconfigurations.
The bucket is versioned and set to Retain, so it survives stack deletion and
must be emptied explicitly. Deleting current objects only adds delete markers:
aws s3 rm s3://<bucket> --recursive --profile example # current objects
aws s3api delete-objects --bucket <bucket> --profile example \
--delete "$(aws s3api list-object-versions --bucket <bucket> --profile example \
--query '{Objects: [].{Key:Key,VersionId:VersionId}}' --output json)"
aws cloudformation delete-stack --stack-name <stack> --profile exampleThen delete the certificate stack. ACM does not remove the DNS validation
record it created, so deleting the certificate leaves an orphaned
_<hash>.<domain> CNAME behind in your hosted zone. CloudFormation will not
clean it up either, because ACM created it rather than the stack. Remove it by
hand:
ZONE=Z1UVA2VESUQ1UN
REC=$(aws route53 list-resource-record-sets --hosted-zone-id $ZONE --profile example --output json \
| jq -c '[.ResourceRecordSets[] | select(.Type=="CNAME" and (.Name|startswith("_")))][0]')
aws route53 change-resource-record-sets --hosted-zone-id $ZONE --profile example \
--change-batch "$(jq -n --argjson r "$REC" '{Changes:[{Action:"DELETE",ResourceRecordSet:$r}]}')"Check the selected record before deleting if the zone contains other underscore CNAMEs (DKIM, other ACM certs) — the filter above takes the first match.
cfn-lint ./*.yml
shellcheck ./*.shBoth run in CI on every pull request.
- Host a personal portfolio or blog.
- Deploy landing pages for startups or campaigns.
- Serve static documentation sites for open-source projects.
Want to improve this template? Submit a pull request or open an issue.
This project is licensed under the MIT License. See the LICENSE file for details.
Star this repo and follow me on X for updates!