cloudfront s3 static hosting

My goto serverless stack for super speed to production web applications, and RESTful APis, is CloudFront, S3, API Gateway, and Lambda. CloudFront provides the front door to the application, serving static content from S3 and providing paths to API Gateway APIs - no CORS here.

CloudFront can be leveraged for caching and routing to APIs, CMSes, etc. It’s a nice cheap setup with quite a lot of flexibility and integration with AWS services. This website is served by CloudFront and AWS CodePipeline provides the CI/CD. I prefer working with that flexibility, however if you want something light and quick you can check out AWS Amplify or Netflify.

This tutorial covers creating the base for this stack by serving static content using AWS CloudFront (the CDN), S3 (storage for the static content). The S3 bucket will be private with serverside encryption enabled by default. AWS Route 53 (DNS service) will be configured to have requests sent to a custom domain routed to the CloudFront distribution created in this tutorial.

The source code for this tutorial can be found here

What if I don’t want a custom domain

If no domain is required, and hitting the CloudFront distribution domain directly is adequate for your needs, then follow these instructions instead.

Costs

Warning! You will incur costs by following this tutorial.

ServiceCost
Domain name.com goes for around €8 for one year (€11 to renew) with Namecheap
AWS Route 53 hosted zone1 x €0.50 per month
AWS CloudFrontCharged on data transfer out & http/https requests
AWS S3You pay for storage, requests, and retrievals - Caching will make retrieval minimal

Step 1: Register a Domain Name

You can use AWS Route 53 to register a domain name by following these instructions. Alternatively you can use your favourite domain registrar to register a domain. My favourite is Namecheap due to their low prices, customer support, and tooling.

This tutorial will use Route 53 to alias your domain name and the subdomain www to route traffic to the CloudFront distribution e.g.

example.com
www.example.com

Step 2: Create a Hosted Zone

If you decided to use AWS Route 53 to register a domain name then a Route 53 hosted zone will already have been created for you. Take note of the Hosted Zone Id. Now go straight to step 4.

Otherwise, if you decided to use a domain registrar other than AWS Route 53 then create an AWS Route 53 hosted zone by using the following CloudFormation template:

AWSTemplateFormatVersion: '2010-09-09'

Parameters:
  DomainName:
    Type: String

Resources:
  HostedZone:
    Type: AWS::Route53::HostedZone
    Properties:
      Name: !Ref DomainName

Outputs:
  HostedZoneId:
    Description: The Hosted Zone Id
    Value: !Ref HostedZone
    Export:
      Name: !Sub ${AWS::StackName}-HostedZoneId
  HostedZoneNameServers:
    Description: The Hosted Zone Name Servers
    Value: !Join
      - ', '
      - !GetAtt HostedZone.NameServers
    Export:
      Name: !Sub ${AWS::StackName}-HostedZoneNameServers

Use the following AWS CLI command to create the hosted zone:

aws cloudformation deploy \
--template-file hosted-zone.yaml \
--stack-name mystaticwebsite-hosted-zone \
--parameter-overrides \
DomainName=<your fully qualified domain name>

Run the following AWS CLI command to get the hosted zone id and the hosted zone nameservers:

aws cloudformation describe-stacks \
--stack-name mystaticwebsite-hosted-zone

Look for the "Outputs" JSON element in the output of the above command. It should look like this:

"Outputs": [
    {
        "OutputKey": "HostedZoneId",
        "OutputValue": "<this will be your hosted zone id>",
        "Description": "The Hosted Zone Id",
        "ExportName": "mystaticwebsite-hosted-zone-HostedZoneId"
    },
    {
        "OutputKey": "HostedZoneNameServers",
        "OutputValue": "<this will be a comma separated list of the nameservers associated with your hosted zone>",
        "Description": "The Hosted Zone Name Servers",
        "ExportName": "mystaticwebsite-hosted-zone-HostedZoneNameServers"
    }
],

Take note of the hosted zone id, and the comma separated list of hosted zone name servers.

Step 3: Set Route 53 as the DNS service for the domain

To successfully have traffic to the domain routed to the CloudFront distribution, Route 53 needs to be set as the DNS service for the domain. To achieve this, the nameservers for the domain need to be set to the nameservers for the Route 53 hosted zone created in Step 2. The nameservers for the Route 53 hosted zone can be found in the comma separated list of the "OutputValue" in the output from the CloudFormation describe CLI command in Step 2.

The domain registrar used to register the domain will have a console that can be used to update the nameservers for the domain. For example these are the instructions for Namecheap

