If you run a website and aren’t serving your assets from a CDN, I want to highly recommend that you do so. I’ve seen significant performance improvements simply by redirecting my image, CSS, and JS requests to a CDN instead of serving them from a server like DreamHost. I like DreamHost just fine, but the time it takes to serve a file and the download speeds it offers are not… optimal.

The idea behind a CDN is that your assets are served from a speed-tuned network of servers that can deliver the fastest speeds using servers located as close as possible to the end user. I can serve up a 1MB image file in about 1/2 the time using a CDN versus serving directly from DreamHost.

CloudFront

I’ve found CloudFront (Amazon Web Services) to be the easiest to set up and the most affordable option. There are a few good options to explore, though. I haven’t done head-to-head testing of all available options, I just know that CloudFront offers some of the highest speeds and one of the most generous free-tier options.

I can serve all of my sites’ assets (Bunch, Marked 2, Dimspire.me, BrettTerpstra.com, and sundry others), including DMGs, zips, images, CSS/JS files, and more, from AWS for about $2/mo. My total AWS bill is $13/mo, and that includes my Glacier backups and S3 storage. Compared to $20/mo for CloudFlare, it’s a pretty easy decision for me. Some of the “fancier” CDNs can do things like serving different image formats and sizes based on path/query strings, but I haven’t been willing to shell out the cash for those.

I’m not going to do a full tutorial on setting up a CloudFront deployment for a website, but the basic steps are:

  1. Create an AWS account (if needed)
  2. Navigate to CloudFront
  3. Create a new distribution
  4. Set the origin to the url of your website
  5. Add a CNAME for your distribution
  6. Generate an SSL certificate (free through Amazon) for the distribution (requires DNS verification of ownership)

Most of the other options can be left to their defaults. Here’s a more in-depth tutorial. Total setup time is about 30 minutes, including waiting for DNS verification and spin-up time. Of course, then you have to modify your site to make use of the CDN. I have solutions for Jekyll and other SSG generators, if you care to ask. There are multiple WordPress plugins for handling this kind of thing as well.

Versioning

Files served from the CDN are heavily cached, meaning if you want to serve up a new version of a file, updated CSS for example, you need to “bust” the cache. This can be done by adding a query string, e.g. ?v=123 to the file url, but it’s preferable to use filename versioning. If you’re using Apache, you can just include this in your .htaccess:

<IfModule mod_rewrite.c>
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule ^(.+)\.(\d+)\.(js|css|png|jpe?g|gif|rb|json|svg|ico|webp|avif|webm|mp4|zip)$ $1.$3 [L]
</IfModule>

That will allow you to request image.123.jpg and it will serve up image.jpg, but the CDN will see that as a new file request and pull an up-to-date version of the file from the server.

In my Jekyll setup, I have a single version number for the whole site defined in _config.yml under a cachebuster key. In my templates I have some logic that determines when the site is rendering in production mode (JEKYLL_ENV=production), and when it is, it substitutes the CDN path and the version number into the URL. For example, if I’m in production mode, https://dimspire.me/assets/css/main.css will be written as https://cdn.dimspire.me/assets/css/main.123.css. As long as the 123 doesn’t change, the CDN will continue serving the same file, but if I bump it to 124, it will sync the latest version of the file from the server and cache it upon the first request.

Priming the CDN

If you want to avoid anybody having to wait for the CDN to ingest a file, cache it, and serve it on the first load, you can make a headers-only curl call to the asset. This is kind of silly, as once any one user in a region has loaded the new asset, it will be served instantly for subsequent loads. But just for fun, I’ll share my Rakefile task for syncing the CDN after a version bump:

class String
  # Outputs a string in a formatted color.
  # @param [<Integer, String>] color_code The code to use
  # @return [void]
  def colorize(color_code)
    "\033[#{color_code}m#{self}\033[0m"
  end

  def green
    colorize(32)
  end

  def red
    colorize(31)
  end
end

def chdir_base
  Dir.chdir(File.dirname(File.expand_path(__FILE__)))
end

def ok_failed(condition, sender=nil)
  res = condition ? "OK" : "FAILED"
  msg = sender ? sender + ": " + res : res
  warn msg
end

desc 'Sync CDN images'
task :sync_cdn do
  # Make sure we're in the base directory
  chdir_base
  
  # Use rsync to upload files to the server that pushes to CloudFront
  ok_failed system('rsync -criz assets/* dh:~/dimspire.me/assets/ &> /dev/null'), 'CDN Sync'
  
  # Read the current version (cachebuster) from the config file
  buster = YAML.load(IO.read('_config.yml'))['cachebuster'].sub(/^\./, '')
  
  # Iterate through all files in assets directory and its subdirectories
  Dir.glob('assets/**/*') do |f|
    # Only prime images and CSS/JS assets
    next unless File.extname(f) =~ /\.(webp|avif|gif|jpe?g|png|css|js|mp4|ogg)$/

    # Generate the CDN filename with the cachebuster inserted (e.g. image.123.jpg)
    asset = %(cdn.dimspire.me/#{f.strip.sub(/\.(\w{2,4})$/, ".#{buster}.\\1")})
  
    # Use curl to request headers for the image, and grep out whether it was a hit or miss
    res = `curl -Is #{asset}|grep --color=never X-Cache|sed 's/X-Cache: //'|sed 's/from cloudfront//'`.strip.upcase
  
    # Provide feedback in Terminal
    $stdout.print res =~ /HIT/ ? "\033[0KHIT #{asset}\r".green : "Priming #{asset}\n".red
  end
end

The only important part of that is curl -Is [ASSET], which does a headers-only request for the file, which will cause the CDN to ingest it if it isn’t already cached, without actually downloading it. Currently Dimspire.me has about 225 “Dimspirations,” each with 9 image versions (wallpapers, jpeg, webp and avif versions), and running this after a version bump (meaning none of the assets are cached) takes about five minutes for 2000+ assets. If I’m only adding new images and not bumping the version, this task takes under two minutes. Doing the simple curl call above on all of the assets that got “revved” (bumped) will mean that nobody has to wait for files to be initially cached, but again, this is pretty silly.

TLDR

If you run a website that serves a lot of images, CSS, or JavaScript, you can see significant performance improvements by using a CDN. CloudFront is, in my experience, the best combination of fast and affordable. If you want to see more of my Jekyll setup (Rakefile, templates, etc.), just ask. I’m always happy to share.