Joe Gilmore

20 mins read

AWS Launching a ServerLess Cloudfront Distribution

This article shows you how to can spin up a distribution in AWS Cloudfront using the Serverless Framework

AWS Launching a ServerLess Cloudfront Distribution

Github Repo for this

You can find all the code required for this blog articles here on my github repo

What are we building here?

We are going to make a script that spins up a private S3 Bucket, and then also adds a Cloudfront (CF) Distribution that points to that bucket.

We will also make sure that we can do the following:

  • Be able to use a Custom Domain Alias e.g. cdn.example.com
  • Use an SSL Certificate from ACL (AWS Certificate Manager)
  • Use a domain we have in Route53 and auto assign a subdomain as an Alias to our CF Distribution
  • Make sure that the CloudFront Distribution CORS are only allowed from a specific URL https://www.example.com or an entire domain and subdomains https://*.example.com or anywhere on the internet *
  • Upload some test files to the S3 Bucket and make sure that they are only accessible via the CF Distribution
  • Configure a custom 404 response that simply returns an empty JSON obhject {}

Once we have spun up our S3 Bucket and CloudFront Distribution - we should be able to add files to the S3 Bucket and then access them via the CF Distro. We shouldn't be able to view them via S3 directly, and they should have the correct CORS access headers for viewing only via our specified domain!

SSL Certificates

First make sure that you have an SSL certification for your domain and subdomain (perhaps even a wildcard certificate is best) - and that it is stored in AWS Certificate Manager (ACM). You will need to copy the ARN of the certificate to use in the CloudFront Distribution.

Route53

This script will also create a Route53 record for the domain and subdomain that you specify and point it to the CloudFront Distribution.

Editing .env.example

You will need to copy the .env.example file to .env and then edit the values to match your own.

IAM_PROFILE=default
SERVICE_NAME=example-s3-cloudfront
MY_REGION=us-east-1
MY_BUCKET=example-private-bucket
MY_BUCKET_ORIGIN_ID=ExampleS3Origin
MY_DISTRO_COMMENT=Example Distro
# Set the price class to "Use Only North America and Europe" (PriceClass_200 is also europe etc, PriceClass_all is basically everywhere AWS supports)
MY_DISTRO_PRICE_CLASS=PriceClass_100 
# Allowed Origins  Can be either a single * or https://*.example.com or https://www.example.com - Read more here https://docs.aws.amazon.com/AmazonS3/latest/userguide/ManageCorsUsing.html - if you wish to use multiple then edit the serverless.yml file itself
ALLOWED_ORIGINS=*

# optional 1 - using a domain alias (also comment out lines 77-81 of serverless.yml if not using this)
MY_DOMAIN_ALIAS=subdomain.example.com
MY_ACM_CERTIFICATE_ARN=arn:aws:acm:us-east-1:1111111111:certificate/5555555-1111-2222-3333-b82099e490bd # Your SSL Cert found in https://us-east-1.console.aws.amazon.com/acm/home?region=us-east-1

# optional 2 - get route 53 to assign our domain alias automatically (also comment out lines 100-117 of serverless.yml if not using this)
MY_ROOT_DOMAIN=example.com

Please note - You don't have to use a .env file and you could simply edit the serverless.yml file directly, but I find it easier to use a .env file for my own values and then edit the serverless.yml file directly for any other values I need to change.

The ServerLess Script

service: ${env:SERVICE_NAME}
useDotenv: true

provider:
  name: aws
  profile: ${env:IAM_PROFILE}
  region: ${env:MY_REGION}
  runtime: nodejs18.x
  
