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.