Introduction

Introduction

Welcome back to the series! We’re nearly done here with your SPA application happily sitting behind a domain an an S3 bucket. Why push forward, you might ask? After all things are looking pretty dandy. Well, it turns out that serving files from your S3 bucket may not be the ideal option. Depending on how often your website changes on S3, and its probably not that often, you’re essentially serving the same files over and over again. Would that make for an excellent case for caching? It would.

Putting a content distribution network in front of the S3 bucket would allow you to cache the responses to incoming requests. For example, once a CSS file is shipped once, the CDN will keep track of it.

Well, this seems complicated, is it really worth it? This setup is as close to “industry best practice as it gets”. As a matter of fact, I’ll be ripping off from Pulumi’s own tutorial pretty heavily and shamelessly. But to be completely honest, for a small page like it probably doesn’t make sense. Using GitHub Pages would be just as suitable of an alternative.

Part 1 – Part 2 – Part 4

The plan

This is it people – it’s the 3rd part and so we’re entering “level HARD”. No more joking around – and if you completed parts I and II, you’re practically a cloud engineer now ;-)

The principal aim will be to create an AWS CloudFront resource and place it between the S3 bucket and the Route53 domain. Additionally, we will create a new bucket for storing AWS CloudFront logs – but that’s just a nice to have.

As we’re about to jump in, the time is ripe for a friendly warning:

In this post we’ll be working with the AWS CloudFront service, which is notoriously slow to work with – in the sense of configuration changes taking 15-20 minutes to perform.

So don’t get your coffee just yet – there will be plenty of opportunity to do that later.

Results

Logging back into Pulumi

Before jumping in, and especially if you’re returning to this series after a break, make sure that your Pulumi environment is configured correctly.

Here is a handy checklist

  • Did you login to the right Pulumi project with pulumi login?
  • Did you configure PULUMI_CONFIG_PASSPHRASE as your environment variable?

If yes, you can check if it’s all working with pulumi stack to display the resources in the stack.

$ pulumi stack
Current stack is dev:
    Managed by jans-mbp.mynet
    Last updated: 1 minute ago (2020-10-31 07:10:29.907622 +0000 UTC)
    Pulumi version: v2.12.1
Current stack resources (4):
    TYPE                              NAME
    pulumi:pulumi:Stack               my-app-dev
    ├─ aws:s3/bucket:Bucket           my-app.jandomanski.com
    ├─ aws:route53/record:Record      targetDomain
    └─ pulumi:providers:aws           default_3_11_0

Current stack outputs (2):
    OUTPUT      VALUE
    bucketName  my-app.jandomanski.com
    recordName  my-app.jandomanski.com

Defining a helper function

Let’s kick off with a very simple helper function. The helper function takes a domain (“www.example.com”) and returns an object with 2 properties (subdomain and parentDomain). What this is needed for will become apparent soon!

// Split a domain name into its subdomain and parent domain names.
// e.g. "www.example.com" => "www", "example.com".
function getDomainAndSubdomain(
  domain: string
): { subdomain: string; parentDomain: string } {
  const parts = domain.split(".");
  if (parts.length < 2) {
    throw new Error(`No TLD found on ${domain}`);
  }
  // No subdomain, e.g. awesome-website.com.
  if (parts.length === 2) {
    return { subdomain: "", parentDomain: domain };
  }

  const subdomain = parts[0];
  parts.shift(); // Drop first element.
  return {
    subdomain,
    // Trailing "." to canonicalize domain.
    parentDomain: parts.join(".") + ".",
  };
}

Building the foundations

What do we have now? We have an S3 bucket and a Route53 record. We agreed to slot in a CDN in between those two. How exactly do we do that?

The CDN can serve responses via HTTPS so let’s provide it with a SSL certificate, as a nice bonus quest. So let’s start by setting this up: insert the following snippet after the bucket definition.

  const domainParts = getDomainAndSubdomain("my-app.jandomanski.com");
  const hostedZoneId = aws.route53
    .getZone({ name: domainParts.parentDomain }, { async: true })
    .then((zone) => zone.zoneId);

  const tenMinutes = 60 * 10;

  // Per AWS, ACM certificate must be in the us-east-1 region.
  const eastRegion = new aws.Provider("east", {
    profile: aws.config.profile,
    region: "us-east-1",
  });
  • The tenMinutes is just a simple constant that we’ll use later,
  • The eastRegion is more interesting.

