# Static Website Architecture

*2026-01-07*

> A CloudFormation template for static websites on custom domains


As mentioned in my [welcome post]({{< relref "/posts/2024/welcome" >}}), 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](https://sawnoffstudios.com/) registered with an ABN also needs a web presence to [justify the entitlement](https://www.abr.gov.au/business-super-funds-charities/applying-abn/abn-entitlement). And these days, [a blog](https://blog.maceage.com/) is trivially achievable with a static site generator such as the excellent [Hugo](https://gohugo.io/).

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](https://aws.amazon.com/s3/), 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](https://docs.aws.amazon.com/AmazonS3/latest/userguide/HostingWebsiteOnS3Setup.html) covers steps up to exposing the website endpoint. At the other end of the scale, plenty of tutorials cover [CloudFront-fronted static hosting](https://www.repost.aws/knowledge-center/cloudfront-serve-static-website), but they skip custom domains or hand-wave the resource creation with console clicks.

I also wanted a [GitHub Actions](https://github.com/features/actions) pipeline that could recreate the entire architecture from scratch at any time. [Some posts](https://dev.to/tiamatt/aws-project-module-3-use-your-custom-domain-for-static-website-on-aws-s3-via-route-53-and-cloudformation-34cn) 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-resources) – website bucket, logging buckets, Route 53 records, certificate, CloudFront distribution and its access controls
- [**Deployment**](#deployment) – a GitHub Actions workflow deploying via CloudFormation using OIDC
- [**IAM policy**](#iam-policy) – the least-privilege policy that makes it all work
- [**Deployment**](#deployment) – using GitHub Actions to deploy the stack and website content
- [**References**](#references) – the sources that saved me hours
- [**Final template**](#final-template) – the complete, copy-pasteable CloudFormation

## The website resources{#website-resources}

These are the AWS resources used to host the website:

- [Website S3 bucket](#website-s3-bucket) (and its [bucket policy](#website-s3-bucket-policy))
- [Website request logging S3 bucket](#website-request-logging-s3-bucket) (and its [bucket policy](#website-request-logging-bucket-policy))
- [Route 53 record set group](#route-53-record-set-group)
- [ACM website certificate](#website-certificate)
- [CloudFront distribution](#cloudfront-distribution)
- [CloudFront origin access control](#cloudfront-origin-access-control)
- [CloudFront request logging S3 bucket](#cloudfront-request-logging-s3-bucket) (and its [bucket policy](#cloudfront-request-logging-bucket-policy))
- [CloudFront access logs](#cloudfront-access-logs-delivery) delivery source, delivery, and destination)

## Website S3 bucket{#website-s3-bucket}

The `AWS::S3::Bucket` resource hosts the static content and serves the website's files directly:

```yaml
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](https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html).

**Server access logging** - a `LoggingConfiguration` sends [request logs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerLogs.html) to the separate bucket defined below.

An `IndexDocument` of `index.html` tells S3 which file to serve as the homepage.

## Website S3 bucket policy{#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:

```yaml
  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{#website-request-logging-s3-bucket}

The bucket above needs somewhere to send its logs. This second bucket collects them:

```yaml
  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](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html) 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{#website-request-logging-bucket-policy}

S3 server access logging needs explicit permission to write:

```yaml
  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{#route-53-record-set-group}

My first instinct was to have CloudFormation create the [Route 53 hosted zone](https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-route53-hostedzone.html). Since I'd already registered the domain, surely that would be straightforward?

Not quite. The hosted zone also holds NS, [MX, TXT](https://proton.me/support/custom-domain), [SPF, DKIM and DMARC records](https://proton.me/support/anti-spoofing-custom-domain), plus [CNAME validation records](https://support.google.com/webmasters/answer/34592?hl=en) 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:

```yaml
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](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/quickref-route53.html):

> "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{#cloudfront-distribution}

Before committing anything to CloudFormation, I first created [the distribution](https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-cloudfront-distribution.html) through the AWS Console to prove the configuration worked, then translated the settings into the template.

```yaml
  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](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/DefaultRootObject.html) in the URL.

**Origins** – CloudFront supports multiple origins, but here there's one: the [S3 bucket](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/DownloadDistS3AndCustomOrigins.html). 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`](https://docs.aws.amazon.com/cloudfront/latest/APIReference/API_ViewerCertificate.html) 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](https://docs.aws.amazon.com/cloudfront/latest/APIReference/API_S3OriginConfig.html): *"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](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html#managed-cache-caching-optimized) by its GUID: `658327ea-f89d-4fab-a63d-7e88639e58f6`.

## CloudFront origin access control{#cloudfront-origin-access-control}

The distribution references an [origin access control](https://docs.aws.amazon.com/cloudfront/latest/APIReference/API_OriginAccessControlConfig.html), so one must be created:

```yaml
  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-request-logging-s3-bucket}

CloudFront can also [log detailed information](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/AccessLogs.html) about every viewer request, so it gets its own log bucket:

```yaml
  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](https://stackoverflow.com/questions/70645117/enable-s3-acl-access-for-cloudfront-logs) to do their job.

## CloudFront request logging bucket policy{#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:

```yaml
  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-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**](https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-logs-deliverysource.html) – identifies where the logs come from:

```yaml
  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**](https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-logs-delivery.html) - binds the source to the destination:

```yaml
  CloudFrontAccessLogsDelivery:
    Type: AWS::Logs::Delivery
    DeletionPolicy: Delete
    DependsOn: CloudFrontAccessLogsDeliverySource
    Properties:
      DeliveryDestinationArn: !GetAtt CloudFrontAccessLogsDeliveryDestination.Arn
      DeliverySourceName: !Sub ${WebsiteName}-cloudfront-log-delivery-source
```

[**Delivery destination**](https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-logs-deliverydestination.html) – where the logs land, as an S3 bucket in JSON format:

```yaml
  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](https://sookocheff.com/post/aws/using-v2-logging-with-cloudfront-via-cloudformation/). 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{#website-certificate}

The final piece of the puzzle: the [SSL certificate](https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-certificatemanager-certificate.html) that lets CloudFront serve the site over HTTPS:

```yaml
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{#deployment}

I chose GitHub Actions over [CodeBuild](https://docs.aws.amazon.com/codebuild/latest/userguide/welcome.html) 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](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws).

Effectively: register `token.actions.githubusercontent.com` as an [identity provider](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers.html) in the AWS Console, then grant the deployment role permission to be assumed from your repository via a trust policy:

```json
{
  "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`](https://github.com/actions/checkout)
2. **Configure AWS credentials** – assume the role via [`aws-actions/configure-aws-credentials`](https://github.com/aws-actions/configure-aws-credentials)
3. **Deploy CloudFormation stack** - [`aws-actions/aws-cloudformation-github-deploy`](https://github.com/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

```yaml
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{#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](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cnames-and-https-requirements.html), so I placed everything in `us-east-1` rather than fighting cross-region with custom resources. There's a relevant [GitHub issue open](https://github.com/aws-cloudformation/cloudformation-coverage-roadmap/issues/523)

**DNS validation** – *"You cannot programmatically request that ACM automatically create your record in Route 53."* Automating everything was the goal, but [DNS validation](https://docs.aws.amazon.com/acm/latest/userguide/dns-validation.html) 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](https://sookocheff.com/post/aws/using-v2-logging-with-cloudfront-via-cloudformation/) 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{#final-template}

The final completed CloudFormation template for the static website resources looks like this:

```yaml
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
```
