Hugo Cloudfront

CloudFront is a great option to host Hugo websites. Previously I showed how to host a hugo blog in AWS App Runner but App Runner is shutting down.

CloudFront is a great option and provides a few benefits over App Runner

  1. More performant leveraging CDN
  2. Cheaper for serving static context

Here are steps we’ll go through

  1. Create S3 bucket to store Hugo static files
  2. Create CloudFront disribution
  3. CloudFront function to rewrite URLs

The CloudFormation template creates an S3 bucket and assoicated policy for a CloudFront distribution to access it (see further below for the CloudFront distribution).

Few items to note

  • The bucket name must be unique across all S3, not just the account!
  • Public access to the public is blocked
  • The policy allows only CloudFront to access the bucket
 1Resources:
 2  SiteBucket:
 3    Type: AWS::S3::Bucket
 4    Properties:
 5      BucketName: !Sub "${BucketNamePrefix}-${Environment}-SiteBucket"
 6      AccessControl: Private
 7      PublicAccessBlockConfiguration:
 8        BlockPublicAcls: true
 9        BlockPublicPolicy: true
10        IgnorePublicAcls: true
11        RestrictPublicBuckets: true
12      VersioningConfiguration:
13        Status: Suspended
14
15  SiteBucketPolicy:
16    Type: AWS::S3::BucketPolicy
17    Properties:
18      Bucket: !Ref SiteBucket
19      PolicyDocument:
20        Version: '2012-10-17'
21        Statement:
22          - Sid: AllowCloudFrontReadOnly
23            Effect: Allow
24            Principal:
25              Service: cloudfront.amazonaws.com
26            Action: s3:GetObject
27            Resource: !Sub "${SiteBucket.Arn}/*"
28            Condition:
29              StringEquals:
30                AWS:SourceArn: !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistribution}"

Next we’ll create the CloudFront distribution and the Origin Access Control to allow CloudFront to access files in the S3 bucket.

  • The default cache and security headers policies are used
  • The S3 bucket is set as origin for the distribution
 1CloudFrontOAC:
 2    Type: AWS::CloudFront::OriginAccessControl
 3    Properties:
 4      OriginAccessControlConfig:
 5        Name: !Sub "${BucketNamePrefix}-${Environment}-oac"
 6        Description: "OAC for S3 origin"
 7        SigningProtocol: sigv4
 8        SigningBehavior: always
 9        OriginAccessControlOriginType: s3
10
11SiteCachePolicy:
12    Type: AWS::CloudFront::CachePolicy
13    Properties:
14      CachePolicyConfig:
15        Name: !Sub "${BucketNamePrefix}-${Environment}-cache-policy"
16        Comment: "Cache policy for site"
17        DefaultTTL: 86400
18        MinTTL: 0
19        MaxTTL: 31536000
20        ParametersInCacheKeyAndForwardedToOrigin:
21          EnableAcceptEncodingGzip: true
22          EnableAcceptEncodingBrotli: true
23          CookiesConfig:
24            CookieBehavior: none
25          HeadersConfig:
26            HeaderBehavior: none
27          QueryStringsConfig:
28            QueryStringBehavior: none
29
30
31  CloudFrontDistribution:
32    Type: AWS::CloudFront::Distribution
33    Properties:
34      DistributionConfig:
35        Enabled: true
36        Comment: !Sub "CloudFront distribution for ${DomainName} (${Environment})"
37        DefaultRootObject: index.html
38        Origins:
39          - Id: S3Origin
40            DomainName: !GetAtt SiteBucket.RegionalDomainName
41            S3OriginConfig: {}
42            OriginAccessControlId: !Ref CloudFrontOAC
43        DefaultCacheBehavior:
44          TargetOriginId: S3Origin
45          ViewerProtocolPolicy: redirect-to-https
46          AllowedMethods:
47            - GET
48            - HEAD
49          CachedMethods:
50            - GET
51            - HEAD
52          CachePolicyId: !Ref SiteCachePolicy
53          Compress: true
54          # Using AWS-managed Response Headers Policy (managed ID is region-specific).
55          # This is the managed security headers policy; hardcoded here because CloudFront
56          # does not provide a stable logical name across accounts/regions. Verify the
57          # policy ID exists in the target region before deploying.
58          ResponseHeadersPolicyId: "67f7725c-6f97-4210-82d7-5512b31e9d03"
59          FunctionAssociations:
60            - EventType: viewer-request
61              FunctionARN: !GetAtt TrailingSlashFunction.FunctionMetadata.FunctionARN
62        ViewerCertificate:
63          AcmCertificateArn: !Ref CertificateArn
64          SslSupportMethod: sni-only
65          MinimumProtocolVersion: TLSv1.2_2021
66        PriceClass: PriceClass_100
67        Aliases:
68          - !Ref DomainName

An ‘A’ record is created to host the distribution on a custom URL. This assumes a hosted zone exists as well as a certificate in the Certificate Manager.

 1ApexRecord:
 2    Type: AWS::Route53::RecordSet
 3    Properties:
 4      HostedZoneId: !Ref HostedZoneId
 5      Name: !Ref DomainName
 6      Type: A
 7      AliasTarget:
 8        DNSName: !GetAtt CloudFrontDistribution.DomainName
 9        # Hardcode this id when creating an A record to a CloudFront distribution
10        HostedZoneId: Z2FDTNDATAQYW2

Hugo assumes index.html is served when using paths without an extension. The below viewer request CloudFront function redirects all paths without a period nor an extension to an index.html in the current path.

 1TrailingSlashFunction:
 2    Type: AWS::CloudFront::Function
 3    Properties:
 4      Name: !Sub "trailing-slash-fn-${Environment}"
 5      AutoPublish: true
 6      FunctionConfig:
 7        Comment: "Rewrite URIs ending with / to /index.html for Hugo sites"
 8        Runtime: cloudfront-js-1.0
 9      FunctionCode: |
10        function handler(event) {
11          const request = event.request;
12          const uri = request.uri;
13
14          // If URI ends with '/', append index.html
15          if (uri.endsWith('/')) {
16            request.uri = uri + 'index.html';
17            return request;
18          }
19
20          // If URI has no file extension, treat as directory and serve /index.html
21          if (uri.indexOf('.') === -1) {
22            request.uri += '/index.html';
23            return request;
24          }
25
26          return request;
27        }

For next time, we’ll discuss deploying changes through CloudFormation and using shell scripts to help.