resources:
  Resources:
    # The S3 Bucket
    MyS3Bucket:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: ${env:MY_BUCKET}
        AccessControl: Private # Set the bucket access control to private

        # https://docs.aws.amazon.com/AmazonS3/latest/userguide/ManageCorsUsing.html
        CorsConfiguration:
          CorsRules:
            - AllowedOrigins:
                - ${env:ALLOWED_ORIGINS}
              AllowedMethods:
                - HEAD
                - GET
              AllowedHeaders:
                - "*"

    # The S3 Bucket Policy
    PrivateBucketPolicy:
      Type: AWS::S3::BucketPolicy
      Properties:
        PolicyDocument:
          Id: MyPolicy
          Version: "2012-10-17"
          Statement:
            - Sid: AllowCloudFrontServicePrincipalReadOnly
              Effect: Allow
              Principal: { "Service": "cloudfront.amazonaws.com" }
              Action:
                - s3:GetObject
              Resource: !Sub arn:aws:s3:::${MyS3Bucket}/*
              Condition:
                StringEquals:
                  "aws:SourceArn": !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${MyCloudFrontDistribution}
        Bucket:
          Ref: MyS3Bucket
    OriginAccessControl:
      Type: AWS::CloudFront::OriginAccessControl
      Properties:
        OriginAccessControlConfig:
          Name: ${env:MY_BUCKET_ORIGIN_ID}
          OriginAccessControlOriginType: s3
          SigningBehavior: always
          SigningProtocol: sigv4

    # The Cloud Front Distribution
    MyCloudFrontDistribution:
      Type: AWS::CloudFront::Distribution
      Properties:
        DistributionConfig:
          Enabled: true
          IPV6Enabled: true
          Comment: ${env:MY_DISTRO_COMMENT} # Add a description to the CloudFront distribution
          PriceClass: ${env:MY_DISTRO_PRICE_CLASS} # Set the price class to "Use Only North America and Europe"

          #optional 1 - Custom Domain alias - Comment out the 5 lines below if you don not want to use the domain alias and instead just use xxxxxxxx.cloudfront.net
          Aliases:
            - ${env:MY_DOMAIN_ALIAS} # Replace with your desired CloudFront distribution alias (Make sure you have a cert in "AWS Certificate Manager (ACM)" in the us-east-1 region)
          ViewerCertificate:
            AcmCertificateArn: ${env:MY_ACM_CERTIFICATE_ARN} # Replace with your ACM certificate ARN
            SslSupportMethod: sni-only

          Origins:
            - DomainName: ${env:MY_BUCKET}.s3.${env:MY_REGION}.amazonaws.com
              Id: ${env:MY_BUCKET_ORIGIN_ID}
              S3OriginConfig: {}
              OriginAccessControlId: !GetAtt OriginAccessControl.Id
          DefaultCacheBehavior:
            AllowedMethods: [GET, HEAD, OPTIONS]
            TargetOriginId: ${env:MY_BUCKET_ORIGIN_ID}
            CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # No caching
            OriginRequestPolicyId: 88a5eaf4-2fd4-4709-b370-b4c650ea3fcf # CORS-S3
            ResponseHeadersPolicyId: 67f7725c-6f97-4210-82d7-5512b31e9d03 # SecurityHeadersPolicy
            ViewerProtocolPolicy: redirect-to-https
          
          # Optional - Default Response example
          DefaultRootObject: index.json
          
          ## Optional - Any 403, and 404's will go directly to /404.json (just an example)
          CustomErrorResponses:
            - ErrorCode: 403
              ResponseCode: 200 
              ResponsePagePath: /404.json 
            - ErrorCode: 404
              ResponseCode: 200 # friendly response in this example
              ResponsePagePath: /404.json 
          

          HttpVersion: http2

  # optional 2 - Route53 Auto Assign subdomain - Comment out the below if you do not want Route53 to update DNS and add a record for your new subdomain        
    Route53RecordIPv4:
      Type: AWS::Route53::RecordSet
      Properties:
        HostedZoneName: ${env:MY_ROOT_DOMAIN}.
        Name: ${env:MY_DOMAIN_ALIAS}.
        Type: A
        AliasTarget:
          DNSName: !GetAtt MyCloudFrontDistribution.DomainName
          HostedZoneId: Z2FDTNDATAQYW2
    Route53RecordIPv6:
      Type: AWS::Route53::RecordSet
      Properties:
        HostedZoneName: ${env:MY_ROOT_DOMAIN}.
        Name: ${env:MY_DOMAIN_ALIAS}.
        Type: AAAA
        AliasTarget:
          DNSName: !GetAtt MyCloudFrontDistribution.DomainName
          HostedZoneId: Z2FDTNDATAQYW2

  ## In order to print out the hosted domain via `serverless info` we need to define the DomainName output for CloudFormation
  Outputs:
    MyCloudFrontDomainName:
      Value: "Fn::GetAtt": [MyCloudFrontDistribution, DomainName]

Optional - Read back outputs from CLI:

This is optional but allows us to get back the CloudFront domain name

aws cloudformation --profile IAM_PROFILE  --region REGION describe-stacks --stack-name SERVICE_NAME-STAGE --query "Stacks[0].Outputs"

Uploading files to the S3 Bucket

You can either upload your testing files manually, or you can use the AWS CLI to upload them for you like this:

aws s3 cp ./test-files/ s3://example-private-bucket/ --recursive --profile default --region us-east-1

Testing our files:

fetch('https://subdomain.example.com/test.json')
	.then(response => response.text())
	.then(data => console.log(data));

CORS Errors

If you ALLOWED_ORIGINS is set to * then any domain should be able to access the test files. Howver if you've set a value, then you'll need to be testing from the allowed domains.

Adding multiple origins:

To do this you will need to edit the serverless file directly and add other domains?

	CorsConfiguration:
		CorsRules:
			- AllowedOrigins:
					- ${env:ALLOWED_ORIGINS}
					- https://www.seconddomain.com

Can I add localhost to the allowed origins?

Yes you can add localhost to the allowed origins, but you will need to make sure that you are using the correct port number. For example if you are running a local server on port 3000 then you will need to add http://localhost:3000 to the allowed origins.

	CorsConfiguration:
		CorsRules:
			- AllowedOrigins:
					- ${env:ALLOWED_ORIGINS}
					- https://www.seconddomain.com
					- http://localhost:3000

Adding CloudFlare

If you have a domain being managed in CloudFlare instead of Route 53, then to spin this up you want to do the following:

  1. You will still need a certificate in ACM for your domain and subdomain, so grab this ARN (this is required for the domain Alias still even if using CloudFlares SSL proxy) and set the MY_ACM_CERTIFICATE_ARN
  2. Comment out the Route53RecordIPv4 and Route53RecordIPv6 sections in the serverless.yml file
  3. Also comment out MY_ROOT_DOMAIN in the .env file
  4. Deploy the stack and note the CloudFront Distribution domain name (you can get this via the CLI command above) - it looks like xxxyyyzzz123.cloudfront.net
  5. Open CloudFlare and add a CNAME record for the subdomain you specified in the .env file and point it to the CloudFront Distribution domain name