CloudFormation: Create a CloudFront Distribution with a Custom Domain and SSL

Posted on | ~10 mins
aws s3 insfrastructure-as-code cloudformation cloudfront

Static website hosting on S3 is great. However, I did not find a way how to set up SSL there. Further, I wanted to have multiple urls (e.g. example.org and example.com) point to this one bucket without much manual effort. All this can be done with CloudFront (Amazon’s content delivery network).

This posts describes how to set up with CloudFormation the following:

  • an S3 bucket,
  • an S3 bucket policy that restricts access to this bucket just to CloudFront,
  • a CloudFront Distribution that points to the S3 bucket,
  • and finally, DNS entries in Route53 that point the real domains to the CloudFront URL.

CloudFormation let’s you provision AWS resources in a declarative manner. You write a YML (or JSON if you are a masochist), which describes which resources you want and how they are interconnected. Then, CloudFormation builds an acyclic graph and figures out what to provision in what order for you.

However, first some one-off manual work is required. It needs to happen only once (per account). And it is easier to do it via web interface than via CLI.

Create A CloudFront Origin Access Identity

It is possible to restrict access to your S3 bucket to your CloudFront distribution only. Once we add the access identity to the bucket policy, we don’t need to enable static website hosting or any further permissions. This is very handy. Go to the AWS Console to the CloudFront service. There, you can find the point Origin Access Identity. Or use this link (change your region if necessary as I am using Ireland).

There, create a new one and give it a name in the comment. Note down both the ID as well as the Canonical User ID for later use.

Mine look like that:

ID: E3MPZH9RAHAGMC
Amazon S3 Canonical User ID: ebb80ad516e7bac67c7cafcd9d33868d...

Create a SSL Certificate (Optional: For Multiple Domains)

One of the main points of this exercise is to be able to serve traffic via HTTPS. In order to do so, we need to either add an existing or create a free SSL certificate in the Certificate Manager. It is important to switch the region to North Virginia (us-east-1) as that is the only region CloudFront can get its certificates from Here is a link to the right place. Either upload an existing certificate or create a new one using the wizard. I have my domains in Route53 so I will use the wizard. What I discovered is that it is possible to add multiple top-level domains to the same certificate in here. This is really cool because each CloudFront distribution can only take one certificate and I was worried that I would have to create multiple distributions.

Add your domains or subdomains (the asterisk is usually a good idea such as *.example.com). Then follow the steps to verify them. If you have them in Route53 as I do, it is really easy. Just use the DNS verification method and then click on each domain the green button, which adds the necessary verification info to Route53. 10 minutes later your certificate should be all green.

Note down the ARN of the certificate for further use:

arn:aws:acm:us-east-1:123456789012:certificate/364912a52-3115-4df9-a067-7290c0a2657s

CloudFormation

In my opinion, creating a CloudFront distribution with CloudFormation is one of the more complicated tasks. Mostly because there are many options, the documentation is all over the place and not very clear. Other resources seem to me somehow way more pleasant. Anyways, let’s start with the simpler tasks.

S3 Bucket

Let’s create a simple S3 bucket and give it a name whichever you like (has to be S3-wide unique though).

1
2
3
4
S3Bucket:
    Type: AWS::S3::Bucket
    Properties: 
      BucketName: test-bucket

Bucket Access Policy

Now we want to grant access to the CloudFront Distribution into our bucket. For that, one needs to add Canonical User ID noted from above.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
S3BucketPolicy:
    Type: "AWS::S3::BucketPolicy"
    Properties: 
      Bucket: !Ref S3Bucket
      PolicyDocument:
        Version: '2012-10-17'
        Statement: 
        - 
          Action: 
            - "s3:GetObject"
          Effect: "Allow"
          Resource: 
            Fn::Join: 
              - ""
              - 
                -  !GetAtt S3Bucket.Arn
                - "/*"
          Principal:
            CanonicalUser: ebb80ad516e7bac67c7cafcd9d33868d...

