← Home

Serving Web content and redirects from the domain apex without Route53 on AWS

If you want to serve Web content from a domain apex (example.com rather than www.example.com) then you need to add DNS A records containing the IP addresses of target servers to the domain. If you’re using Route53 (AWS’s service for domain name registration and DNS), then Route53 handles this automatically for you.

In a recent project, DNS and domain name registration was handled outside of AWS, so the Route53 option wasn’t available. This meant I needed to put together a way to have static IP addresses (IP addresses that don’t change) within AWS to serve Web content.

The first option I thought of was to start up an EC2 instance in a public subnet for this task.

There’s a few issues with this approach:

  • The EC2 instance itself must run a Web server, so it needs to be appropriately hardened, intrusion detection software added etc. which is extra work.
  • If the Web server needs to handle TLS traffic, then the TLS certificate must be present on the EC2 instance.
  • There’s no load balancing, so if the server goes down or can’t handle the load, the site would be down.

The next option was to use the relatively new Network Load Balancer feature to handle incoming traffic. This has a static IP address, but can pipe traffic to backend IP addresses.

Behind the Network Load Balancer, I placed an Application Load Balancer to use as a Web server to redirect traffic away from the domain apex to a subdomain (www.example.com) where I could serve traffic using CloudFront.

Using an Application Load Balancer instead of an EC2 instance has several benefits:

  • It doesn’t need to be patched, secured etc.
    It scales automatically.
    It integrates with Amazon Certificate Manager (in its local region, no us-east-1 like CloudFront) to provide TLS support.
    It has built in redirect features, and can even execute Lambdas now.
  • I decided to use the built-in redirect feature to redirect HTTP to HTTPS, and redirect from example.com to www.example.com so that I could serve the static content (generated using Prismic and Gatsby) using CloudFront.

Serving content with CloudFront off a domain apex is straightforward: Create a CloudFront distribution using your S3 bucket as a content origin server, then apply a CNAME of www.example.com to your domain with its value set to the CloudFront distribution’s domain name (typically example.cloudfront.net).

Here’s the CloudFormation template to get that up-and-running:

AWSTemplateFormatVersion: '2010-09-09'
Description: Sets up the required resources for the website at example.com
Parameters:
  DomainName:
    Type: String
    Description: The website domain name.
    Default: example.co.uk
  RedirectTo:
    Type: String
    Description: The Application Load Balancer redirect, typically from example.com to the www.example.com CloudFront distribution. Not used in dev.
    Default: www.example.co.uk
  ALBCertificateArn:
    Type: String
    Description: ARN of the SSL certificate used for the Application Load Balancer redirect (must be in the local region).
  CloudFrontCertificateArn:
    Type: String
    Description: ARN of the SSL certificate used for the CloudFront distribution (must be in us-east-1).
  WebsiteCloudFrontViewerRequestLambdaFunctionARN:
    Type: String
    Description: ARN of the Lambda@Edge function that does rewriting of URLs (must be in us-east-1). See lambda_at_edge.js
  Stage:
    Type: String
    Description: Deployment stage
    Default: prod

