Skip to content

Latest commit

 

History

21 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

AWS Static Website Hosting with CloudFormation

GitHub stars License

Deploy a secure, scalable static website using S3, CloudFront, and Route53 in minutes.

Why Use This Template?

  • 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 spa or static; each gets the routing behavior it actually needs.
  • Customizable: Parameters for CSP, HSTS, TLS policy, price class, and an optional WAF.

Architecture

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
Loading

Prerequisites

  • An AWS account and a named AWS CLI profile
  • A Route53 public hosted zone for your domain
  • aws, jq, and bash. On macOS: brew install awscli jq

Quick start

# 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.

Doing it by hand

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 example

To find your hosted zone ID by hand:

aws route53 list-hosted-zones-by-name --profile=example |
jq -r '.HostedZones[] | select(.Name=="example.com.") | .Id'

Choosing SiteType

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,/guide

There 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.

Parameters

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

Security notes

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.

The default CSP is opinionated, not risk-free

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.

HSTS: includeSubDomains is the dangerous one

Both HSTS options default to false on purpose.

  • HstsIncludeSubdomains is 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.
  • HstsPreload does 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.

Not included by default

Access logging and WAF live in separate stacks (see below). There is no origin failover and no geo restriction.

Naming the bucket

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-site

Avoid 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 example

aws 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.

Publishing permanent, versioned artifacts

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.

Optional: GitHub Actions deploy role (OIDC)

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 example

It 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')]"

Immutable subject claims

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_prefix

If 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.

Scoping

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: read

Retiring a hostname (redirect-site.yml)

Points 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 example

Start 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.

Two design choices worth knowing

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.

DNS cutover — not handled by the stack

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.

Optional: access logging

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 example

Uses 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.

Optional: WAF

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.

Why logging and WAF are separate stacks

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.

Upgrading from an earlier version of this template

Three breaking changes. New deployments are unaffected.

  1. DomainNameHostedZoneId. 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.

  2. 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.

    ⚠️ Changing BucketName replaces 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.

  3. SPA mode no longer rewrites 403. Only 404 is served /index.html. Because s3:ListBucket is 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.

Teardown

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 example

Then 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.

Development

cfn-lint ./*.yml
shellcheck ./*.sh

Both run in CI on every pull request.

Use Cases

  • Host a personal portfolio or blog.
  • Deploy landing pages for startups or campaigns.
  • Serve static documentation sites for open-source projects.

Contributing

Want to improve this template? Submit a pull request or open an issue.

License

This project is licensed under the MIT License. See the LICENSE file for details.

Stay Updated

Star this repo and follow me on X for updates!

About

This AWS CloudFormation template automates the deployment of a static website using S3 for hosting, CloudFront for CDN, and Route53 for DNS. Perfect for serverless websites, portfolios, or documentation sites.

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages