-- Leo's gemini proxy
-- Connecting to capsule.adrianhesketh.com:1965...
-- Connected
-- Sending request
-- Meta line: 20 text/gemini; charset=utf-8
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-- Response ended
-- Page fetched on Sun Apr 28 13:20:57 2024