Resources:
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.1.0.0/16
      Tags:
      - Key: Name
        Value: !Ref DomainName
  InternetGateway:
    Type: AWS::EC2::InternetGateway
    DependsOn: VPC
  AttachGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref VPC
      InternetGatewayId: !Ref InternetGateway
  PublicSubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.1.10.0/24
      AvailabilityZone: !Select [ 0, !GetAZs ]
      Tags:
      - Key: Name
        Value: !Sub ${AWS::StackName}-public-a
  PublicSubnetB:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.1.20.0/24
      AvailabilityZone: !Select [ 1, !GetAZs ]
      Tags:
      - Key: Name
        Value: !Sub ${AWS::StackName}-public-b
  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
      - Key: Name
        Value: Public
  PublicRouteToInternet:
    Type: AWS::EC2::Route
    DependsOn: AttachGateway
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway
  PublicSubnetARouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnetA
      RouteTableId: !Ref PublicRouteTable
  PublicSubnetBRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnetB
      RouteTableId: !Ref PublicRouteTable

  AllowAllWebSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Allow all Web traffic on ports 80 and 443
      VpcId:
        Ref: VPC
      SecurityGroupIngress:
      - IpProtocol: tcp
        FromPort: 80
        ToPort: 80
        CidrIp: 0.0.0.0/0
      - IpProtocol: tcp
        FromPort: 443
        ToPort: 443
        CidrIp: 0.0.0.0/0
      SecurityGroupEgress:
      - IpProtocol: tcp
        FromPort: 80
        ToPort: 80
        CidrIp: 0.0.0.0/0
      - IpProtocol: tcp
        FromPort: 443
        ToPort: 443
        CidrIp: 0.0.0.0/0

  NetworkLoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: !Sub ${AWS::StackName}-nlb
      SubnetMappings:
        - AllocationId: !GetAtt 
            - NetworkLoadBalancerIP1
            - AllocationId
          SubnetId: !Ref PublicSubnetA
        - AllocationId: !GetAtt 
            - NetworkLoadBalancerIP2
            - AllocationId
          SubnetId: !Ref PublicSubnetB
      Type: network
  NetworkLoadBalancerIP1:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc
  NetworkLoadBalancerIP2:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc
  NetworkLoadBalancerListener80:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties: 
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref NetworkLoadBalancerTargetGroup80
      LoadBalancerArn: !Ref NetworkLoadBalancer
      Port: 80
      Protocol: TCP
  NetworkLoadBalancerListener443:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties: 
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref NetworkLoadBalancerTargetGroup443
      LoadBalancerArn: !Ref NetworkLoadBalancer
      Port: 443
      Protocol: TCP
  NetworkLoadBalancerTargetGroup80:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Port: 80
      Protocol: TCP
      # Targets are specified by a Lambda which regularly gets the IP addresses
      # of the ApplicationLoadBalancer.
      TargetType: ip
      VpcId: !Ref VPC
  NetworkLoadBalancerTargetGroup443:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Port: 443
      Protocol: TCP
      # Targets are specified by a Lambda which regularly gets the IP addresses
      # of the ApplicationLoadBalancer.
      TargetType: ip
      VpcId: !Ref VPC

  ApplicationLoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: !Sub ${AWS::StackName}-alb-int
      Scheme: internal
      SecurityGroups:
        - Ref: AllowAllWebSG
      Subnets:
        - !Ref PublicSubnetA
        - !Ref PublicSubnetB
      Type: application
  ApplicationLoadBalancerListener80:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties: 
      DefaultActions:
        - Type: redirect
          RedirectConfig:
            Host: !Ref RedirectTo
            Protocol: HTTPS
            Port: 443
            StatusCode: HTTP_302
      LoadBalancerArn: !Ref ApplicationLoadBalancer
      Port: 80
      Protocol: HTTP
  ApplicationLoadBalancerListener443:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties: 
      DefaultActions:
        - Type: redirect
          RedirectConfig:
            Host: !Ref RedirectTo
            Protocol: HTTPS
            Port: 443
            StatusCode: HTTP_302
      LoadBalancerArn: !Ref ApplicationLoadBalancer
      Port: 443
      Protocol: HTTPS
      Certificates:
        - CertificateArn: !Ref ALBCertificateArn

  WebsiteBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref 'DomainName'

  WebsiteCloudFrontOriginAccessIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: !Sub 'CloudFront OAI for ${DomainName}'

  WebsiteBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref WebsiteBucket
      PolicyDocument:
        Statement:
          -
            Action:
              - s3:GetObject
            Effect: Allow
            Resource: !Join [ "", [ "arn:aws:s3:::", !Ref WebsiteBucket, "/*" ] ]
            Principal:
              CanonicalUser: !GetAtt WebsiteCloudFrontOriginAccessIdentity.S3CanonicalUserId

  WebsiteCloudfront:
    Type: AWS::CloudFront::Distribution
    DependsOn:
    - WebsiteBucket
    Properties:
      DistributionConfig:
        Comment: !Ref 'DomainName'
        Origins:
          - DomainName: !GetAtt WebsiteBucket.DomainName
            Id: website-s3-bucket
            S3OriginConfig:
              OriginAccessIdentity:
                !Join [ "", [ "origin-access-identity/cloudfront/", !Ref WebsiteCloudFrontOriginAccessIdentity ] ]
        Aliases:
          - !Ref 'DomainName'
          - !Ref 'RedirectTo'
        DefaultCacheBehavior:
          ViewerProtocolPolicy: redirect-to-https
          TargetOriginId: website-s3-bucket
          Compress: true
          ForwardedValues:
            QueryString: true
          LambdaFunctionAssociations:
            - EventType: viewer-request
              LambdaFunctionARN: !Ref WebsiteCloudFrontViewerRequestLambdaFunctionARN
        ViewerCertificate:
          AcmCertificateArn: !Ref CloudFrontCertificateArn
          MinimumProtocolVersion: TLSv1.2_2018
          SslSupportMethod: sni-only
        Enabled: true
        HttpVersion: http2
        DefaultRootObject: index.html
        IPV6Enabled: true
        CustomErrorResponses:
          - ErrorCode: 403
            ResponseCode: 404
            ResponsePagePath: '/error/index.html'
        PriceClass: PriceClass_100
      Tags:
        -
          Key: Name
          Value: !Ref 'DomainName'
        -
          Key: Environment
          Value: !Ref 'Stage'

