Deploy a static website with AWS CloudFormation

Hosting a static website involves a various set of AWS resources depending on our requirements. While all the actions defined here can be done via the web console or, with some faith and hardship, through the CLI, we are going to take the CloudFormation route.

CloudFormation is basically the Infrastructure as Code (Wiki link) face of AWS.

When DNS entries are involved, I split, when relevant, the examples in two depending on where DNS entries are handled: Route 53 or Cloudflare.

CloudFormation, the theory

CloudFormation relies on a template file, written in JSON or YAML. Such template declares all the AWS resources we will need, how they are configured and, subjected to some limitations, how they are related to each other.

CloudFormation can be managed through the CLI or the web console.

Template overview

The overall structure of template.yml is following the CloudFormation template anatomy:

AWSTemplateFormatVersion: "version date"

Description: String

Parameters: # set of parameters

Mappings: # set of mappings

Conditions: # set of conditions definition

Resources: # set of resources

Cloudformation empty consoleCloudFormation empty console

The various examples below help illustrating the purpose of each section. For a more formal approach, some useful links:

AWS documentation:

Other notes:

Functions

Some functions will be used to ease the template definitions. Those functions will be explained when encountered in the templates below.

Resources documentation

As I spend quite a lot of times to find back "what are the properties of this resources", here is a list of all resources CloudFormation documentation:

CLI

The most useful command is aws cloudformation deploy --stack-name <stack name>. Such command is convenient:

  • it automatically creates a stack if the provided stack name does not exist
  • it automatically updates the stack matching the provided stack name.

For a more step-by-step control, stacks can be manually created (aws cloudformation create-stack) and deleted (aws cloudformation delete-stack). During the creation or updates, AWS generates change-sets which sequentially defines the list of actions to meet the template requirements.

Precautious fellows can check the change sets before having them executed:

  • aws cloudformation create-change-set --stack-name <stack name> --change-set-name <change set name> --template-body ... create a change set in a given stack but does not execute it
  • aws cloudformation describe-change-set --stack-name <stack name> --change-set-name <change set name> lists all the operations the change set will trigger if executed.
  • aws cloudformation execute-change-set --stack-name <stack name> --change-set-name <change set name> finally execute the change set and the stack will be updated according to the new template definition

AWS Documentation:

Basic HTTP hosting

Hosting a static web on AWS starts with a single S3 bucket. Such website is restricted to have a specific URL: http://{bucket name}.s3-website.{region}.amazonaws.com which is more than enough for testing some simple websites.

The simplicity of a basic hosting is a good opportunity to do a soft introduction to CloudFormation template.

Step by step template definition

All we need is a simple S3 bucket with a website configuration. The bucket content is fully public and such access can be controlled by a bucket policy. The CloudFormation template can be drafted as:

AWSTemplateFormatVersion: 2010-09-09

Resources:
  S3Hosting:
    Type: AWS::S3::Bucket
    Properties:
      # TODO

  S3HostingBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      # TODO

S3Hosting and S3HostingBucketPolicy are the logical ID of the S3 bucket and the bucket policy resources. Logical ID are arbitrary and let's try to have them as explicit as possible.

Filling the properties boils down to googling the properties list and pick up the relevant ones. The Resources documentation section is a list of such documentation links.

If we want to name our bucket bw-hosting-basic, our template can be defined as

AWSTemplateFormatVersion: 2010-09-09