Step 4: Create the Certificate

With the creation of the Route 53 hosted zone, and with Route 53 as the DNS service for the domain, the certificate for the static website and can be created and automatically validated by AWS Route 53. The following CloudFormation template creates the certificate using AWS ACM, however it must be created in the us-east-1 region otherwise it cannot be used with CloudFront:

AWSTemplateFormatVersion: '2010-09-09'

Parameters:
  DomainName:
    Type: String
  HostedZoneId:
    Type: String

Resources:
  Certificate:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: !Ref DomainName
      SubjectAlternativeNames:
        - !Ref DomainName
        - !Sub www.${DomainName}
      DomainValidationOptions:
        - DomainName: !Ref DomainName
          HostedZoneId: !Ref HostedZoneId
        - DomainName: !Sub www.${DomainName}
          HostedZoneId: !Ref HostedZoneId
      ValidationMethod: DNS

Outputs:
  CertificateArn:
    Description: The Certificate ARN
    Value: !Ref Certificate
    Export:
      Name: !Sub ${AWS::StackName}-CertificateArn

This is the AWS CLI command to create the stack:

aws cloudformation deploy \
--template-file acm-certificate.yaml \
--stack-name mystaticwebsite-acm-certificate \
--region us-east-1 \
--parameter-overrides \
DomainName=<your fully qualified domain name> \
HostedZoneId=<hosted zone id>

Notice the region is set to us-east-1. This is necessary otherwise the certificate won’t work with CloudFront.

Once the stack is created, run the following command to get the certificate ARN, this is required when creating the CloudFront distribution.

aws cloudformation describe-stacks \
--region us-east-1 \
--stack-name mystaticwebsite-acm-certificate

The Outputs JSON element will look like this:

"Outputs": [
    {
        "OutputKey": "CertificateArn",
        "OutputValue": "arn:aws:acm:us-east-1:111111111111:certificate/b8d0e2c9-daf7-42e8-a59b-3693bc299c32",
        "Description": "The Certificate ARN",
        "ExportName": "mystaticwebsite-acm-certificate-CertificateArn"
    }
],

Take note of the ARN which will look like this: arn:aws:acm:us-east-1:111111111111:certificate/b8d0e2c9-daf7-42e8-a59b-3693bc299c32

Step 5: Create The CloudFront Distribution

The following CloudFormation template creates the Origin Access Identity, static resources S3 bucket, and CloudFront distribution:

AWSTemplateFormatVersion: '2010-09-09'

Parameters:
  DomainName:
    Type: String
  CertificateArn:
    Type: String

