Updraft ExtensionsPreview
ExampleIntermediate 15 min

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.

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

Requests reach CloudFront over TLS; CloudFront signs its origin request with SigV4 and the bucket policy accepts it only from this distribution. The bucket has no public access at all.

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

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.