Resources:
  S3Hosting:
    Type: AWS::S3::Bucket
    Properties:
      # That's our bucket name
      BucketName: bw-hosting-basic
      # We declare this bucket a website hosting, similar to the
      # website configuration in the properties tab in the web console
      WebsiteConfiguration:
        ErrorDocument: 404.html
        IndexDocument: index.html

  S3HostingBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      # This policy is declared for the bucket above
      Bucket: bw-hosting-basic
      PolicyDocument:
        Id: MyPolicy
        Version: 2012-10-17
        Statement:
          # The Sid is an arbitrary value
          - Sid: PublicReadForGetBucketObjects
            # Public access so fetching an object is allowed for everything
            Effect: Allow
            Principal: "*"
            Action: s3:GetObject
            # This policy is limited to the bucket defined in this stack, hey,
            # it is not open-bar!
            Resource: arn:aws:s3:::bw-hosting-basic/*

Nice! But it is not very DRY: bw-hosting-basic is written three times. Let's put the bucket name into a BucketName parameter with bw-hosting-basic as a default value. A parameter value can be overridden during the CloudFormation stack deployment.

AWSTemplateFormatVersion: 2010-09-09

Parameters:
  BucketName:
    Type: String
    Default: bw-hosting-basic

Resources:
  # ...

The BucketName parameter is a variable that we have to interpolate in our template. !Ref function enters the scene. To compare with SCSS syntax, writing !Ref someVarName is similar to #{$someVarName} in SCSS grammar. Our bucket definition becomes:

# ...

Resources:
  S3Hosting:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref BucketName
      WebsiteConfiguration:
        ErrorDocument: 404.html
        IndexDocument: index.html

Each AWS resource will return a specific value when called upon a !Ref function. For example !Ref {a bucket logical ID} returns the bucket name. Other resources may have other values such as the ARN.

!Ref ensures that our bucket policy is applied to the correct bucket:

# ...
S3HostingBucketPolicy:
  Type: AWS::S3::BucketPolicy
  Properties:
    Bucket: !Ref S3Hosting
    PolicyDocument:
      # How to call !Ref in "arn:aws:s3:::bw-basic-hosting/*"?

AWS documentation makes use of the !Join function:

S3HostingBucketPolicy:
  Properties:
    # ...
    PolicyDocument:
      Id: MyPolicy
      Version: 2012-10-17
      Statement:
        - Sid: PublicReadForGetBucketObjects
          Effect: Allow
          Principal: "*"
          Action: "s3:GetObject"
          Resource: !Join
            - ""
            - - "arn:aws:s3:::"
              - !Ref S3Hosting
              - /*

I prefer the !Sub syntax: !Sub arn:aws:s3:::${S3Hosting}/*.

The end result is the template-basic.yaml.

Note: although the different tutorials and guides add AccessControl: PublicRead in the S3Hosting bucket definition, I did not need it so I removed it.

Deployment

It is now time to deploy! Deploying is as simple as

# Feel free to change "bw-hosting-basic" to whatever value
aws cloudformation deploy --stack-name bw-hosting-basic --template-file template-basic.yaml

This command will block the terminal until the stack creation is completed:

CLI deployment in progress

To deploy the same stack with a different bucket name, the bucket name parameter has to be overridden:

# I changed the stack name. If the stack name was bw-hosting-basic, it would have updated the
# previous stack
aws cloudformation deploy --stack-name bw-hosting-basic-custom-name --template-file template-basic.yaml --parameter-overrides BucketName=some-other-name

Stack creation progress can be checked in the web console:

Stack deployment in progress

Select the stack and check the Events tab to follow the creation steps:

Creation in progressCreation completed
Stack deployment creation detailsStack deployment creation details

When stack deployment is finished, you get back the control of the terminal:

CLI deployment in progress

To check what name the bucket is currently having:

aws cloudformation describe-stack-resource --stack-name bw-hosting-basic --logical-resource-id S3Hosting

Time to upload some content:

# Use the CLI to send some content to a S3 bucket
aws s3 sync website s3://bw-basic-hosting --profile alun

Website upload

S3 content can be checked in the web console:

Website content check

# Open the URL with firefox
firefox http://bw-basic-hosting.s3-website.eu-west-3.amazonaws.com/

The website has two pages: /index.html and /about.html. The former ensures that / properly serves the index page and the latter checks that non-SPA websites are properly served:

Index pageAbout page
It works!About page works!

Stack deletion

S3 buckets cannot be deleted if not empty. The stack deletion then requires two actions:

aws s3 rm --recursive s3://bw-hosting-basic

S3 bucket content deletion

aws cloudformation delete-stack --stack-name bw-hosting-basic

Stack deletion

Unlike stack creation, stack deletion is not blocking: the command is fired and the terminal is not blocked. It also means that errors might not shown up so better check in the web console if the deletion went fine:

Stack deletion

Custom URL/domain

All systems online! but online behind a not-very-user-friendly-URL.

With AWS Route53

This section specificity is that the bucket name must match the target domain name: example.com must be hosted in a bucket with the same name. It also applies to subdomains: subdomain.example.com must be hosted in a bucket with exactly the same name.

This template can be built from the previous template:

  • BucketName parameter is renamed DomainName to be more explicit
  • Unlike AWS template snippets, I had to keep the S3 bucket policy
  • Two more resources are added: the S3 bucket for redirection and Route 53 entries

The S3 redirection bucket, typically redirecting www.example.com to example.com, is simply defined as follow:

S3Redirection:
  Type: AWS::S3::Bucket
  Properties:
    AccessControl: BucketOwnerFullControl
    BucketName: !Sub www.${DomainName}
    WebsiteConfiguration:
      RedirectAllRequestsTo:
        HostName: !Ref S3Hosting

Note: I am not sure if the AccessControl can be dropped or not as I just copied from AWS snippet

The Route 53 DNS entries handles both domain example.com and www.example.com by pointing them to the appropriate resources. The following code is simply copied from AWS snippet.

DNSEntries:
  Type: AWS::Route53::RecordSetGroup
  Properties:
    HostedZoneName: !Ref RootDomainName
    RecordSets:
      - AliasTarget:
          HostedZoneId: !FindInMap [S3Regions, !Ref "AWS::Region", ZoneId]
          DNSName: !FindInMap [S3Regions, !Ref "AWS::Region", Endpoint]
        Name: !Ref DomainName
        Type: A
      - Name: !Sub www.${DomainName}
        ResourceRecords:
          - !GetAtt S3Redirection.DomainName
        Type: CNAME
        TTL: 900

The final template is template-domain-aws.yaml.

Deployment and stack deletion commands are the same:

aws cloudformation deploy --stack-name bw-hosting-domain-aws --template-file template-domain-aws.yaml
aws s3 sync website s3://bw-domain-aws.al-un.fr

aws s3 rm --recursive s3://bw-domain-aws.al-un.fr
aws cloudformation delete-stack --stack-name bw-hosting-domain-aws

References:

With CloudFlare

Similarly to the previous example, the bucket name must match the target domain name. As Route 53 is not used, the RootDomainName parameter is now unnecessary.

Parameters:
  DomainName:
    Type: String
    Default: bw-hosting-domain-cloudflare.al-un.fr

CloudFlare has to do Route 53's job: handle a CNAME entry (such as example.com or app.example.com) pointing to the S3 bucket website endpoint (such as example.com.s3-website..amazonaws.com or app.example.com.s3-website..amazonaws.com).

When editing DNS entries, don't forget to strip out ending slashes and the protocol (no http://).

This can easily be done in the web interface by creating a DNS entry:

  • type: CNAME
  • name: your subdomain name or @ for the root domain (such as example.com)
  • target: the S3 bucket website endpoint
  • TTL: automatic or any desired value

Such DNS entry creation can also be performed with the Cloudflare API. A token with DNS edition permission on the appropriate domain is required. To create the DNS record (Cloudflare docs)

curl "https://api.cloudflare.com/client/v4/zones/<zone id>/dns_records" -X POST \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
--data @- <<'EOF'
{
  "type": "CNAME",
  "name": "bw-hosting-domain-cloudflare",
  "content": "bw-hosting-domain-cloudflare.al-un.fr.s3-website.eu-west-3.amazonaws.com",
  "ttl": 1
}
EOF

References:

Deployment (Cloudflare example)

Stack creation and upload to S3 commands are the same:

aws cloudformation deploy --stack-name bw-hosting-domain --template-file template-domain-cloudflare.yaml
# Bucket name is copied from the template
aws s3 cp --recursive website s3://bw-hosting-domain-cloudflare.al-un.fr

Stack creation and S3 content upload

Create the CNAME record either via the CLI or the web console:

CNAME record creation in CloudFlare

Both page are now served under bw-hosting-domain-cloudflare.al-un.fr:

Index pageAbout page
Index page works!About page works!

Stack deletion steps are identical to the basic HTTP hosting stack deletion steps.

HTTPS redirection

A custom URL is nicer with HTTPS right? This is where we need CloudFront.

With AWS Route 53

Certificate stack

From June 2020, CloudFormation can automatically create the DNS entries to validate a certificate request. However, if the stack is deleted, only the certificate is deleted. The DNS entries used to validate the domain remain and must be manually deleted if required.

To be accessible to CloudFront, AWS Certificates must be created in North Virginia (us-east-1). An option is to create a dedicated stack. As only the certificate is created in us-east-1, the stack is light:

AWSTemplateFormatVersion: 2010-09-09

Resources:
  Certificate:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: app.example.com
      DomainValidationOptions:
        - DomainName: app.example.com
          HostedZoneId: <example.com. ZoneID>
      ValidationMethod: DNS

The template-https-aws-acm.yaml is an example of a single certificate validating two sub domains of the same root domains.

This template example can be improved by having some parameters

# Create the stack in us-east-1. From now on, it is recommended to pass
# the --region argument to ensure that we are hitting the right spot
aws cloudformation deploy --stack-name bw-https-aws-acm --template-file template-https-acm.yaml --region us-east-1

# Describe the certificate to check the Certificate ARN
aws cloudformation describe-stack-resource --stack-name bw-https-aws-acm --logical-resource-id Certificate --region us-east-1

Alternatively, we can use the Output section in the template. Although it sounds overkill as the certificate ARN is easy to find, the output would look like:

Outputs:
  CertificateArn:
    Value: !Ref Certificate
    Description: "Certificate ARN"

Main stack

The HTTPS redirection is managed by CloudFront. CloudFront can serve a S3 bucket by identifying itself with a CloudFront origin identity. Some changes are worth to note:

  • S3 bucket name is not tied to the domain anymore
  • S3 bucket does not need a website configuration anymore
Parameters:
  CertificateArn:
    Type: String
    Description: Certificate must be created before CloudFormation stack so the value is fixed
  BucketName:
    Type: String
  DomainName:
    Type: String

Resources:
  S3Hosting:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref BucketName

  # Declare the identity of our CloudFront distribution
  CDNOriginIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: !Sub "Cloudfront Origin identity for ${DomainName}"

  CDN:
    Type: "AWS::CloudFront::Distribution"
    Properties:
      DistributionConfig:
        Aliases:
          - !Ref DomainName
        DefaultCacheBehavior:
          # ... some configs are omitted for brievity
          TargetOriginId: !Sub "S3-origin-${S3Hosting}"
          # This is where we define the HTTPS redirection
          ViewerProtocolPolicy: redirect-to-https
        DefaultRootObject: index.html
        Enabled: True
        Origins:
          - DomainName: !GetAtt S3Hosting.RegionalDomainName
            Id: !Sub "S3-origin-${S3Hosting}"
            S3OriginConfig:
              OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${CDNOriginIdentity}"
        PriceClass: PriceClass_100 # PriceClass_100 / PriceClass_200 / PriceClass_All
        # The SSL certificate used
        ViewerCertificate:
          AcmCertificateArn: !Ref CertificateArn
          MinimumProtocolVersion: TLSv1.2_2018
          SslSupportMethod: sni-only

Note about DomainName: !GetAtt S3Hosting.RegionalDomainName: see this StackOverflow post

The S3 bucket policy can now restrict access to the CloudFront distribution only:

Resources:
  S3HostingBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref S3Hosting
      PolicyDocument:
        Statement:
          - Action:
              - "s3:GetObject"
            Effect: Allow
            Principal:
              AWS: !Sub "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${CDNOriginIdentity}"
            Resource: !Sub "arn:aws:s3:::${S3Hosting}/*"
        Version: "2012-10-17"

The last step is to create a DNS entry with Route 53 to have a nice URL such https://app.example.com rather than some xxxxxxxxxxxxx.cloudfront.net:

Resources:
  DNS:
    Type: AWS::Route53::RecordSetGroup
    Properties:
      HostedZoneName: !Ref RootDomainName
      RecordSets:
        - AliasTarget:
            DNSName: !GetAtt CDN.DomainName
            HostedZoneId: Z2FDTNDATAQYW2 # CloudFront hosted zone ID is fixed
          Name: !Ref DomainName
          Type: A

The deployment process and content upload to S3 is the same as the previous example.

With Cloudflare

Note: I am using CloudFlare SSL certificate so the actual certificate is not delivered by Amazon but CloudFlare but this example should work with any DNS manager.

Certificate stack

The certificate is still managed by AWS but the DNS validation has to be done in Cloudflare. The certificate stack is lighter than the AWS counterpart:

AWSTemplateFormatVersion: 2010-09-09

Resources:
  Certificate:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: bw-https.al-un.fr
      ValidationMethod: DNS

If multiple domains must be covered by this certificate, the SubjectAlternativeNames also works here.

Deployment has also to be done in North Virginia:

aws cloudformation deploy --stack-name bw-hosting-https-acm --region us-east-1 --template-file template-https-cloudflare-acm.yaml

Don't forget this command is blocking until the stack creation is completed: Long long ACM stack creation

To find the ACM stack, make sure that region is switched to North Virginia / us-east-1

us-east-1 Cloudformation stacks

Open the certificate manager to check that a certificate request is created. Make sure you are still in us-east-1:

Certificate request

To validate the certificate, you must create the CNAME records in Cloudflare as defined in AWS Certificate manager:

Certificate DNS validation

Get yourself a coffee and let AWS works on its own. After a while, the certificate will be issued:

Certificate success!

The ACM stack creation is then completed:

Certificate success!

Main stack

The Cloudflare HTTPS main stack is very similar to the AWS version. There are two differences:

  • There is no Route 53 resources as DNS entries are handled by Cloudflare
  • An output property display the CDN domain name that will be used in the CNAME record in Cloudflare
# Obviously, you have to adjust the CertificateArn parameter value
aws cloudformation deploy --stack bw-hosting-https --region eu-west-3 --template-file template-https-cloudflare.yaml --parameter-override CertificateArn="arn:aws:acm:us-east-1:558043415147:certificate/0230f177-2606-4e36-8964-64ac484766bb"

aws s3 cp --recursive website s3://bw-hosting-https-cloudflare

The usual AWS CLI commands!

The CloudFront distribution usually takes a while (few minutes) to be created:

Stack creation complete!

When the stack is up, the last step is to create the CNAME record in Cloudflare matching the domain name and pointing to the CloudFront (CDN) domain name. As there is an Outputs property in the template, there is no need to browser CloudFront distributions:

CloudFront domain nameDNS record creation
CloudFront domain nameAdding DNS record

Inputting the http:// URL now automatically redirects to the https:// URL:

Index pageAbout page
Index page works!About page works!

Delete this stack before the ACM stack. Also, don't forget to remove the DNS entries in Cloudflare (DNS validation CNAME entry and CDN CNAME entry).

CloudFront cache invalidation

This section has not much difference between Route 53 and Cloudflare so my example will rely on the Cloudflare option only. For the Route 53 option, please refer to the HTTPS example.

This section does not deal with service workers! If you use service workers, make sure that cache is properly managed.

Certificate

The AWS Certificate is the same as before and has to be deployed first to get the ARN.

  • Deploy the ACM stack in us-east-1
    aws cloudformation deploy --stack-name bw-hosting-invalidate-cache-acm --template-file template-invalidate-cache-acm.yaml --region us-east-1
    
  • Create the CNAME entries in Cloudflare to validate the certificate

Main stack

Our previous configuration (CDN, S3 bucket) mainly remains the same. The core idea is to invalidate Cloudfront cache whenever the hosting bucket is updated:

  • To avoid spamming notifications, only updates on index.html trigger a notification
  • A lambda function processes the notification by creating an invalidation in the appropriate Cloudfront distribution

Note, the order of resources declaration in the template file does not matter but I try to be as closed as possible to the actual order of resources creation

IAM fun starts here

S3 event notification

Adding the notification to the S3 bucket will change the resource definition into:

S3Hosting:
  Type: AWS::S3::Bucket
  Properties:
    BucketName: !Ref BucketName
    NotificationConfiguration:
      LambdaConfigurations:
        - Event: s3:ObjectCreated:*
          Filter:
            S3Key:
              Rules:
                - Name: suffix
                  Value: index.html
          Function: <some lambda ARN>

However, this sole declaration lacks permission: the S3 bucket is now allowed to invoke the lambda provided by its ARN:

S3PermissionToCacheInvalidationLambda:
  Type: AWS::Lambda::Permission
  Properties:
    FunctionName: !GetAtt CacheInvalidationLambda.Arn
    Action: lambda:InvokeFunction
    Principal: s3.amazonaws.com
    SourceAccount: !Ref AWS::AccountId
    SourceArn: !Sub "arn:aws:s3:::${BucketName}"

Note that for SourceArn: !Sub "arn:aws:s3:::${BucketName}", I am not referring with !Ref S3Hosting. Doing so would end up with a circular reference:

  1. Build the S3 bucket will require the permission
  2. Building the permission will require the S3 bucket

Fatal error!

The resource "chain" is then Lambda > S3PermissionToCacheInvalidationLambda > S3Hosting.

Cache invalidation lambda

A lambda function must be declared with an IAM role to define its access scope. To avoid cyclical reference (same as previous permission declaration), the lambda IAM role cannot target a specific Cloudfront distribution and must have access to all Cloudfront distributions.

The lambda has to be authorised to:

  • List all Cloudfront distribution without any restriction
  • Create Cloudfront cache invalidation
  • Create log group (Required only if the LogGroup is not created by CloudFormation)
  • Logs
Parameters:
  # ...
  CacheInvalidationLambdaName:
    Type: String
    Default: <some lambda function name>

Resources:
  CacheInvalidationLambdaIamRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Action:
              - sts:AssumeRole
            Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
      Description: IAM role for the Lambda to invalidate CloudFront cache
      Policies:
        - PolicyName: CloudFrontCacheInvalidationPolicy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Action:
                  - cloudfront:ListDistributions
                Effect: Allow
                Resource: "*"
              - Action:
                  - cloudfront:CreateInvalidation
                Effect: Allow
                Resource: !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/*"
        - PolicyName: CloudWatchPolicy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Action:
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                Effect: Allow
                Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${CacheInvalidationLambdaName}/*"
              # Required only if LogGroup is not created by the stack
              - Action:
                  - "logs:CreateLogGroup"
                Effect: Allow
                Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"
      RoleName: !Sub "${CacheInvalidationLambdaName}-role"

Once the IAM role is ready, declaring the lambda function is a straight:

CacheInvalidationLambda:
  Type: AWS::Lambda::Function
  Properties:
    Code:
      ZipFile: |
        ...Enter the lambda code here...
    FunctionName: <lambda function name>
    Handler: index.handler
    Role: !GetAtt CacheInvalidationLambdaIamRole.Arn
    Runtime: nodejs12.x
    Timeout: 30

Lambda code is skipped for clarity. The real code can be found in

Optionally, the log group can be declared as a resource of the stack:

CacheInvalidationLambdaLogGroup:
  Type: AWS::Logs::LogGroup
  Properties:
    LogGroupName: !Sub "/aws/lambda/${CacheInvalidationLambda}"
    RetentionInDays: 30

If you don't declare the log group as a resource, you have to add the permission to create a log group to IAM role:

CacheInvalidationLambdaIamRole:
  Type: AWS::IAM::Role
  Properties:
    # ...
    Policies:
      # ....
      - PolicyName: CloudWatchPolicy
        PolicyDocument:
          Version: 2012-10-17
          Statement:
            # ...
            # This part:
            - Action:
                - "logs:CreateLogGroup"
              Effect: Allow
              Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"

Having the log group in the stack ensures that the log group is also deleted when the stack is deleted. If the log group is created automatically by the lambda, it is not deleted when the stack is deleted.

Deployment

This stack requires IAM capabilities provided by the --capabilities CAPABILITY_NAMED_IAM argument

aws cloudformation deploy --stack-name bw-hosting-invalidate-cache --template-file template-invalidate-cache.yaml --capabilities CAPABILITY_NAMED_IAM --region eu-west-3
aws s3 cp --recursive website s3://bw-hosting-invalidate-cache

Don't forget to create the CNAME entry in your DNS entries pointing to the Cloudfront distribution domain name

Upon any upload of any index.html, the cache is invalidated for all files:

Check in CloudFront distributionCheck in CloudWatch log group
CloudFront cache invalidationLambda log

Extra miles

CloudFormation offers capabilities than are not covered (yet?) by the guide / repo:

  • Deletion policy: when a CloudFormation stack is deleted, some resources might be needed to be kept.
  • CloudFront origin path: all the examples use the whole bucket to host a website but multiple websites, if served by a CloudFront distribution, can be hosted on the same bucket. An use case example is to have a folder per environment: development/, staging/ and production/
  • CloudFormation nested stack: A stack (child stack) can be defined as a resource of another stack (parent stack). This is useful when multiple stacks share common resources: the common resources are defined in the root stack and the various stacks are configured as children stacks, getting the common resources value (ARN, name, ID...) through parameters
  • Mappings property allows template re-usability. A good use case is to re-use a CloudFormation template for multiple environment and the Mappings would list all values such as:
    Mappings:
      EnvMaps:
        develop:
          Domain: dev.example.com
        staging:
          Domain: stg.example.com
        production:
          Domain: example.com