Resources:
  OriginAccessIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: !Sub ${AWS::StackName}-s3-origin-oai

  StaticResourcesBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true

  StaticResourcesBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref StaticResourcesBucket
      PolicyDocument:
        Statement:
          - Effect: Allow
            Principal:
              AWS: !Sub arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${OriginAccessIdentity}
            Action: s3:GetObject
            Resource: !Sub arn:aws:s3:::${StaticResourcesBucket}/*

  Distribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Aliases:
          - !Ref DomainName
          - !Sub www.${DomainName}
        Origins:
          - DomainName: !Sub ${StaticResourcesBucket}.s3.${AWS::Region}.amazonaws.com
            Id: S3Origin
            S3OriginConfig:
              OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${OriginAccessIdentity}
        Enabled: true
        DefaultRootObject: index.html
        DefaultCacheBehavior:
          AllowedMethods:
            - DELETE
            - GET
            - HEAD
            - OPTIONS
            - PATCH
            - POST
            - PUT
          TargetOriginId: S3Origin
          ForwardedValues:
            QueryString: false
            Cookies:
              Forward: none
          ViewerProtocolPolicy: redirect-to-https
          CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
        PriceClass: PriceClass_All
        ViewerCertificate:
          AcmCertificateArn: !Ref CertificateArn
          SslSupportMethod: sni-only

Outputs:
  DistributionId:
    Description: CloudFront Distribution Id
    Value: !Ref Distribution
    Export:
      Name: !Sub ${AWS::StackName}-DistributionId
  DistributionDomainName:
    Description: CloudFront Distribution Domain Name
    Value: !GetAtt Distribution.DomainName
    Export:
      Name: !Sub ${AWS::StackName}-DistributionDomainName
  StaticResourcesBucketName:
    Description: Static Resources Bucket Name
    Value: !Ref StaticResourcesBucket
    Export:
      Name: !Sub ${AWS::StackName}-StaticResourcesBucketName

Run the following command to create the CloudFormation stack:

aws cloudformation deploy \
--template-file cloudfront-distribution.yaml \
--stack-name mystaticwebsite-cloudfront-distribution \
--parameter-overrides \
DomainName=<your domain name> \
CertificateArn=<certificate arn>

Origin Access Identity

The Origin Access Identity allows CloudFront to read from the S3 bucket without having to make the S3 bucket public.

S3 Bucket

The static content is stored in the S3 bucket from which CloudFront will serve the content. My preference is to encrypt all data whether it’s at rest or in-flight, so here serverside encryption is enabled by default. It’s data that will be publicly accessible through CloudFront, so the default AWS S3 key would appear to be adequate here despite it being a shared key across AWS accounts.

The S3 bucket is explicitly configured as private with the PublicAccessBlockConfiguration.

S3 Bucket Policy

It is necessary to give s3:GetObject permission to the Origin Access Identity so that CloudFront can request items from the S3 bucket. This means that the bucket can be kept private but that CloudFront can still access the static content within.

Cache Policy

The cache policy id for the default cache behaviour is set to the managed cache policy 658327ea-f89d-4fab-a63d-7e88639e58f6 - Managed-CachingOptimized - which means caching is enabled and CloudFront cache invalidation is required after making changes to static content files.

Try It Out

It should now be possible test the CloudFront distribution. Upload an index.html file to the S3 bucket then send an HTTP request to the CloudFront URL. The name of the S3 bucket and CloudFront URL are required. Run the following command to get the CloudFront distribution stack outputs:

aws cloudformation describe-stacks \
--stack-name mystaticwebsite-cloudfront-distribution

The output should look like the following:

"Outputs": [
    {
        "OutputKey": "DistributionId",
        "OutputValue": "<distribution Id>",
        "Description": "CloudFront Distribution Id",
        "ExportName": "mystaticwebsite-cloudfront-distribution-DistributionId"
    },
    {
        "OutputKey": "DistributionDomainName",
        "OutputValue": "d1111111111111.cloudfront.net",
        "Description": "CloudFront Distribution Domain Name",
        "ExportName": "mystaticwebsite-cloudfront-distribution-DistributionDomainName"
    },
    {
        "OutputKey": "StaticResourcesBucketName",
        "OutputValue": "mystaticwebsite-cloudfront-staticresourcesbucket-1ab0a0a0a9abc",
        "Description": "Static Resources Bucket Name",
        "ExportName": "mystaticwebsite-cloudfront-distribution-StaticResourcesBucketName"
    }
],

Take note of the OutputValue of "OutputKey": "DistributionDomainName" and "OutputKey": "StaticResourcesBucketName".

Run the following command to create a file name index.html and upload it to the S3 bucket:

echo 'My Static Content' > index.html && \
aws s3 cp index.html s3://<static resources bucket name>

With a browser, request the CloudFront URL taken from the stack output to see the content returned from CloudFront. Alternatively the following cURL command can be used:

curl https://<cloudfront distribution domain>

The response should look like this:

My Static Content

Step 6: Route Traffic To The CloudFront Distribution

A further step is required to have requests to the domain routed to the CloudFront distribution. It requires Route 53 alias records to be created:

  1. IPV4 alias record (A) pointing the root domain to the CloudFront distribution
  2. IPV4 alias record (A) pointing the www subdomain to the CloudFront distribution
  3. IPV6 alias record (AAAA) pointing the root domain to the CloudFront distribution
  4. IPV6 alias record (AAAA) pointing the www subdomain to the CloudFront distribution

The following CloudFormation template sets up the appropriate records:

AWSTemplateFormatVersion: '2010-09-09'

Parameters:
  DomainName:
    Type: String
  HostedZoneId:
    Type: String
  DistributionDomainName:
    Type: String

Resources:
  HostedZoneRecordSetGroup:
    Type: AWS::Route53::RecordSetGroup
    Properties:
      HostedZoneId: !Ref HostedZoneId
      RecordSets:
        - Name: !Ref DomainName
          Type: A
          AliasTarget:
            HostedZoneId: Z2FDTNDATAQYW2
            DNSName: !Ref DistributionDomainName
        - Name: !Sub www.${DomainName}
          Type: A
          AliasTarget:
            HostedZoneId: Z2FDTNDATAQYW2
            DNSName: !Ref DistributionDomainName
        - Name: !Ref DomainName
          Type: AAAA
          AliasTarget:
            HostedZoneId: Z2FDTNDATAQYW2
            DNSName: !Ref DistributionDomainName
        - Name: !Sub www.${DomainName}
          Type: AAAA
          AliasTarget:
            HostedZoneId: Z2FDTNDATAQYW2
            DNSName: !Ref DistributionDomainName

Run the following command to create the CloudFormation stack:

aws cloudformation deploy \
--template-file mystaticwebsite-record-set-group.yaml \
--stack-name mystaticwebsite-record-set-group \
--parameter-overrides \
DomainName=<your domain name> \
HostedZoneId=<hosted zone id> \
DistributionDomainName=<cloudfront distribution domain name>

Try it Out

Navigate to the domain name in a browser or run the following command:

curl https://<your domain name>

The following should be returned:

My Static Content

No domain no problem

If no domain is required, and hitting the CloudFront distribution domain directly is adequate for your needs, then this single CloudFormation template will suffice:

AWSTemplateFormatVersion: '2010-09-09'

Resources:
  OriginAccessIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: !Sub ${AWS::StackName}-s3-origin-oai

  StaticResourcesBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub ${AWS::StackName}-static-resources
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true

  StaticResourcesBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref StaticResourcesBucket
      PolicyDocument:
        Statement:
          - Effect: Allow
            Principal:
              AWS: !Sub arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${OriginAccessIdentity}
            Action: s3:GetObject
            Resource: !Sub arn:aws:s3:::${StaticResourcesBucket}/*

  Distribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Origins:
          - DomainName: !Sub ${StaticResourcesBucket}.s3.${AWS::Region}.amazonaws.com
            Id: S3Origin
            S3OriginConfig:
              OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${OriginAccessIdentity}
        Enabled: true
        DefaultRootObject: index.html
        DefaultCacheBehavior:
          AllowedMethods:
            - DELETE
            - GET
            - HEAD
            - OPTIONS
            - PATCH
            - POST
            - PUT
          TargetOriginId: S3Origin
          ForwardedValues:
            QueryString: false
            Cookies:
              Forward: none
          ViewerProtocolPolicy: redirect-to-https
          CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
        PriceClass: PriceClass_All

Outputs:
  DistributionId:
    Description: CloudFront Distribution Id
    Value: !Ref Distribution
    Export:
      Name: !Sub ${AWS::StackName}-DistributionId
  DistributionDomainName:
    Description: CloudFront Distribution Domain Name
    Value: !GetAtt Distribution.DomainName
    Export:
      Name: !Sub ${AWS::StackName}-DistributionDomainName
  StaticResourcesBucketName:
    Description: Static Resources Bucket Name
    Value: !Ref StaticResourcesBucket
    Export:
      Name: !Sub ${AWS::StackName}-StaticResourcesBucketName

Run the following command to create the CloudFormation stack:

aws cloudformation deploy \
--template-file cloudfront-distribution-no-custom-domain.yaml \
--stack-name mystaticwebsite-cloudfront-distribution

Run the following command to retrieve the CloudFront distribution domain:

aws cloudformation describe-stacks \
--stack-name mystaticwebsite-cloudfront-distribution

The output should look like the following:

"Outputs": [
    {
        "OutputKey": "DistributionId",
        "OutputValue": "<distribution Id>",
        "Description": "CloudFront Distribution Id",
        "ExportName": "mystaticwebsite-cloudfront-distribution-DistributionId"
    },
    {
        "OutputKey": "DistributionDomainName",
        "OutputValue": "d1111111111111.cloudfront.net",
        "Description": "CloudFront Distribution Domain Name",
        "ExportName": "mystaticwebsite-cloudfront-distribution-DistributionDomainName"
    },
    {
        "OutputKey": "StaticResourcesBucketName",
        "OutputValue": "mystaticwebsite-cloudfront-staticresourcesbucket-1ab0a0a0a9abc",
        "Description": "Static Resources Bucket Name",
        "ExportName": "mystaticwebsite-cloudfront-distribution-StaticResourcesBucketName"
    }
],

Take note of the OutputValue of "OutputKey": "DistributionDomainName" and "OutputKey": "StaticResourcesBucketName".

Run the following command to create a file name index.html and upload it to the S3 bucket:

echo 'My Static Content' > index.html && \
aws s3 cp index.html s3://<static resources bucket name>

With a browser, request the CloudFront URL taken from the stack output to see the content returned from CloudFront. Alternatively the following cURL command can be used:

curl https://<cloudfront distribution domain>

The response should look like this:

My Static Content

About the author

Hi, I'm Karl Kyck a cloud architect specialising in building sustainable serverless architectures on AWS.