capsule.adrianhesketh.com
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...
# More
## Next
=> /2019/09/28/raspberry-pi-4x4-keypad-with-go/ 4x4 alphanumeric keypad on the Raspberry Pi with Go
## Previous
=> /2019/01/09/open-source-at-infinity-works-in-2018/ Open source at Infinity Works in 2018
## Home
=> gemini://capsule.adrianhesketh.com home