Static Website Architecture

·16 min read
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

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 WebsiteRequestLoggingBucket

Three 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: 90

Retain, 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::AccountId

The 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 distribution

The 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-only

Aliases – 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: sigv4

SigningBehavior: 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: BucketOwnerPreferred

Same 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.Id

Delivery - 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-source

Delivery 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: json

CloudFront 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: DNS

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

  1. Create the hosted zone for the DNS records
  2. Point the registered domain’s name servers at the hosted zone
  3. Pass the hosted zone ID as the WebsiteHostedZoneId parameter
  4. Run the CloudFormation template
  5. 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:

  1. Clone repository - actions/checkout
  2. Configure AWS credentials – assume the role via aws-actions/configure-aws-credentials
  3. Deploy CloudFormation stack - aws-actions/aws-cloudformation-github-deploy
  4. Sync files to the website S3 bucket - aws s3 sync
  5. Get CloudFront distribution ID - via aws cloudformation describe-stacks
  6. Invalidate CloudFront distribution - aws cloudfront create-invalidation to 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 WebsiteBucket