A static site behind CloudFront, with the bucket kept private
The standard way to serve a static site from S3 — which is not the way most tutorials show, because website hosting cannot do TLS and origin access control keeps the bucket private.
AWS::S3::Bucket, AWS::Route53::RecordSetThe problem with the obvious approach #
Search for “S3 static website” and you will find WebsiteConfiguration, a
public bucket policy, and an http:// URL. That configuration works, and it has
two properties that rule it out for anything real:
- The website endpoint speaks HTTP only. There is no TLS, at all, and no way to add it. Browsers mark the site insecure and anything embedded in it is blocked as mixed content.
- The bucket must be public. Every object is readable by anyone who guesses a key, and the four public access blocks must be turned off to allow it.
The alternative keeps the bucket entirely private and puts CloudFront in front of it with an origin access control — a signed request from CloudFront that the bucket policy trusts and nobody else can forge.
The template #
Three resources and one policy. The policy is where the interesting constraint lives.
Parameters:
DomainName:
Type: String
Default: www.acme.example
HostedZoneId:
Type: String
CertificateArn:
Type: String
Description: >-
Must be an ACM certificate in us-east-1. CloudFront cannot use a
certificate from any other region, whatever the distribution's own
region happens to be.
Resources:
SiteBucket:
Type: AWS::S3::Bucket
DeletionPolicy: Retain
UpdateReplacePolicy: Retain
Properties:
# No BucketName: let CloudFormation generate one. Nothing references
# this bucket by name, so a unique generated name is strictly better.
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: AES256
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
VersioningConfiguration:
Status: Enabled
OwnershipControls:
Rules:
- ObjectOwnership: BucketOwnerEnforced
LifecycleConfiguration:
Rules:
- Id: abort-incomplete-uploads
Status: Enabled
AbortIncompleteMultipartUpload:
DaysAfterInitiation: 7
- Id: expire-old-versions
Status: Enabled
NoncurrentVersionExpirationInDays: 30
OriginAccessControl:
Type: AWS::CloudFront::OriginAccessControl
Properties:
OriginAccessControlConfig:
Name: !Sub '${AWS::StackName}-oac'
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4
Distribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
Aliases: [!Ref DomainName]
DefaultRootObject: index.html
ViewerCertificate:
AcmCertificateArn: !Ref CertificateArn
SslSupportMethod: sni-only
MinimumProtocolVersion: TLSv1.2_2021
Origins:
# RegionalDomainName, not DomainName: the non-regional form
# redirects on first request and breaks SigV4 signing.
- Id: s3-origin
DomainName: !GetAtt SiteBucket.RegionalDomainName
OriginAccessControlId: !GetAtt OriginAccessControl.Id
S3OriginConfig:
OriginAccessIdentity: ''
DefaultCacheBehavior:
TargetOriginId: s3-origin
ViewerProtocolPolicy: redirect-to-https
CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # CachingOptimized
Compress: true
CustomErrorResponses:
# Single-page apps: hand 404s back to the client router.
- ErrorCode: 404
ResponseCode: 200
ResponsePagePath: /index.html
ErrorCachingMinTTL: 10
# The bucket trusts CloudFront, and only this distribution.
SiteBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref SiteBucket
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: AllowCloudFrontServicePrincipalReadOnly
Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Action: s3:GetObject
Resource: !Sub '${SiteBucket.Arn}/*'
Condition:
StringEquals:
AWS:SourceArn: !Sub 'arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${Distribution}'
SiteRecord:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneId: !Ref HostedZoneId
Name: !Ref DomainName
Type: A
AliasTarget:
DNSName: !GetAtt Distribution.DomainName
# CloudFront's canonical zone. This constant is the same in every
# account and every region, and it is not your hosted zone.
HostedZoneId: Z2FDTNDATAQYW2
Outputs:
SiteUrl:
Value: !Sub 'https://${DomainName}'
BucketName:
Description: Deploy target for the built site.
Value: !Ref SiteBucket
DistributionId:
Description: Needed for cache invalidation after a deploy.
Value: !Ref Distribution
import { Stack, RemovalPolicy, Duration } from 'aws-cdk-lib';
import { Bucket, BlockPublicAccess, BucketEncryption, ObjectOwnership } from 'aws-cdk-lib/aws-s3';
import { Distribution, ViewerProtocolPolicy, SecurityPolicyProtocol } from 'aws-cdk-lib/aws-cloudfront';
import { S3BucketOrigin } from 'aws-cdk-lib/aws-cloudfront-origins';
import { ARecord, RecordTarget } from 'aws-cdk-lib/aws-route53';
import { CloudFrontTarget } from 'aws-cdk-lib/aws-route53-targets';
const siteBucket = new Bucket(this, 'SiteBucket', {
encryption: BucketEncryption.S3_MANAGED,
blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
objectOwnership: ObjectOwnership.BUCKET_OWNER_ENFORCED,
versioned: true,
removalPolicy: RemovalPolicy.RETAIN,
lifecycleRules: [
{ id: 'abort-incomplete-uploads', abortIncompleteMultipartUploadAfter: Duration.days(7) },
{ id: 'expire-old-versions', noncurrentVersionExpiration: Duration.days(30) },
],
});
// withOriginAccessControl wires up the OAC and the bucket policy,
// including the SourceArn condition, so the cycle never appears.
const distribution = new Distribution(this, 'Distribution', {
defaultBehavior: {
origin: S3BucketOrigin.withOriginAccessControl(siteBucket),
viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
compress: true,
},
domainNames: [domainName],
certificate, // must be in us-east-1
defaultRootObject: 'index.html',
minimumProtocolVersion: SecurityPolicyProtocol.TLS_V1_2_2021,
errorResponses: [
{ httpStatus: 404, responseHttpStatus: 200, responsePagePath: '/index.html', ttl: Duration.seconds(10) },
],
});
new ARecord(this, 'SiteRecord', {
zone: hostedZone,
recordName: domainName,
target: RecordTarget.fromAlias(new CloudFrontTarget(distribution)),
});
What the outputs resolve to #
!Ref SiteBucket
→
static-site-sitebucket-1a2b3c4d5e6fgenerated, because nothing references it by name
!GetAtt SiteBucket.RegionalDomainName
→
static-site-sitebucket-1a2b3c4d5e6f.s3.us-west-2.amazonaws.com
!GetAtt Distribution.DomainName
→
d111111abcdef8.cloudfront.net
!Ref Distribution
→
E1PDK09ESKHJWDneeded for invalidations
Four things that go wrong #
Tearing it down #
The bucket has DeletionPolicy: Retain, so delete-stack leaves it behind
along with every object and version. That is deliberate — but it means teardown
is two steps:
aws cloudformation delete-stack --stack-name static-site
aws cloudformation wait stack-delete-complete --stack-name static-site
# Then, once you are sure:
aws s3 rm s3://static-site-sitebucket-1a2b3c4d5e6f --recursive
aws s3api delete-bucket --bucket static-site-sitebucket-1a2b3c4d5e6f
A versioned bucket needs its non-current versions and delete markers removed
before delete-bucket succeeds; s3 rm --recursive does not remove them.