Deploy to AWS S3 and CloudFront with Rake

Article summary

Even in this age of web applications and dynamic websites, sometimes it is still helpful (or necessary) to host static HTML websites. Sometimes these are just one-off pages; other times they are full websites. While the traditional route involves self or shared hosting, that is no longer necessary. You can quickly and easily host a site using Amazon’s S3 and Cloudfront, and you can easily deploy with Rake and the help of a few Ruby gems.

I recently wrote a set of Rake tasks to help me deploy a static site to S3 with Cloudfront support. While fairly simple, the setup may prove useful to people looking for an automated way to deploy HTML files, such as generated with Middleman or some other static site generator.

1. Requirements

First, you’ll need to actually have an AWS account, a S3 Bucket, and a Cloudfront distribution. They need to be configured to serve a static HTML website. There are several guides for doing this, including Amazon’s own tutorial.

In order to successfully deploy a static site to Amazon, we also need a few pieces of information:

  • AWS Access Key ID
  • AWS Secret Access Key
  • S3 Bucket Name
  • Cloudfront Distribution ID

2. Configuration

All of the credentials and sensitive information are stored in a config.yml file. This is so that you can store the credentials in a separate file, and keep it out of source control. See example below:

---
:aws:
  :access_key_id: UTHEE1ZAEPIAX2OHPH0I
  :secret_access_key: eij5ugaiphee6uusheg6eiVaiD0ein8moh7ieS0o
:acf:
  :distribution_id: PHIN8KA8EID6XA
:deploy:
  :from_folder: './build'
  :s3_bucket: 'my-s3-bucket'

3. Tasks

Basically, deployment involves two major operations: uploading to S3, and then invalidating the Cloudfront distribution (so that your changes will show up). Fortunately, two gems make this process painless: aws and simple-cloudfront-invalidator.

The general idea is to iterate over all of the files that we want to deploy, and use the aws gem to “put” them in the S3 bucket.

In order to serve the files properly, S3 needs to know the correct MIME type for each of the files. For lower fidelity purposes, the MIME type can be guessed from the file extension. In this case, all of the files are standard and have standard extensions (e.g. .html corresponds to a MIME type of text/html). Thus, we can use the mime-types gem to detect the MIME type and specify this when uploading each file with the aws gem.

  task :s3 do

    s3 = Aws::S3.new(
      config[:aws][:access_key_id],
      config[:aws][:secret_access_key]
    )

    bucket = s3.bucket(config[:deploy][:s3_bucket],true)

    Dir.chdir(config[:deploy][:from_folder])

    Dir["**/*"].each do |file|
      next if File.directory?(file)
      mime_type = MIME::Types.type_for(file).first.simplified
      headers = {'content-type' => mime_type}
      bucket.put(file,File.read(file),{},'public-read',headers)
    end
    
  end

Then, we want to gather the list of all files, and create an invalidation request with simple-cloudfront-invalidator so that Cloudfront will clear currently cached files.

  task :invalidate do

    acf = SimpleCloudfrontInvalidator::CloudfrontClient.new(
      config[:aws][:access_key_id],
      config[:aws][:secret_access_key],
      config[:acf][:distribution_id]
    )

    Dir.chdir(config[:deploy][:from_folder])

    acf.invalidate(Dir["**/*"])

  end

4. Putting it All Together

Now we can put everything together in a Rakefile, complete with reading in the configuration file, logging, and task dependencies.

require 'aws'
require 'yaml'
require 'logger'
require 'simple-cloudfront-invalidator'
require 'mime-types'

log = Logger.new(STDOUT)
log.level = Logger::INFO

config = YAML.load_file("config.yml")
pwd = Dir.getwd

namespace :aws do
  desc "Deploy to S3."
  task :s3 do

    s3 = Aws::S3.new(
      config[:aws][:access_key_id],
      config[:aws][:secret_access_key]
    )

    bucket = s3.bucket(config[:deploy][:s3_bucket],true)

    Dir.chdir(config[:deploy][:from_folder])

    log.info("Beginning to deploy files")

    Dir["**/*"].each do |file|
      next if File.directory?(file)
      mime_type = MIME::Types.type_for(file).first.simplified
      log.debug("Uploading #{file} with Content-Type: #{mime_type}")
      headers = {'content-type' => mime_type}
      bucket.put(file,File.read(file),{},'public-read',headers)
    end

    log.info("Done!")

    Dir.chdir(pwd)
  end

  desc "Invalidate Cloudfront distribution."
  task :invalidate do    

    acf = SimpleCloudfrontInvalidator::CloudfrontClient.new(
      config[:aws][:access_key_id],
      config[:aws][:secret_access_key],
      config[:acf][:distribution_id]
    )

    Dir.chdir(config[:deploy][:from_folder])

    log.info("Beginning invalidation request.")

    log.debug("Invalidating the following files:\n" + Dir['**/*'].join("\n"))

    acf.invalidate(Dir["**/*"])

    log.info("Done!")

    Dir.chdir(pwd)
  end

  desc "Deploy files to S3 and invalidate Cloudfront distribution"
  task :deploy => [:s3, :invalidate]

  task :default => :deploy
end

5. Deployment

After getting this set up, deployment becomes as easy as rake aws:deploy. Of course, you’ll need to be sure all of the necessary gems are installed and available. For convenience, I’ve created a small sample repo which includes the above Rakefile, a Gemfile, and a sample static site.