Once you’ve generated TLS certificates within us-east-1, you can apply them and you’ve got a very scalable site.

This all sounds simple, but there’s one real problem left on this part of the solution. The Network Load Balancer needs to forward on to the Application Load Balancer, but needs to use an IP address as a target. The IP addresses of the Application Load Balancers will change over time, so how can we handle that?

AWS provide CloudFormation template for just this situation found in this blog post. [0] It’s frankly, a bit complicated, given that it runs a Lambda every few minutes to carry out a DNS lookup and adjust the Network Load Balancer targets, but the provided template works well and I haven’t had any production issues.

So, we’ve now got a redirect in place and need to get the CloudFront content running. Typically, there’s a few things to deal with when migrating a site:

  1. Making sure that links from our old site get redirected to an appropriate place in our new site.

  2. Handling subdirectories in S3 (e.g. [1] since S3 only serves up index.html` automatically in the root of an S3 bucket.

While this could all be done by executing AWS Lambda functions directly from the Application Load Balancer, that would be a bit slower, since the Lambda would have to collect the content from S3 and then serve it, so I opted to use Lambda@Edge to execute custom code within the CloudFront distribution.

I couldn’t find an example that did both redirects and default documents, but it was easy enough to write. The main issue was poor documentation and a slow workflow for deployment.

  var path = require('path');

  const redirects = {
    "/about-us": { to: "/about", statusCode: 301 },
    "/contact-us/head-office": { to: "/contact/head-office", statusCode: 302 },
  };

  exports.handler = async event => {
    const { request } = event.Records[0].cf;
    const normalisedUri = normalise(request.uri);
    const redirect = redirects[normalisedUri];
    if (redirect) {
      return redirectTo(redirect.to, redirect.statusCode);
    }
    if (!hasExtension(request.uri)) {
      request.uri = trimSlash(request.uri) + "/index.html";
    }
    return request;
  };

  const trimSlash = uri => hasTrailingSlash(uri) ? uri.slice(0, -1) : uri;
  const normalise = uri => trimSlash(uri).toLowerCase();
  const hasExtension = uri => path.extname(uri) !== '';
  const hasTrailingSlash = uri => uri.endsWith('/');

  const redirectTo = (to, statusCode) => ({
    status: statusCode.toString(),
    statusDescription: 'Found',
    headers: {
      location: [{
        key: 'Location',
        value: to,
      }],
    },
  });

Surprisingly complex for a simple thing…