RichClegg

Build a site like this with AI, part 1 of 4

Host a static site on S3 and CloudFront

One CloudFormation stack gives you a private bucket, a CDN and HTTPS, within the always-free CloudFront allowance.

The hosting for every site I run is one CloudFormation stack per site. It creates four things: a private S3 bucket, a CloudFront distribution in front of it, an origin access control that lets CloudFront sign its requests, and a bucket policy that only accepts those signed requests. The stacks live in a private infrastructure repo of mine, so the snippets below are the parts that matter.

Keep the bucket private

The old way to host on S3 was to make the bucket public and turn on static website hosting. Don’t. Block all public access and let CloudFront be the only reader:

Bucket:
  Type: AWS::S3::Bucket
  Properties:
    BucketName: !Sub '${AWS::StackName}-${AWS::AccountId}-${AWS::Region}'
    PublicAccessBlockConfiguration:
      BlockPublicAcls: true
      BlockPublicPolicy: true
      IgnorePublicAcls: true
      RestrictPublicBuckets: true

Naming the bucket after the stack matters later. The deploy role in part two can only touch buckets that start with its stack name.

Let only your distribution read it

An origin access control (OAC) makes CloudFront sign every request to S3 with SigV4. The bucket policy then allows s3:GetObject for the CloudFront service, but only when the request comes from this one distribution:

OAC:
  Type: AWS::CloudFront::OriginAccessControl
  Properties:
    OriginAccessControlConfig:
      Name: !Sub '${AWS::StackName}-oac'
      OriginAccessControlOriginType: s3
      SigningBehavior: always
      SigningProtocol: sigv4

BucketPolicy:
  Type: AWS::S3::BucketPolicy
  Properties:
    Bucket: !Ref Bucket
    PolicyDocument:
      Statement:
        - Effect: Allow
          Principal: {Service: cloudfront.amazonaws.com}
          Action: s3:GetObject
          Resource: !Sub '${Bucket.Arn}/*'
          Condition:
            StringEquals:
              AWS:SourceArn: !Sub 'arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${Distribution}'

Use the managed policies

The distribution itself is short because AWS publishes managed cache and security header policies. You reference them by id instead of writing your own:

DefaultCacheBehavior:
  TargetOriginId: s3
  ViewerProtocolPolicy: redirect-to-https
  Compress: true
  CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # Managed-CachingOptimized
  ResponseHeadersPolicyId: 67f7725c-6f97-4210-82d7-5512b31e9d03 # Managed-SecurityHeadersPolicy

PriceClass_100 keeps traffic on North American and European edge locations, which is plenty for a portfolio. Without a custom domain you get a working HTTPS URL on cloudfront.net right away. When you want your own domain, you add an ACM certificate and the domain as an alias on the distribution.

Deploy it

With admin credentials, once:

aws cloudformation deploy \
  --template-file site.yaml \
  --stack-name my-site

The stack outputs the bucket name, the distribution id and the URL. From here on, GitHub Actions does the deploys, which is part two.

What it costs

CloudFront’s always-free tier includes 1 TB of data transfer out and 10 million HTTP and HTTPS requests a month. S3 is not in the always-free tier, but a site this size stores a few megabytes, and CloudFront’s cache means S3 sees few requests. For a personal site that comes to a few cents a month once credits run out.

Ask the AI to cite the docs. Free tier terms change. When I asked Claude Code about costs, I asked it to read the current AWS pages and link them. It found that the Free Tier changed for accounts opened after July 15, 2025, and that CloudFront’s newer flat-rate free plan is not available while an account is still on the Free Tier.