Use an AWS CloudFormation script to create and host an SPA on S3 with SSL and apex/subdomain redirection using CloudFront

2025-01-06



Context

This post shows a way to:

  • Create a single page app hosted on AWS S3
  • Redirect all requests to the www subdomain
  • Use CloudFront to handle SSL distributions

By way of a CloudFormation script so you can do it over and over and over again with confidennce that it works.

Inputs you need to supply

  • The FQDN for Your domain (this post assumes you host it in an AWS Route 53 hosted zone)
    • example: hellopaella.com
  • The hosted zone ID for the domain (you can can get it from Route 53 ‘hosted zone details’)

CloudFormation script, unannotated.

See annotations following the code block.

AWSTemplateFormatVersion: '2010-09-09'
Description: Website using an S3 bucket with SSL redirect using CloudFront distribution for both Apex and subdomains

Parameters:
  DomainName:
    Type: String
  DomainHostedZone:
    Type: String

Resources:
  WebsiteBucket:
    Type: AWS::S3::Bucket
    Description: WWW Content Bucket
    Properties:
      BucketName: !Sub "www.${DomainName}"
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      WebsiteConfiguration:
        IndexDocument: index.html
        ErrorDocument: error.html
      OwnershipControls:
        Rules:
          - ObjectOwnership: BucketOwnerEnforced
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - BucketKeyEnabled: true
            ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      Tags:
        - Key: !Ref DomainName
          Value: WebsiteBucket

  ApexBucket:
    Type: AWS::S3::Bucket
    Description: Apex bucket redirection
    Properties:
      BucketName: !Ref DomainName
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      WebsiteConfiguration:
        RedirectAllRequestsTo:
          HostName: !Sub "www.${DomainName}"
          Protocol: https
      OwnershipControls:
        Rules:
          - ObjectOwnership: BucketOwnerEnforced
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - BucketKeyEnabled: true
            ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      Tags:
        - Key: !Ref DomainName
          Value: WebsiteBucket

  WebsiteBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Description: WebsiteBucketPolicy Description
    Properties:
      Bucket: !Ref WebsiteBucket
      PolicyDocument:
        Id: SiteBucketPolicy
        Version: 2012-10-17
        Statement:
          - Sid: PolicyForCloudFrontPrivateContent
            Effect: Allow
            Principal:
              Service: "cloudfront.amazonaws.com"
            Action:
              - s3:GetObject
            Resource: !Join
              - ''
              - - 'arn:aws:s3:::'
                - !Ref WebsiteBucket
                - /*

  SiteCertificate:
    Type: AWS::CertificateManager::Certificate
    Description: Certificate Description
    Properties:
      DomainName: !Ref DomainName
      DomainValidationOptions:
        - DomainName: !Ref DomainName
          HostedZoneId: !Ref DomainHostedZone
      ValidationMethod: DNS
      SubjectAlternativeNames:
        - !Sub "*.${DomainName}"
      Tags:
        - Key: !Ref DomainName
          Value: SiteCertificate

  ApexCFCachePolicy:
    Type: AWS::CloudFront::CachePolicy
    Description: ApexCFCachePolicy
    Properties:
      CachePolicyConfig:
        Comment: "Apex Policy with caching enabled. Supports Gzip and Brotli compression."
        MinTTL: 1
        MaxTTL: 3.1536E7
        ParametersInCacheKeyAndForwardedToOrigin:
          QueryStringsConfig:
            QueryStringBehavior: none
          EnableAcceptEncodingBrotli: true
          HeadersConfig:
            HeaderBehavior: none
          CookiesConfig:
            CookieBehavior: none
          EnableAcceptEncodingGzip: true
        DefaultTTL: 86400
        Name: !Sub "${DomainHostedZone}CachingOptimized"

  ApexCFDistribution:
    Type: AWS::CloudFront::Distribution
    Description: CFDistribution for Apex
    Properties:
      DistributionConfig:
        Logging:
          IncludeCookies: false
          Bucket: ""
          Prefix: ""
        Comment: !Ref DomainName
        Origins:
          - ConnectionTimeout: 10
            OriginAccessControlId: ""
            ConnectionAttempts: 3
            OriginCustomHeaders: []
            DomainName: !Sub "${DomainName}.s3-website-${AWS::Region}.amazonaws.com"
            OriginShield:
              Enabled: false
            OriginPath: ""
            Id: !Ref DomainName
            CustomOriginConfig:
              OriginKeepaliveTimeout: 5
              OriginReadTimeout: 30
              OriginSSLProtocols:
                - "SSLv3"
                - "TLSv1"
                - "TLSv1.1"
                - "TLSv1.2"
              HTTPSPort: 443
              HTTPPort: 80
              OriginProtocolPolicy: "http-only"
        Aliases:
          - !Ref DomainName
        DefaultRootObject: ""
        DefaultCacheBehavior:
          AllowedMethods:
            - "HEAD"
            - "DELETE"
            - "POST"
            - "GET"
            - "OPTIONS"
            - "PUT"
            - "PATCH"
          CachedMethods:
            - "HEAD"
            - "GET"
          SmoothStreaming: false
          CachePolicyId: !Ref ApexCFCachePolicy
          Compress: true
          FunctionAssociations: []
          LambdaFunctionAssociations: []
          TargetOriginId: !Ref DomainName
          ViewerProtocolPolicy: "redirect-to-https"
          ResponseHeadersPolicyId: "60669652-455b-4ae9-85a4-c4c02393f86c"
          GrpcConfig:
            Enabled: false
          TrustedSigners: []
          FieldLevelEncryptionId: ""
          TrustedKeyGroups: []
          ForwardedValues:
            QueryString: false
            Cookies:
              Forward: none
          OriginRequestPolicyId: 88a5eaf4-2fd4-4709-b370-b4c650ea3fcf
        PriceClass: PriceClass_100
        ViewerCertificate:
          MinimumProtocolVersion: "TLSv1.2_2021"
          SslSupportMethod: "sni-only"
          AcmCertificateArn: !Ref SiteCertificate
        Staging: false
        CustomErrorResponses: []
        ContinuousDeploymentPolicyId: ""
        OriginGroups:
          Quantity: 0
          Items: []
        Enabled: true
        IPV6Enabled: true
        WebACLId: ""
        HttpVersion: "http2"
        Restrictions:
          GeoRestriction:
            Locations: []
            RestrictionType: "none"
        CacheBehaviors: []
      Tags:
        - Key: !Ref DomainName
          Value: ApexCFDistribution

  CFOriginAccessControl:
    Type: AWS::CloudFront::OriginAccessControl
    Description: CFOriginAccessControl
    Properties:
      OriginAccessControlConfig:
        SigningBehavior: "always"
        Description: !Sub "www.${DomainName}"
        SigningProtocol: "sigv4"
        OriginAccessControlOriginType: "s3"
        Name: !Sub "www.${DomainName}"

  WWWCFDistribution:
    Type: AWS::CloudFront::Distribution
    Description: WWWCFDistribution
    Properties:
      DistributionConfig:
        Logging:
          IncludeCookies: false
          Bucket: ""
          Prefix: ""
        Comment: !Sub "www.${DomainName}"
        DefaultRootObject: index.html
        Origins:
          - ConnectionTimeout: 10
            OriginAccessControlId: !Ref CFOriginAccessControl
            ConnectionAttempts: 3
            DomainName: !GetAtt WebsiteBucket.RegionalDomainName
            OriginShield:
              Enabled: false
            S3OriginConfig:
              OriginAccessIdentity: ""
            OriginPath: ""
            Id: !Sub "www.${DomainName}"
        ViewerCertificate:
          MinimumProtocolVersion: "TLSv1.2_2021"
          SslSupportMethod: "sni-only"
          AcmCertificateArn: !Ref SiteCertificate
        PriceClass: "PriceClass_100"
        DefaultCacheBehavior:
          Compress: true
          FunctionAssociations: []
          LambdaFunctionAssociations: []
          TargetOriginId: !Sub "www.${DomainName}"
          ViewerProtocolPolicy: "redirect-to-https"
          ResponseHeadersPolicyId: "60669652-455b-4ae9-85a4-c4c02393f86c"
          GrpcConfig:
            Enabled: false
          TrustedSigners: []
          FieldLevelEncryptionId: ""
          TrustedKeyGroups: []
          AllowedMethods:
            - "HEAD"
            - "DELETE"
            - "POST"
            - "GET"
            - "OPTIONS"
            - "PUT"
            - "PATCH"
          CachedMethods:
            - "HEAD"
            - "GET"
          SmoothStreaming: false
          CachePolicyId: !Ref ApexCFCachePolicy
        Staging: false
        CustomErrorResponses: []
        ContinuousDeploymentPolicyId: ""
        OriginGroups:
          Quantity: 0
          Items: []
        Enabled: true
        Aliases:
          - !Sub "www.${DomainName}"
        IPV6Enabled: true
        WebACLId: ""
        HttpVersion: "http2"
        Restrictions:
          GeoRestriction:
            Locations: []
            RestrictionType: "none"
        CacheBehaviors: []
      Tags:
        - Key: !Ref DomainName
          Value: WWWCFDistribution

  WWWDNSRecord:
    Type: AWS::Route53::RecordSet
    Description: WWWDNSRecord
    Properties:
      HostedZoneName: !Sub "${DomainName}."
      Name: !Sub "www.${DomainName}"
      Type: A
      AliasTarget:
        DNSName: !GetAtt WWWCFDistribution.DomainName
        HostedZoneId: Z2FDTNDATAQYW2

  ApexDNSRecord:
    Type: AWS::Route53::RecordSet
    Description: ApexDNSRecord
    Properties:
      HostedZoneName: !Sub "${DomainName}."
      Name: !Ref DomainName
      Type: A
      AliasTarget:
        DNSName: !GetAtt WWWCFDistribution.DomainName
        HostedZoneId: Z2FDTNDATAQYW2

Outputs:
  ApexCFDistributionURL:
    Value: !GetAtt ApexCFDistribution.DomainName
    Description: ApexCFDistribution URL
  WWWCFDistributionURL:
    Value: !GetAtt WWWCFDistribution.DomainName
    Description: WWWCFDistribution URL
  WWWDNSRecord:
    Value: !Sub "https://www.${DomainName}"
    Description: WWWDNSRecord
  ApexDNSRecord:
    Value: !Sub "https://${DomainName}"
    Description: ApexDNSRecord

Annotations

ApexBucket

Notice how it redirects all requests to www, asking for SSL, supported by CloudFront

SiteCertificate

Provisions a cert to cover all domain names under your apex domain (example.com and *.example.com)

The trick: ApexCFDistribution

DomainName: !Sub "${DomainName}.s3-website-${AWS::Region}.amazonaws.com" The domain name is not a regular S3 designation, but contains -website-, without which, all goes to hell.

In contrast: WWWCFDistribution

DomainName: !GetAtt WebsiteBucket.RegionalDomainName is plain.

The rest of the script is standard.

What this will do

It will create:

  • The subdomain bucket to which you would upload your content to be served as an SPA/Static site
  • Apex S3 bucket that will redirect to the www subdomain
  • A certificate to cover the apex and all subdomains
  • Two CloudFront distributions to handle the redirects (http to https)
  • Assorted needed peripherial resources.

What this will allow you to do on behalf of your site’s visitors

  • They visit http://example.com and will be redirected to https://example.com and then to https://www.example.com
  • They visit http://www.example.com and will be redirected https://www.example.com

at which point they’ll see your website’s content

Conclusion

I hope this helps! I looked around quite a bit and did not see a way to do this with CloudFormation; all the examples were using the AWS Console, but I hate clicking around… Let me know how to improve or optimise the script!

Happy hacking!



Other Tags

API GW
AWS
ActiveRecord
Agile
Alexa
Analysis
Ansible
BDD
BLE
C
CAB
CloudFormation
CloudFront
CloudWatch
Cross-compile
Cucumber
DevOps
Devops
DotNet
Embedded
Fitbit
GNU
GitHub Actions
Governance
How-to
Inception
IoT
Javascript
Jest
Lambda
Mac OS X
MacRuby
Metrics
MySQL
NetBeans
Objective-C
PMO
Product Management
Programme management
Project Management
Quality Assurance
Rails
Raspberry Pi
Remote compilation
Remote debugging
Remote execution
Risk Assessment
Route 53
Ruby
S3
SPA
Self Organising Teams
SpecFlow
TDD
Unit testing
VSM
Value
arm
contract testing
inception
nrf51
pact
planning
rSpec
ruby
ssh