The eastRegion is just a resource, like everything else in Pulumi, but it’s important for the SSL. The SSL certificates can only be created in the “us-east-1” region. My default region is “eu-west-1” and thus I need an additional provider to create other resources in that region. This may not be necessary for you if you’re already using the “us-east-1” region.

This looks like a simple enough change, so let’s be “greedy” and get this update done. Quick win to start the post. Here is what you should seen when you run pulumi up.

$ pulumi up
Previewing update (dev):
     Type                     Name        Plan       
     pulumi:pulumi:Stack      my-app-dev             
 +   └─ pulumi:providers:aws  east        create     
 
Resources:
    + 1 to create
    3 unchanged

Do you want to perform this update? yes
Updating (dev):
     Type                     Name        Status      
     pulumi:pulumi:Stack      my-app-dev              
 +   └─ pulumi:providers:aws  east        created     
 
Outputs:
    bucketName: "my-app.jandomanski.com"
    recordName: "my-app.jandomanski.com"

Resources:
    + 1 created
    3 unchanged

Duration: 16s

Creating and validating an SSL certificate

With the eastProvider we can now create an SSL certificate on AWS. If you’ve ever done it manually, this will be pure magic. Using a special resource, you can *automatically validate the SSL certificate. No more email validation, or manual DNS validation! In this case, a Route53 record is created automatically to validate the certificate.

  const certificate = new aws.acm.Certificate(
    "certificate",
    {
      domainName: "my-app.jandomanski.com",
      validationMethod: "DNS",
    },
    { provider: eastRegion }
  );

  const certificateValidationDomain = new aws.route53.Record(
    "my-app.jandomanski.com-validation",
    {
      name: certificate.domainValidationOptions[0].resourceRecordName,
      zoneId: hostedZoneId,
      type: certificate.domainValidationOptions[0].resourceRecordType,
      records: [certificate.domainValidationOptions[0].resourceRecordValue],
      ttl: tenMinutes,
    }
  );

  const certificateValidation = new aws.acm.CertificateValidation(
    "certificateValidation",
    {
      certificateArn: certificate.arn,
      validationRecordFqdns: [certificateValidationDomain.fqdn],
    },
    { provider: eastRegion }
  );

Let’s run the update and get our SSL certificate. Here is what the pulumi output should look like:

$ pulumi up 
Previewing update (dev):
     Type                              Name                               Plan       
     pulumi:pulumi:Stack               my-app-dev                                    
 +   ├─ aws:acm:Certificate            certificate                        create     
 +   ├─ aws:route53:Record             my-app.jandomanski.com-validation  create     
 +   └─ aws:acm:CertificateValidation  certificateValidation              create     
 
Resources:
    + 3 to create
    4 unchanged

Do you want to perform this update? yes
Updating (dev):
     Type                              Name                               Status      
     pulumi:pulumi:Stack               my-app-dev                                     
 +   ├─ aws:acm:Certificate            certificate                        created     
 +   ├─ aws:route53:Record             my-app.jandomanski.com-validation  created     
 +   └─ aws:acm:CertificateValidation  certificateValidation              created     
 
Outputs:
    bucketName: "my-app.jandomanski.com"
    recordName: "my-app.jandomanski.com"

Resources:
    + 3 created
    4 unchanged

Duration: 57s

Configuring and creating the CDN

AWS CloudFront is a complicated service with a lot of belts and whistles. Examples include

  • HTTPS -> HTTPS redirects,
  • ability to use AWS Lambdas to process requests and responses,
  • injecting custom headers.