Details:

  • Line 4: Reference to the S3Bucket we just created. Means the policy is for that bucket.
  • Lines 13-17: This will actually end up being a string that is looks like this: arn:aws:s3:::test-bucket/*.
  • Lines 18-19: Principal defines for which user this policy is. It is for the origin user that we created in the access origin identity step. It is quite long and I have shortened it here.

CloudFront Distribution

This is quite a long one but I will explain the interesting points line by line. The lines which you will have to adapt to your own setup are highlighted.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
TestDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Origins:
        - DomainName: test-bucket.s3-eu-west-1.amazonaws.com
          Id: s3ProductionBucket
          S3OriginConfig:
            OriginAccessIdentity: origin-access-identity/cloudfront/E3MPZH9RAHAGMC
        Enabled: 'true'
        Comment: TestDistribution
        DefaultRootObject: index.html
        CustomErrorResponses:
        - ErrorCode: 403
          ResponseCode: 200
          ResponsePagePath: /index.html
        Aliases:
        - example.net
        - www.example.net
        - example.com
        - www.example.com
        DefaultCacheBehavior:
          AllowedMethods:
          - GET
          - HEAD
          Compress: true
          TargetOriginId: s3ProductionBucket
          ForwardedValues:
            QueryString: 'false'
            Cookies:
              Forward: none
          ViewerProtocolPolicy: redirect-to-https
        PriceClass: PriceClass_100
        ViewerCertificate:
          AcmCertificateArn: arn:aws:acm:us-east-1:123456789012:certificate/364912a52-3115-4df9-a067-7290c0a2657s
          MinimumProtocolVersion: TLSv1.1_2016
          SslSupportMethod: sni-only

Explanations:

  • Lines 5-6: As CloudFront is a content delivery network, it needs a source where to get the files from. In this case, it is called origin and it is our S3 bucket. Some caution is necessary with the domain same. You could in theory omit your region and just write test-bucket.s3.amazonaws.com as your endpoint. However, since I create a new bucket in the same go, I ran into forwarding problems that are explained here for example. It doesn’t hurt to also specify the region as it seemed to avoid that problem for me.
  • Line 7: You can give the ID any name. Just make sure that line 27 refers back to this name.
  • Line 9: We have created a bucket policy that only a certain ID can access the S3 bucket. At it here from the CloudFront Dashboard.
  • Lines 13-16: If a user requests an object or URL that is not in the S3 bucket, you get a 403 error. In that case, I just return the index page. An alternative would be to return a 404 not found error and point to a custom error page.
  • Lines 17- 21: Aliases determine which domain names the CloudFront Distribution should react to. I added here both my normal domains as well as their www. counterparts.
  • Lines 23-25: As I have a completely static website, I only allow the HEAD and GET methods (this is the minimum). You can add more but might not make sense for an S3 page.
  • Line 26: Turns on compression. CloudFront will compress your files with gzip, which is nice.
  • Lines 28-31: Whether forward cookies or any parameters to the origin. S3 can’t handle either - no use in that. Off with it.
  • Line 32: CloudFront should redirect all http requests to https.
  • Line 33: Price class determines how many regions are used when distributing your content. Price class 100 includes the USA, Canada and Europe. Other classes can be found in the middle of this document.
  • Lines 34-37: Since we want to offer SSL, we need to specify a few things. First, we need to paste in the ARN of the newly created certificate in the beginning. Then, we need to set the minimum supported protocol. The TLSv1.1 version was recommended, but you can chose a lower one. Finally, we need to tell that we don’t have a dedicated IP (which costs 600$ per month) and that server name indication is enough.

Route53 DNS Entries

Now, let’s do the final step and add some DNS alias (type A) entries. Repeat that for each of the domains you want to point to.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
ExampleNETWWWAlias:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneName: example.net.
      Name: www.example.net.
      Type: A
      AliasTarget:
        HostedZoneId: Z2FDTNDATAQYW2
        DNSName: !GetAtt TestDistribution.DomainName
  
  ExampleNETAlias:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneName: example.net.
      Name: www.example.net.
      Type: A
      AliasTarget:
        HostedZoneId: Z2FDTNDATAQYW2
        DNSName: !GetAtt TestDistribution.DomainName

Details:

  • Line 4: Each Route53 domain has its own hosted zone. This zone can be found either by a ZoneID or with a name. Pay attention that the HostedZoneName needs to end with a period.
  • Line 5: What is going to be the real domain name that you enter in the browser.
  • Line 6: The type is alias. It means that we use one name but actually forward to another.
  • Lines 7-9: We need to say where to forward our request to. The HostedZoneId is found in the AWS documentation and hard-coded for all CloudFront distributions. The DNS name can be fetched from the freshly-created distribution. When resolved, it looks something like this: d1k79hn1918dmm.cloudfront.net.

Send the YAML to Cloud Formation

At the end of this article, you will find the full example YAML. To send it to CloudFormation, call the CLI with the following command.

aws cloudformation create-stack --stack-name cloudfront-test --template-body file://cloudformation.yml

You can then check in the CloudFormation console if there are any errors and the progress. Usually, I would say, it takes 20 minutes till your distribution is created. So you don’t want to make changes often there.

As a tip: You may want to add parts to this script bit by bit. First, let’s say you create the bucket and bucket policy. If that is successful, uncomment the distribution and run the command above with update-stack instead of create-stack. Lastly, add the Route53 entries. This way, if something goes wrong, the error rollback happens to the last step and not to completely zero. It saves a lot of time especially with such tedious resources as the cloudfront distribution, which needs as much time to be deleted as created.

Upload Data to the S3 Bucket

When you put data into the S3 bucket, I recommend to add a cache-control max-age header. This makes sure that browser caching is enabled but also that CloudFront can cache that file for the same period. If you want to dig into the options, here is a link. An example command to upload files from the public folder:

aws s3 sync public/ s3://test-bucket --delete  --cache-control max-age=86400

The max-age=86400 is one day in seconds and the --delete option makes sure that old files don’t remain in the bucket which are not present in the public folder.

Invalidating Caches

When you make changes in the S3 bucket and want that CloudFront serves them right away (and not only after the caching period is over), you can use this command to invalidate all caches:

aws cloudfront create-invalidation --distribution-id $CLOUDFRONT_ID --paths /\*

You can find the right Cloudfront distribution ID from the web dashboard.

Conclusion

This was a learning experience in the area of infrastructure as code. CloudFormation is quite handy when you need to recreate a similar infrastructure setup multiple times or don’t want to do everything in the web interface. However, in the end you end up editing YAML files, send them to CloudFormation, wait for an error to appear, then change them again and so on. Programming languages have way more static code checks so the development process is much more rapid. I hope this post helped a bit to make the creation of a CloudFront Distribution with CloudFormation easier.

Appendix: Full CloudFormation YAML

AWSTemplateFormatVersion: '2010-09-09'
Resources:
  S3Bucket:
      Type: AWS::S3::Bucket
      Properties: 
        BucketName: test-bucket

  S3BucketPolicy:
    Type: "AWS::S3::BucketPolicy"
    Properties: 
      Bucket: !Ref S3Bucket
      PolicyDocument:
        Version: '2012-10-17'
        Statement: 
        - 
          Action: 
            - "s3:GetObject"
          Effect: "Allow"
          Resource: 
            Fn::Join: 
              - ""
              - 
                -  !GetAtt S3Bucket.Arn
                - "/*"
          Principal:
            CanonicalUser: ebb80ad516e7bac67c7cafcd9d33868d...

  TestDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Origins:
        - DomainName: test-bucket.s3-eu-west-1.amazonaws.com
          Id: s3ProductionBucket
          S3OriginConfig:
            OriginAccessIdentity: origin-access-identity/cloudfront/E3MPZH9RAHAGMC
        Enabled: 'true'
        Comment: TestDistribution
        DefaultRootObject: index.html
        CustomErrorResponses:
        - ErrorCode: 403
          ResponseCode: 200
          ResponsePagePath: /index.html
        Aliases:
        - example.net
        - www.example.net
        - example.com
        - www.example.com
        DefaultCacheBehavior:
          AllowedMethods:
          - GET
          - HEAD
          Compress: true
          TargetOriginId: s3ProductionBucket
          ForwardedValues:
            QueryString: 'false'
            Cookies:
              Forward: none
          ViewerProtocolPolicy: redirect-to-https
        PriceClass: PriceClass_100
        ViewerCertificate:
          AcmCertificateArn: arn:aws:acm:us-east-1:123456789012:certificate/364912a52-3115-4df9-a067-7290c0a2657s
          MinimumProtocolVersion: TLSv1.1_2016
          SslSupportMethod: sni-only

  ExampleNETWWWAlias:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneName: example.net.
      Name: www.example.net.
      Type: A
      AliasTarget:
        HostedZoneId: Z2FDTNDATAQYW2
        DNSName: !GetAtt TestDistribution.DomainName
  
  ExampleNETAlias:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneName: example.net.
      Name: www.example.net.
      Type: A
      AliasTarget:
        HostedZoneId: Z2FDTNDATAQYW2
        DNSName: !GetAtt TestDistribution.DomainName

  ExampleCOMWWWAlias:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneName: example.com.
      Name: www.example.com.
      Type: A
      AliasTarget:
        HostedZoneId: Z2FDTNDATAQYW2
        DNSName: !GetAtt TestDistribution.DomainName
  
  ExampleCOMAlias:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneName: example.com.
      Name: www.example.com.
      Type: A
      AliasTarget:
        HostedZoneId: Z2FDTNDATAQYW2
        DNSName: !GetAtt TestDistribution.DomainName