Static Website Architecture

As mentioned in my welcome post, I’ve built plenty of websites over the years, most of them with a web server backed by a database. While looking for full-time work, I wanted a personal website – a single page with my details, credentials, and contact information. A web server with a database felt like overkill for that.
Then I found out that an indie game studio registered with an ABN also needs a web presence to justify the entitlement. And these days, a blog is trivially achievable with a static site generator such as the excellent Hugo.
So the requirement was simple: a static website, hosted cheaply, served over HTTPS, on a custom domain, reproducible from scratch. What followed was more complicated than I expected.
Why S3 alone isn’t enough
As cloud object storage, S3 is quite literally designed to serve files and data to client machines. While S3 can expose website endpoints, the addresses look like [bucket-name].s3-website.[region].amazonaws.com – not exactly professional-looking.
You’d think you could point a DNS record at that endpoint and be done. But if you want to serve content over HTTPS – like the vast majority of the internet – you need to issue an SSL certificate for your custom domain. Since S3 is object storage, it doesn’t natively support SSL certificates for custom domains.
To serve static content over HTTPS with a custom domain, you need CloudFront in front of the bucket. The AWS documentation covers steps up to exposing the website endpoint. At the other end of the scale, plenty of tutorials cover CloudFront-fronted static hosting, but they skip custom domains or hand-wave the resource creation with console clicks.
I also wanted a GitHub Actions pipeline that could recreate the entire architecture from scratch at any time. Some posts got me part of the way there, but nothing was comprehensive, so I pulled the pieces together – and hit a few gotchas along the way.
What this post covers
This is part one of a series. This post deals with the AWS resources:
- The resources – website bucket, logging buckets, Route 53 records, certificate, CloudFront distribution and its access controls
- Deployment – a GitHub Actions workflow deploying via CloudFormation using OIDC
- IAM policy – the least-privilege policy that makes it all work
- Deployment – using GitHub Actions to deploy the stack and website content
- References – the sources that saved me hours
- Final template – the complete, copy-pasteable CloudFormation
The website resources
These are the AWS resources used to host the website:
- Website S3 bucket (and its bucket policy)
- Website request logging S3 bucket (and its bucket policy)
- Route 53 record set group
- ACM website certificate
- CloudFront distribution
- CloudFront origin access control
- CloudFront request logging S3 bucket (and its bucket policy)
- CloudFront access logs delivery source, delivery, and destination)
Website S3 bucket
The AWS::S3::Bucket resource hosts the static content and serves the website’s files directly:
WebsiteBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Delete
Properties:
BucketName: !Ref WebsiteDomain
PublicAccessBlockConfiguration:
RestrictPublicBuckets: true
IgnorePublicAcls: true
BlockPublicPolicy: true
BlockPublicAcls: true
WebsiteConfiguration:
IndexDocument: index.html
LoggingConfiguration:
DestinationBucketName: !Ref WebsiteRequestLoggingBucketThree decisions worth explaining:
Delete by default - because the website content lives in a Git repository, DeletionPolicy: Delete is safe: deleting the stack removes the bucket, and the content can always be redeployed.
Block all public access - RestrictPublicBuckets, IgnorePublicAcls, BlockPublicPolicy and BlockPublicAcls are all set to true. The content will be served exclusively via CloudFront, so there’s no reason for the bucket to ever be publicly reachable.
Server access logging - a LoggingConfiguration sends request logs to the separate bucket defined below.
An IndexDocument of index.html tells S3 which file to serve as the homepage.
Website S3 bucket policy
Since public access is blocked, CloudFront needs explicit permission. An AWS::S3::BucketPolicy grants the CloudFront service principal read access to the bucket:
WebsiteBucketPolicy:
Type: AWS::S3::BucketPolicy
DeletionPolicy: Delete
Properties:
Bucket: !Ref WebsiteBucket
PolicyDocument:
Version: 2012-10-17
Id: PolicyForCloudFrontPrivateContent
Statement:
- Action: s3:GetObject
Sid: AllowCloudFrontServicePrincipal
Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Resource: !Sub arn:${AWS::Partition}:s3:::${WebsiteBucket}/*
Condition:
StringEquals:
'AWS:SourceArn': !Sub arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistribution}The Condition is the important part: it pins the permission to a specific distribution ARN, so no other CloudFront distribution - yours or anyone else’s - can read from the bucket.
Website request logging S3 bucket
The bucket above needs somewhere to send its logs. This second bucket collects them:
WebsiteRequestLoggingBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
BucketName: !Sub ${WebsiteDomain}-bucket-requests
PublicAccessBlockConfiguration:
RestrictPublicBuckets: true
IgnorePublicAcls: true
BlockPublicPolicy: true
BlockPublicAcls: true
LifecycleConfiguration:
TransitionDefaultMinimumObjectSize: varies_by_storage_class
Rules:
- Status: Enabled
Transitions:
- StorageClass: STANDARD_IA
TransitionInDays: 30
Id: Move to S3-IA
- Status: Enabled
Id: Move to Glacier
Transitions:
- StorageClass: GLACIER_IR
TransitionInDays: 90Retain, always - unlike the content bucket, the logging buckets use DeletionPolicy: Retain and UpdateReplacePolicy: Retain. Logs are historical data; losing them because a stack was deleted would be a bad trade.
Tier down over time - the lifecycle configuration transitions objects to Standard Infrequent Access after 30 days, then Glacier Instant Retrieval after 90. Old logs stay accessible but cost pennies to keep.
Website request logging bucket policy
S3 server access logging needs explicit permission to write:
WebsiteRequestLoggingBucketPolicy:
Type: AWS::S3::BucketPolicy
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
Bucket: !Ref WebsiteRequestLoggingBucket
PolicyDocument:
Version: 2012-10-17
Id: PolicyForBucketRequestLogging
Statement:
- Action: s3:PutObject
Sid: AllowS3LoggingServicePrincipal
Effect: Allow
Principal:
Service: logging.s3.amazonaws.com
Resource: !Sub arn:${AWS::Partition}:s3:::${WebsiteRequestLoggingBucket}/*
Condition:
StringEquals:
aws:SourceAccount: !Ref AWS::AccountIdThe aws:SourceAccount condition means only your own account’s logging service can write here – nobody else can point their bucket logs at it.
Route 53 record set group
My first instinct was to have CloudFormation create the Route 53 hosted zone. Since I’d already registered the domain, surely that would be straightforward?
Not quite. The hosted zone also holds NS, MX, TXT, SPF, DKIM and DMARC records, plus CNAME validation records I didn’t want to disturb. Instead, I created the hosted zone up front, passed its ID as a parameter, and used it only for the website’s A records:
Parameters:
# ...
WebsiteHostedZoneId:
Description: Hosted Zone ID for DNS record creation
Type: String
# ...
WebsiteRecordSet:
Type: AWS::Route53::RecordSetGroup
DeletionPolicy: Delete
Properties:
HostedZoneId: !Ref WebsiteHostedZoneId
RecordSets:
- Name: !Ref WebsiteDomain
Type: A
AliasTarget:
DNSName: !GetAtt CloudFrontDistribution.DomainName
HostedZoneId: Z2FDTNDATAQYW2 # Alias record that routes traffic to CloudFront distribution
- Name: !Sub www.${WebsiteDomain}
Type: A
AliasTarget:
DNSName: !GetAtt CloudFrontDistribution.DomainName
HostedZoneId: Z2FDTNDATAQYW2 # Alias record that routes traffic to CloudFront distributionThe first gotcha: that mysterious hardcoded Z2FDTNDATAQYW2. The AWS documentation states:
“When you create alias resource record sets, you must specify Z2FDTNDATAQYW2 for the HostedZoneId property. Alias resource record sets for CloudFront can’t be created in a private zone.”
That’s CloudFront’s own hosted zone ID – a constant for every CloudFront distribution, not the hosted zone your records live in. This took a fair amount of head-scratching to discover.
CloudFront distribution
Before committing anything to CloudFormation, I first created the distribution through the AWS Console to prove the configuration worked, then translated the settings into the template.
CloudFrontDistribution:
Type: AWS::CloudFront::Distribution
DeletionPolicy: Delete
Properties:
DistributionConfig:
Aliases:
- !Ref WebsiteDomain
- !Sub www.${WebsiteDomain}
DefaultRootObject: index.html
Origins:
- Id: !GetAtt WebsiteBucket.RegionalDomainName
DomainName: !GetAtt WebsiteBucket.RegionalDomainName
S3OriginConfig:
OriginAccessIdentity: ''
OriginAccessControlId: !GetAtt CloudFrontOriginAccessControl.Id
Enabled: true
DefaultCacheBehavior:
TargetOriginId: !GetAtt WebsiteBucket.RegionalDomainName
AllowedMethods:
- GET
- HEAD
Compress: true
ViewerProtocolPolicy: redirect-to-https
CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # Managed-CachingOptimized
ViewerCertificate:
AcmCertificateArn: !Ref WebsiteCertificate
MinimumProtocolVersion: TLSv1.2_2021
SslSupportMethod: sni-onlyAliases – both the apex domain and www. are listed, so both serve the site.
DefaultRootObject - index.html loads without the file being specified in the URL.
Origins – CloudFront supports multiple origins, but here there’s one: the S3 bucket. The origin uses the bucket’s regional domain name as both its Id and DomainName.
HTTPS everywhere - ViewerProtocolPolicy: redirect-to-https sends plain HTTP requests to their HTTPS equivalents, and ViewerCertificate pins the distribution to the ACM certificate (covered below), TLS 1.2 as a floor, with SNI-only support.
Two settings in the origin config deserve explanation – and one important distinction tripped me up early on: origin access control (OAC) versus origin access identity (OAI). These are two generations of the same idea: restricting an S3 origin so only your CloudFront distribution can read from it.
OAI is the original mechanism – a special CloudFront identity attached to the distribution and granted access via the bucket policy. OAC is its successor: the distribution cryptographically signs every origin request with SigV4, and the bucket policy trusts requests signed for that specific distribution. OAC adds features OAI lacks – support for SSE-KMS encrypted buckets among them.
OAC is the recommended approach today, so that’s what I used. Which leaves the oddity in the snippet above: an empty OriginAccessIdentity alongside the OriginAccessControlId. Per the AWS documentation: “If you’re using origin access control (OAC) instead of origin access identity, specify an empty OriginAccessIdentity element.”
Finally, the DefaultCacheBehavior limits methods to GET and HEAD, enables Compress - a no-brainer for text-heavy pages - and, rather than defining a custom caching policy from scratch, references AWS’s managed CachingOptimized policy by its GUID: 658327ea-f89d-4fab-a63d-7e88639e58f6.
CloudFront origin access control
The distribution references an origin access control, so one must be created:
CloudFrontOriginAccessControl:
Type: AWS::CloudFront::OriginAccessControl
DeletionPolicy: Delete
Properties:
OriginAccessControlConfig:
Name: !GetAtt WebsiteBucket.RegionalDomainName
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4SigningBehavior: always ensures CloudFront signs every origin request – including those for cached content – with SigV4, the only valid value for SigningProtocol.
CloudFront request logging bucket
CloudFront can also log detailed information about every viewer request, so it gets its own log bucket:
CloudFrontRequestLoggingBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
BucketName: !Sub ${WebsiteDomain}-cloudfront-requests
PublicAccessBlockConfiguration:
RestrictPublicBuckets: true
IgnorePublicAcls: true
BlockPublicPolicy: true
BlockPublicAcls: true
LifecycleConfiguration:
TransitionDefaultMinimumObjectSize: varies_by_storage_class
Rules:
- Status: Enabled
Transitions:
- StorageClass: STANDARD_IA
TransitionInDays: 30
Id: Move to S3-IA
- Status: Enabled
Id: Move to Glacier
Transitions:
- StorageClass: GLACIER_IR
TransitionInDays: 90
OwnershipControls:
Rules:
- ObjectOwnership: BucketOwnerPreferredSame shape as the request-logging bucket: public access blocked, lifecycle tiering to IA then Glacier, and retained on deletion or replacement.
One more gotcha lurked here. Without the OwnershipControls block, deployment fails with “The S3 Bucket that you specified for CloudFront logs does not enable ACL access” - which prevents CloudFront from writing logs entirely. Setting ObjectOwnership: BucketOwnerPreferred allows the log delivery service’s ACL headers to do their job.
CloudFront request logging bucket policy
As with S3 logging, CloudFront log delivery needs explicit permission - this time via the delivery.logs.amazonaws.com service principal:
CloudFrontRequestLoggingBucketPolicy:
Type: AWS::S3::BucketPolicy
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
Bucket: !Ref CloudFrontRequestLoggingBucket
PolicyDocument:
Version: 2012-10-17
Id: PolicyForCloudFrontRequestLogging
Statement:
- Action: s3:PutObject
Sid: AllowLogDeliveryServicePrincipal
Effect: Allow
Principal:
Service: delivery.logs.amazonaws.com
Resource: !Sub arn:${AWS::Partition}:s3:::${CloudFrontRequestLoggingBucket}/AWSLogs/${AWS::AccountId}/CloudFront/*
Condition:
StringEquals:
s3:x-amz-acl: bucket-owner-full-control
aws:SourceAccount: !Ref AWS::AccountId
ArnLike:
aws:SourceArn: !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:delivery-source:CreatedByCloudFront-${CloudFrontDistribution.Id}This is the most paranoid policy in the stack, with three conditions stacked up:
Account-scoped - aws:SourceAccount restricts writes to your own account.
ACL-verified - the s3:x-amz-acl: bucket-owner-full-control header forces ownership transfer. Without it, log objects delivered from another account would remain owned by the uploader – and given the bucket blocks public access, you wouldn’t be able to read, modify, or delete them.
Source-verified - the ArnLike condition on aws:SourceArn accepts deliveries originating only from this specific CloudFront distribution.
CloudFront access logs delivery
CloudFront v2 logging uses vended log delivery, which requires three resources – a delivery source, a destination, and a delivery binding them together.
Delivery source – identifies where the logs come from:
CloudFrontAccessLogsDeliverySource:
Type: AWS::Logs::DeliverySource
DeletionPolicy: Delete
Properties:
LogType: ACCESS_LOGS
Name: !Sub ${WebsiteName}-cloudfront-log-delivery-source
ResourceArn: !Sub
- arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${Distribution}
- Distribution: !GetAtt CloudFrontDistribution.IdDelivery - binds the source to the destination:
CloudFrontAccessLogsDelivery:
Type: AWS::Logs::Delivery
DeletionPolicy: Delete
DependsOn: CloudFrontAccessLogsDeliverySource
Properties:
DeliveryDestinationArn: !GetAtt CloudFrontAccessLogsDeliveryDestination.Arn
DeliverySourceName: !Sub ${WebsiteName}-cloudfront-log-delivery-sourceDelivery destination – where the logs land, as an S3 bucket in JSON format:
CloudFrontAccessLogsDeliveryDestination:
Type: AWS::Logs::DeliveryDestination
DeletionPolicy: Delete
Properties:
DestinationResourceArn: !GetAtt CloudFrontRequestLoggingBucket.Arn
Name: !Sub ${WebsiteName}-cloudfront-log-delivery-destination
OutputFormat: jsonCloudFront has two logging modes: legacy and v2. Legacy is still the default when provisioning with CloudFormation, and it’s easy to end up with it by accident. The three resources above are what route you onto v2 logging instead - delivering structured JSON rather than the legacy gzipped CSV format.
ACM website certificate
The final piece of the puzzle: the SSL certificate that lets CloudFront serve the site over HTTPS:
WebsiteCertificate:
Type: AWS::CertificateManager::Certificate
DeletionPolicy: Delete
Properties:
DomainName: !Ref WebsiteDomain
SubjectAlternativeNames:
- !Sub www.${WebsiteDomain}
DomainValidationOptions:
- DomainName: !Sub ${WebsiteDomain}
HostedZoneId: !Ref WebsiteHostedZoneId
ValidationMethod: DNSA SubjectAlternativeName covers the www. subdomain so both addresses are valid.
Deployment
I chose GitHub Actions over CodeBuild for deployment.
No extra infrastructure to manage – CodeBuild is a separate build environment to configure and monitor; GitHub Actions just runs.
Everything in one place – no switching between AWS Console and CI to check on the build; it’s all in the GitHub interface next to the code.
Deployment prerequisites
Before deploying the CloudFormation template, these steps are required:
- Create the hosted zone for the DNS records
- Point the registered domain’s name servers at the hosted zone
- Pass the hosted zone ID as the
WebsiteHostedZoneIdparameter - Run the CloudFormation template
- Confirm the creation of the DNS CNAME records for the website certificate
Step 5 matters most, because DNS validation requires a manual step. In the AWS Console, the certificate sits in Pending validation until the CNAME records exist – and the build won’t proceed without it. It’s a one-time setup; the records can be reused for future deployments.
Deploying with GitHub Actions
Deployment itself was straightforward. The interesting part was the trust relationship between GitHub and AWS.
OpenID Connect (OIDC) – rather than storing long-lived AWS access keys as GitHub secrets, the workflow assumes an IAM role using OIDC, following the steps in the GitHub documentation.
Effectively: register token.actions.githubusercontent.com as an identity provider in the AWS Console, then grant the deployment role permission to be assumed from your repository via a trust policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::[AccountID]:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:[Organisation]/[Repository]:*"
}
}
}
]
}The Condition in particular is worth emphasising: it scopes the trust to a single repository. This is what makes it possible to run CloudFormation in CI without long-lived credentials.
GitHub Actions Workflow
The workflow has six steps:
- Clone repository -
actions/checkout - Configure AWS credentials – assume the role via
aws-actions/configure-aws-credentials - Deploy CloudFormation stack -
aws-actions/aws-cloudformation-github-deploy - Sync files to the website S3 bucket -
aws s3 sync - Get CloudFront distribution ID - via
aws cloudformation describe-stacks - Invalidate CloudFront distribution -
aws cloudfront create-invalidationto clear the caches
name: Deploy Website
on:
push:
branches:
- master
env:
AWS_REGION: "us-east-1"
WEBSITE_NAME: "maceage"
WEBSITE_DOMAIN: "maceage.com"
permissions:
id-token: write
contents: read
jobs:
DeployWebsite:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v7
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.IAM_ROLE_ARN }}
role-session-name: MaceageWebsiteDeployment
aws-region: ${{ env.AWS_REGION }}
- name: Deploy CloudFormation Stack
uses: aws-actions/aws-cloudformation-github-deploy@v2
with:
name: MaceageWebsite
template: deploy/infra-website.yml
parameter-overrides: "WebsiteName=${{ env.WEBSITE_NAME }},WebsiteDomain=${{ env.WEBSITE_DOMAIN }},WebsiteHostedZoneId=${{ secrets.WEBSITE_HOSTED_ZONE_ID }}"
- name: Sync website files to S3 Bucket
run: |
aws s3 sync src/ s3://${{ env.WEBSITE_DOMAIN }} --exact-timestamps --delete
- name: Get CloudFront distribution ID
run: |
echo "DISTRIBUTION_ID=$(aws cloudformation --region us-east-1 describe-stacks --stack-name MaceageWebsite --query 'Stacks[0].Outputs[?OutputKey==`CloudFrontDistributionId`].OutputValue' --output text)" >> $GITHUB_ENV
- name: Invalidate CloudFront distribution
run: |
aws cloudfront create-invalidation --distribution-id ${{ env.DISTRIBUTION_ID }} --paths '/*'IAM policy
Getting the IAM policy right was pure trial and error – deploy, watch it fail, add the missing permission, repeat.
The eventual result:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"cloudformation:CreateStack",
"cloudformation:DescribeStacks",
"cloudformation:DescribeEvents",
"cloudformation:CreateChangeSet",
"cloudformation:DescribeChangeSet",
"cloudformation:DeleteChangeSet",
"cloudformation:ExecuteChangeSet",
"cloudfront:CreateOriginAccessControl",
"cloudfront:UpdateDistribution",
"cloudfront:DeleteDistribution",
"cloudfront:AllowVendedLogDeliveryForResource",
"cloudfront:CreateInvalidation",
"cloudfront:CreateFunction",
"cloudfront:DescribeFunction",
"cloudfront:ListFunctions",
"cloudfront:ListTagsForResource",
"cloudfront:GetFunction",
"cloudfront:DeleteFunction",
"cloudfront:PublishFunction",
"cloudfront:UpdateFunction",
"route53:GetHostedZone",
"route53:ListQueryLoggingConfigs",
"route53:GetChange",
"route53:DeleteHostedZone",
"route53:ListTagsForResource",
"route53:ListResourceRecordSets",
"route53:ChangeResourceRecordSets",
"s3:CreateBucket",
"s3:GetBucketPolicy",
"s3:PutBucketPolicy",
"s3:DeleteBucketPolicy",
"s3:PutLifecycleConfiguration",
"s3:PutBucketPublicAccessBlock",
"s3:PutBucketLogging",
"s3:PutBucketWebsite",
"s3:PutBucketOwnershipControls",
"s3:GetBucketAcl",
"s3:PutBucketAcl",
"s3:ListBucket",
"s3:PutObject",
"s3:DeleteObject",
"s3:PutObjectAcl",
"s3:DeleteBucket",
"acm:RequestCertificate",
"acm:DescribeCertificate",
"acm:DeleteCertificate",
"cloudfront:CreateDistribution",
"cloudfront:GetDistribution",
"cloudfront:TagResource",
"cloudfront:GetOriginAccessControl",
"cloudfront:DeleteOriginAccessControl",
"logs:GetDeliveryDestination",
"logs:PutDeliveryDestination",
"logs:DeleteDeliveryDestination",
"logs:ListTagsForResource",
"logs:GetDeliverySource",
"logs:DeleteDeliverySource",
"logs:PutDeliverySource",
"logs:DescribeDeliveries",
"logs:CreateDelivery",
"logs:DeleteDelivery",
"logs:GetDelivery",
"logs:GetLogDelivery",
"logs:GetDeliveryDestinationPolicy",
"logs:CreateLogDelivery",
"logs:DeleteDeliveryDestinationPolicy",
"logs:DeleteLogDelivery",
"logs:PutDeliveryDestinationPolicy",
"logs:UpdateLogDelivery",
"logs:UpdateDeliveryConfiguration",
"logs:DescribeDeliverySources",
"logs:DescribeDeliveryDestinations"
],
"Resource": "*"
}
]
}Given the breadth of resources this stack creates, a broad Resource: "*" is unavoidable – many of these services don’t support resource-level permissions on the resources being created.
References
Three discoveries shaped the final architecture:
Deploy outside us-east-1 - “To use an ACM certificate with CloudFront, make sure you request (or import) the certificate in the US East (N. Virginia) Region (us-east-1).” I initially created the S3 bucket and distribution in ap-southeast-2 - but CloudFront distributions must live in us-east-1 to use ACM certificates, so I placed everything in us-east-1 rather than fighting cross-region with custom resources. There’s a relevant GitHub issue open
DNS validation – “You cannot programmatically request that ACM automatically create your record in Route 53.” Automating everything was the goal, but DNS validation stayed stubbornly manual – the CNAME records must be created by hand.
CloudFront v2 logging – “If you’re provisioning CloudFront with CloudFormation, it’s easy to end up with the legacy setup by accident.” Ensuring v2 logging rather than the legacy default.
Wrapping up
When I started, I assumed this would be straightforward: create an S3 bucket, point DNS at the website endpoint, provision a certificate.
As usual, the reality involved unexpected twists:
- CloudFront-hosted zones instead of your own, in Route 53 aliases
- Origin access control instead of the deprecated origin access identity
- Object ownership controls to allow the log delivery ACL to function
- Everything in
us-east-1, certificate and distribution alike - Vended log delivery, if you want the modern logging setup
Hopefully this helps anyone else looking to host a static website on S3 with a custom domain, CloudFront, and an SSL certificate.
Final Template
The final completed CloudFormation template for the static website resources looks like this:
Parameters:
WebsiteName:
Description: Name to use without dots
Type: String
WebsiteDomain:
Description: Domain name of the website (e.g. example.com)
Type: String
WebsiteHostedZoneId:
Description: Hosted Zone ID for DNS record creation
Type: String
Resources:
WebsiteBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Delete
Properties:
BucketName: !Ref WebsiteDomain
PublicAccessBlockConfiguration:
RestrictPublicBuckets: true
IgnorePublicAcls: true
BlockPublicPolicy: true
BlockPublicAcls: true
WebsiteConfiguration:
IndexDocument: index.html
LoggingConfiguration:
DestinationBucketName: !Ref WebsiteRequestLoggingBucket
WebsiteBucketPolicy:
Type: AWS::S3::BucketPolicy
DeletionPolicy: Delete
Properties:
Bucket: !Ref WebsiteBucket
PolicyDocument:
Version: 2012-10-17
Id: PolicyForCloudFrontPrivateContent
Statement:
- Action: s3:GetObject
Sid: AllowCloudFrontServicePrincipal
Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Resource: !Sub arn:${AWS::Partition}:s3:::${WebsiteBucket}/*
Condition:
StringEquals:
'AWS:SourceArn': !Sub arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistribution}
WebsiteRequestLoggingBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
BucketName: !Sub ${WebsiteDomain}-bucket-requests
PublicAccessBlockConfiguration:
RestrictPublicBuckets: true
IgnorePublicAcls: true
BlockPublicPolicy: true
BlockPublicAcls: true
LifecycleConfiguration:
TransitionDefaultMinimumObjectSize: varies_by_storage_class
Rules:
- Status: Enabled
Transitions:
- StorageClass: STANDARD_IA
TransitionInDays: 30
Id: Move to S3-IA
- Status: Enabled
Id: Move to Glacier
Transitions:
- StorageClass: GLACIER_IR
TransitionInDays: 90
WebsiteRequestLoggingBucketPolicy:
Type: AWS::S3::BucketPolicy
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
Bucket: !Ref WebsiteRequestLoggingBucket
PolicyDocument:
Version: 2012-10-17
Id: PolicyForBucketRequestLogging
Statement:
- Action: s3:PutObject
Sid: AllowS3LoggingServicePrincipal
Effect: Allow
Principal:
Service: logging.s3.amazonaws.com
Resource: !Sub arn:${AWS::Partition}:s3:::${WebsiteRequestLoggingBucket}/*
Condition:
StringEquals:
aws:SourceAccount: !Ref AWS::AccountId
WebsiteRecordSet:
Type: AWS::Route53::RecordSetGroup
DeletionPolicy: Delete
Properties:
HostedZoneId: !Ref WebsiteHostedZoneId
RecordSets:
- Name: !Ref WebsiteDomain
Type: A
AliasTarget:
DNSName: !GetAtt CloudFrontDistribution.DomainName
HostedZoneId: Z2FDTNDATAQYW2 # Alias record that routes traffic to CloudFront distribution
- Name: !Sub www.${WebsiteDomain}
Type: A
AliasTarget:
DNSName: !GetAtt CloudFrontDistribution.DomainName
HostedZoneId: Z2FDTNDATAQYW2 # Alias record that routes traffic to CloudFront distribution
CloudFrontDistribution:
Type: AWS::CloudFront::Distribution
DeletionPolicy: Delete
Properties:
DistributionConfig:
Aliases:
- !Ref WebsiteDomain
- !Sub www.${WebsiteDomain}
DefaultRootObject: index.html
Origins:
- Id: !GetAtt WebsiteBucket.RegionalDomainName
DomainName: !GetAtt WebsiteBucket.RegionalDomainName
S3OriginConfig:
OriginAccessIdentity: ''
OriginAccessControlId: !GetAtt CloudFrontOriginAccessControl.Id
Enabled: true
DefaultCacheBehavior:
TargetOriginId: !GetAtt WebsiteBucket.RegionalDomainName
AllowedMethods:
- GET
- HEAD
Compress: true
ViewerProtocolPolicy: redirect-to-https
CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # Managed-CachingOptimized
ViewerCertificate:
AcmCertificateArn: !Ref WebsiteCertificate
MinimumProtocolVersion: TLSv1.2_2021
SslSupportMethod: sni-only
CloudFrontOriginAccessControl:
Type: AWS::CloudFront::OriginAccessControl
DeletionPolicy: Delete
Properties:
OriginAccessControlConfig:
Name: !GetAtt WebsiteBucket.RegionalDomainName
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4
CloudFrontRequestLoggingBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
BucketName: !Sub ${WebsiteDomain}-cloudfront-requests
PublicAccessBlockConfiguration:
RestrictPublicBuckets: true
IgnorePublicAcls: true
BlockPublicPolicy: true
BlockPublicAcls: true
LifecycleConfiguration:
TransitionDefaultMinimumObjectSize: varies_by_storage_class
Rules:
- Status: Enabled
Transitions:
- StorageClass: STANDARD_IA
TransitionInDays: 30
Id: Move to S3-IA
- Status: Enabled
Id: Move to Glacier
Transitions:
- StorageClass: GLACIER_IR
TransitionInDays: 90
OwnershipControls:
Rules:
- ObjectOwnership: BucketOwnerPreferred
CloudFrontRequestLoggingBucketPolicy:
Type: AWS::S3::BucketPolicy
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
Bucket: !Ref CloudFrontRequestLoggingBucket
PolicyDocument:
Version: 2012-10-17
Id: PolicyForCloudFrontRequestLogging
Statement:
- Action: s3:PutObject
Sid: AllowLogDeliveryServicePrincipal
Effect: Allow
Principal:
Service: delivery.logs.amazonaws.com
Resource: !Sub arn:${AWS::Partition}:s3:::${CloudFrontRequestLoggingBucket}/AWSLogs/${AWS::AccountId}/CloudFront/*
Condition:
StringEquals:
s3:x-amz-acl: bucket-owner-full-control
aws:SourceAccount: !Ref AWS::AccountId
ArnLike:
aws:SourceArn: !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:delivery-source:CreatedByCloudFront-${CloudFrontDistribution.Id}
CloudFrontAccessLogsDeliverySource:
Type: AWS::Logs::DeliverySource
DeletionPolicy: Delete
Properties:
LogType: ACCESS_LOGS
Name: !Sub ${WebsiteName}-cloudfront-log-delivery-source
ResourceArn: !Sub
- arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${Distribution}
- Distribution: !GetAtt CloudFrontDistribution.Id
CloudFrontAccessLogsDelivery:
Type: AWS::Logs::Delivery
DeletionPolicy: Delete
DependsOn: CloudFrontAccessLogsDeliverySource
Properties:
DeliveryDestinationArn: !GetAtt CloudFrontAccessLogsDeliveryDestination.Arn
DeliverySourceName: !Sub ${WebsiteName}-cloudfront-log-delivery-source
CloudFrontAccessLogsDeliveryDestination:
Type: AWS::Logs::DeliveryDestination
DeletionPolicy: Delete
Properties:
DestinationResourceArn: !GetAtt CloudFrontRequestLoggingBucket.Arn
Name: !Sub ${WebsiteName}-cloudfront-log-delivery-destination
OutputFormat: json
WebsiteCertificate:
Type: AWS::CertificateManager::Certificate
DeletionPolicy: Delete
Properties:
DomainName: !Ref WebsiteDomain
SubjectAlternativeNames:
- !Sub www.${WebsiteDomain}
DomainValidationOptions:
- DomainName: !Sub ${WebsiteDomain}
HostedZoneId: !Ref WebsiteHostedZoneId
ValidationMethod: DNS
Outputs:
WebsiteDomain:
Value: !Sub ${WebsiteDomain}
Description: Website public domain address
CloudFrontDistributionId:
Value: !GetAtt CloudFrontDistribution.Id
Description: CloudFront Distribution ID
WebsiteBucketRegionalDomainName:
Value: !GetAtt WebsiteBucket.RegionalDomainName
Description: Regional domain name of WebsiteBucket
WebsiteBucketWebsiteURL:
Value: !GetAtt WebsiteBucket.WebsiteURL
Description: S3 website endpoint of WebsiteBucketRelated Posts
2024-07-03
2024-08-30
2024-07-11