Many of these features can be defined by DistributionArgs. It’s rather verbose and almost completely copy&pasted from the Pulumi Tutorial . If you want a better sense of what’s available in CDN configuration, have a look below:

  // distributionArgs configures the CloudFront distribution. Relevant documentation:
  // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/distribution-web-values-specify.html
  // https://www.terraform.io/docs/providers/aws/r/cloudfront_distribution.html
  const distributionArgs: aws.cloudfront.DistributionArgs = {
    enabled: true,
    // Alternate aliases the CloudFront distribution can be reached at, in addition to https://xxxx.cloudfront.net.
    // Required if you want to access the distribution via config.targetDomain as well.
    aliases: ["my-app.jandomanski.com"],

    // We only specify one origin for this distribution, the S3 content bucket.
    origins: [
      {
        originId: bucket.arn,
        domainName: bucket.websiteEndpoint,
        customOriginConfig: {
          // Amazon S3 doesn't support HTTPS connections when using an S3 bucket configured as a website endpoint.
          // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/distribution-web-values-specify.html#DownloadDistValuesOriginProtocolPolicy
          originProtocolPolicy: "http-only",
          httpPort: 80,
          httpsPort: 443,
          originSslProtocols: ["TLSv1.2"],
        },
      },
    ],

    defaultRootObject: "index.html",

    // A CloudFront distribution can configure different cache behaviors based on the request path.
    // Here we just specify a single, default cache behavior which is just read-only requests to S3.
    defaultCacheBehavior: {
      targetOriginId: bucket.arn,

      viewerProtocolPolicy: "redirect-to-https",
      allowedMethods: ["GET", "HEAD", "OPTIONS"],
      cachedMethods: ["GET", "HEAD", "OPTIONS"],

      forwardedValues: {
        cookies: { forward: "none" },
        queryString: false,
      },

      minTtl: 0,
      defaultTtl: tenMinutes,
      maxTtl: tenMinutes,
    },

    // "All" is the most broad distribution, and also the most expensive.
    // "100" is the least broad, and also the least expensive.
    priceClass: "PriceClass_100",

    // You can customize error responses. When CloudFront receives an error from the origin (e.g. S3 or some other
    // web service) it can return a different error code, and return the response for a different resource.
    customErrorResponses: [
      { errorCode: 404, responseCode: 404, responsePagePath: "/404.html" },
    ],

    restrictions: {
      geoRestriction: {
        restrictionType: "none",
      },
    },

    viewerCertificate: {
      acmCertificateArn: certificateValidation.certificateArn, // Per AWS, ACM certificate must be in the us-east-1 region.
      sslSupportMethod: "sni-only",
    },
  };

Finally, let’s add a line to create the CDN and run pulumi up.

  const cdn = new aws.cloudfront.Distribution("cdn", distributionArgs);

Before proceeding, you should know one thing.

WARNING AWS CloudFormation is a very big service and it can take a WHILE (5-25 minutes) to create or update resources

Here is what your pulumi up should look like

$ pulumi up 
Previewing update (dev):
     Type                            Name        Plan       
     pulumi:pulumi:Stack             my-app-dev             
 +   └─ aws:cloudfront:Distribution  cdn         create     
 
Resources:
    + 1 to create
    7 unchanged

Do you want to perform this update? yes
Updating (dev):
     Type                            Name        Status      
     pulumi:pulumi:Stack             my-app-dev              
 +   └─ aws:cloudfront:Distribution  cdn         created     
 
Outputs:
    bucketName: "my-app.jandomanski.com"
    recordName: "my-app.jandomanski.com"

Resources:
    + 1 created
    7 unchanged

Duration: 2m53s

Updating the Route53

This is the final step and should take no time at all! At the moment, we have a Route53 record that points to the S3 bucket holding the React app. The Route53 record is defined like this:

  // Create a Route53 A-record
  const record = new aws.route53.Record("targetDomain", {
    name: "my-app.jandomanski.com",
    zoneId: hostedZoneId,
    type: "A",
    aliases: [
      {
        zoneId: bucket.hostedZoneId,
        name: bucket.websiteDomain,
        evaluateTargetHealth: true,
      },
    ],
  });

Now that we have a CDN, we can update the Route53 record to point to a new endpoint. Same domain name, same zoneId, just pointing to the new CDN – which acts as a proxy for the S3 bucket.

  // Create a Route53 A-record
  const record = new aws.route53.Record("targetDomain", {
    name: "my-app.jandomanski.com",
    zoneId: hostedZoneId,
    type: "A",
    aliases: [
      {
        name: cdn.domainName,
        zoneId: cdn.hostedZoneId,
        evaluateTargetHealth: true,
      },
    ],
  });

