Tweaking site performance and configuring AWS
I’ve recently updated jmx2.com. The site worked as it was and I didn’t really have the spare time to get to the ever-growing list of updates I’d accumulated. I had a week of relative quiet appear and I’ve finally worked on the site again. Here’s what I’ve been doing.
- General clean up of the code in the Craft Twig templates.
- Built a new server with PHP 8 and moved the site.
- I had already been using a
picture
element to serve different sized images, but I’ve finally added webp images to those tags. (No avif support yet though.) - Corrected an error in my
sizes
attribute that was serving oversized images to some mobile devices. - Updated the build system from Laravel Mix to Vite.
- Updating my local development workflow allowed me to serve modern Javascript to modern browsers and serve Babel-converted JS to old browsers.
- The new workflow also provides dynamically created Critical CSS for the pages on the site.
These updates brought the PageSpeed score for jmx2.com from the mid-80s to the high 90’s.
Most of the updates happened in Craft and my Twig templates. There were a few AWS-specific things that I needed to solve in this process and that’s the purpose of this post.
A CDN for images
I moved all of the images for the site to a CDN. In Craft, I set up an Amazon S3 bucket which then gets distributed to a CloudFront URL that has a domain of cloud.jmx2.com
associated with it.
Setting caching time for pre-existing and newly-uploaded assets
PageSpeed Insights revealed I had not set a max-age for the cache for the images being served from CloudFront.
I store my site assets in a directory called site-assets
so I added a “Cache-Control” header with the value of max-age=31536000
to all the files in that directory. In the list of objects, select the directory or specific files you want to add the metadata on then select the Actions dropdown and then “Edit metadata”.
Why 3153600? The max-age is an integer of seconds; 365 days is made up of 3153600 seconds. So, I’m caching images for a full year.
The setting above set the header on all of the pre-existing assets that I manually copied over.
New assets uploaded directly from Craft CMS need to have their cache duration set as well. The Amazon S3 plugin has a setting for this when you create a volume. I’ve set my cache duration for 1 year here as well.
With that fix in place, my PageSpeed score was improved as you can see in the next screenshot, but keep reading where I fix a potential problem I have created.
Allowing image embeds while protecting fonts
I want my images to be able to be served to any site so that media embeds work. For example, if someone shared one of our projects on Facebook, I want the social media image to appear.
I also host my fonts on CloudFront. I want to restrict how those files are used. I’d like to have them served only to jmx2.com.
My initial thought was a restrictive referer header. But, if I only allowed jmx2.com
to be a valid referer header, that would prevent social embeds from showing images for the posts. (Trivia: ‘Referer’ is misspelled in the HTML spec, so we must use the misspelled version of the word now! :shrug:)
There are also issues with adding a referer header to CloudFront assets. For full details see this 2016 post in the AWS Security Blog: How to Prevent Hotlinking by Using AWS WAF, Amazon CloudFront, and Referer Checking.
If you are using a CDN such as CloudFront to speed up your site’s delivery of content, validating the
Referer
header at the web server becomes less practical. The CDN stores a copy of your content in the edge of its network of servers, so even if your web server validates the original request’s headers (in this case, theReferer
), additional requests for that content must be validated by the CDN itself because they are unlikely to hit the origin web server.
Ultimately, a referer header would not work anyway for my situation. Since the fonts and the images exist on the same CloudFront server, I needed to add an additional rule in AWS.
AWS WAF
What is WAF? Amazon’s FAQ:
AWS WAF is a web application firewall that helps protect web applications from attacks by allowing you to configure rules that allow, block, or monitor (count) web requests based on conditions that you define. These conditions include IP addresses, HTTP headers, HTTP body, URI strings, SQL injection and cross-site scripting.
Head to the WAF start page and create a rule, which is called an ACL, for “access control list.” I’ve created one called jmx2-referer-check-for-fonts.
There is a handy GUI to create your rules. I’ll talk through the steps I took, but since the rules can be written in JSON, that’s what I’ll include below for reference. The language I’ll use is pretty stilted, but I think it’s the best way to describe using the GUI.
Under the “Add rules” dropdown, I selected “Add my own rules and rule groups.”
I chose to make a “regular rule” and not a “rate-based rule”. If a request matches all the statements, which I’ll describe next, then block and send a custom header with a 403 response code with the error message of “Invalid request for access to font”.
For the 2 statements, the first was a “negated” statement that inspects the header in the “referer” field name with the match type of “contains string” with the string to match of “jmx2.com”. I transformed the text to lowercase.
The second statement is where I checked for the font. This state was not negated. It inspects the URI path to see if it contains the string “woff”. The text is transformed to lowercase.
That means if I had an image with the string “woff”, it would be caught in this rule. That’s a downside I’ll live with though.
This rule is applied to the CloudFront distribution I’ve got for the site. Now images can be served to any location, but my fonts look for jmx2.com in their referer header.
Here’s the JSON for the rule.
{
"Name": "jmx2-referer-check-for-fonts",
"Priority": 0,
"Action": {
"Block": {
"CustomResponse": {
"ResponseHeaders": [
{
"Name": "Error",
"Value": "Invalid request for access to font"
}
],
"ResponseCode": 403
}
}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "jmx2-referer-check"
},
"Statement": {
"AndStatement": {
"Statements": [
{
"NotStatement": {
"Statement": {
"ByteMatchStatement": {
"FieldToMatch": {
"SingleHeader": {
"Name": "referer"
}
},
"PositionalConstraint": "CONTAINS",
"SearchString": "jmx2.com",
"TextTransformations": [
{
"Type": "LOWERCASE",
"Priority": 0
}
]
}
}
}
},
{
"ByteMatchStatement": {
"FieldToMatch": {
"UriPath": {}
},
"PositionalConstraint": "CONTAINS",
"SearchString": "woff",
"TextTransformations": [
{
"Type": "LOWERCASE",
"Priority": 0
}
]
}
}
]
}
}
}
The cost of protection is not free
Now my fonts are protected but there is a cost associated with AWS WAF. It’s $5 per web ACL per month and $1 per rule per month and $0.60 per million requests.
What I’ve described above is overkill for my company site. It would be cheaper not to have these rules in place or figure out a different hosting solution for the fonts. I’ve set the rules up primarily to experiment on something that is not client work. I’m doing it for the experience.