Here is how the pulumi log output looks like

$ pulumi up 
Previewing update (dev):
     Type                   Name          Plan       Info
     pulumi:pulumi:Stack    my-app-dev               
 ~   └─ aws:route53:Record  targetDomain  update     [diff: ~aliases]
 
Resources:
    ~ 1 to update
    7 unchanged

Do you want to perform this update? yes
Updating (dev):
     Type                   Name          Status      Info
     pulumi:pulumi:Stack    my-app-dev                
 ~   └─ aws:route53:Record  targetDomain  updated     [diff: ~aliases]
 
Outputs:
    bucketName: "my-app.jandomanski.com"
    recordName: "my-app.jandomanski.com"

Resources:
    ~ 1 updated
    7 unchanged

Duration: 51s

Testing if it all works

There are some quick ways to diagnose if we got what we want the two simple tests should be

  • open http://my-app.jandomanski.com -> should redirect to https://
  • open https://my-app.jandomanski.com -> should open index.html
  • open https://my-app.jandomanski.com/index.html -> same as above

Beyond, there are some further advanced experiments we can do using curl.

First of all, remember index.html and how we set the Cache-Control headers? Well, those are respected by the CDN. You can clearly see this by hitting the endpoint with cURL.

$ curl https://my-app.jandomanski.com/index.html -I
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 2219
Connection: keep-alive
Date: Sun, 01 Nov 2020 19:20:52 GMT
Cache-Control: no-cache,no-store
Last-Modified: Sun, 01 Nov 2020 19:20:43 GMT
ETag: "67e4d5da5073a0ba60ce72a01c3feee4"
Server: AmazonS3
X-Cache: Miss from cloudfront
Via: 1.1 a5c420a169b19bd150b00f34513e997d.cloudfront.net (CloudFront)
X-Amz-Cf-Pop: LHR62-C3
X-Amz-Cf-Id: P3048HiqZZ66rJ2PujNg44G51v2wzkwumXp1aO2LOumDmbsT03nVFg==

You can hit this over and over again, in all cases you’ll get X-Cache: Miss from cloudfront and Cache-Control: no-cache,no-store. Because of the Cache-Control header, the content is always served from S3 directly. What about other files? Does caching work for them as expected?

$ curl https://my-app.jandomanski.com/favicon.ico -I
HTTP/1.1 200 OK
Content-Type: image/x-icon
Content-Length: 3150
Connection: keep-alive
Date: Sun, 01 Nov 2020 19:24:45 GMT
Cache-Control: max-age=31536000
Last-Modified: Sat, 31 Oct 2020 08:31:51 GMT
ETag: "6e1267d9d946b0236cdf6ffd02890894"
Server: AmazonS3
X-Cache: Hit from cloudfront
Via: 1.1 6fc6ff9b881f0fff41ff95cfddcc92eb.cloudfront.net (CloudFront)
X-Amz-Cf-Pop: LHR52-C1
X-Amz-Cf-Id: 6MIpI2IkMlT1UiGnmP4hsO8qM_TIbxEG5ZbhRh3fhoGREYz_O08Snw==
Age: 3

Can you see the Cache-Control and X-Cache headers above? Both indicate a few things about the CDN:

  • it respected the Cache-Control property we set on the S3 objects,
  • it cached the appropriate files, and is serving them from cache – no trips to the S3 bucket are done.

Conclusions

So that’s it, right? Over the course of the series, we travelled from a very simple S3 hosting of a simple site. Things were simple then but not very cost efficient. Then we added a Route53 domain for some nice access. Finally, we put a CDN in front of the S3 bucket as a caching layer.

But did we sacrifice something? The initial index.ts seemed very simple but the one we have now is quite large. Is there a way to refactor? How to make this more modular? What about testing? Is there an easy way to test this code, without creating the infrastructure? Well, those are very wise questions indeed – and we’ll answer them in the BONUS fourth part to this series.