I’ve written previously about my misgivings with static sites on AWS, and while some of those points are still valid, there’s no question that the maturity of the AWS product and the incredible surface area that its products cover easily make it one of the best choices for hosting on the Internet.
Until now I would have recommended a CloudFlare/other host hybrid so that you could get free HTTPS support for a custom domain with automatic certificate renewal, but the advent of AWS Certificate Manager (ACM) was the last missing piece in the all-Amazon stack. By composing a few services, we’ll get a static site with some pretty amazing features:
Keep in mind that although this article walks through setting up a static site step-by-step, you can look at the full source code of a sample project at any time to dig into how things work.
I’ll leave getting a static site built as an exercise to the reader. There are hundreds of static site frameworks out there and plenty of good choices.
In my own experience, I’ve found that assembling my own static site generation script takes about the same amount of time as getting on-boarded with any of the major site generation frameworks. This isn’t as much a critique of them as it is a nod to the inevitability of any general-purpose project to expand in features until it takes a lot of documentation and false starts to get up to speed. Writing your own script may be a greater maintenance burden over the long run, but this is offset by the much improved flexibility that it gets you.
The singularity example site uses a Go build script and a small standard library-based web server with fswatch (a small cross-plaform program that watches a filesystem for changes) to get a nice development workflow that’s fast and easy.
We’ll be using the AWS CLI. If you’re on Mac or Linux, you should be able to install it as simply as:
$ pip install --user awscli
$ aws configure
aws configure will ask for an AWS access key and secret key which you
can get by logging into the AWS Console. You may also want to
configure a default region because some of the commands below will require one.
Amazon’s storage system, S3, will be used to store the contents of our static site. First create a bucket named according to the custom domain that you’ll be using for your site:
$ export S3_BUCKET=singularity.brandur.org
$ aws s3 mb s3://$S3_BUCKET
Next up, let’s create a
Makefile with a
deploy target which will upload the
results of your build above:
# Force text/html for HTML because we're not using an extension.
aws s3 sync ./public/ s3://$(S3_BUCKET)/ \
--acl public-read --delete --content-type text/html --exclude 'assets*'
# Then move on to assets and allow S3 to detect content type.
aws s3 sync ./public/assets/images/ s3://$(S3_BUCKET)/assets/images/ \
--acl public-read --delete --follow-symlinks
# No AWS access key. Skipping deploy.
Normally assets uploaded to S3 are assigned a content type that’s detected from
their file extension. Because I want to give my HTML documents “pretty URIs”
(i.e. extensionless like
/hello instead of
/hello.html) we play a trick
with content type that necessitates an upload in two steps:
Now try running the task:
$ export $AWS_ACCESS_KEY_ID=access-key-from-aws-configure-above
$ export $AWS_SECRET_ACCESS_KEY=secret-key-from-aws-configure-above
$ make deploy
You can use the AWS credentials that you’ve configured AWS CLI with for now, but we’ll want to avoid the risk of exposing them as much as possible. We’ll address that problem momentarily.
ACM is Amazon’s certificate manager service. Using it we’ll provision a certificate for your custom domain which will then be attached to CloudFront. After the certificate is issued, Amazon will automatically take care of its renewal to keep your site accessible with perfect autonomy. You can even have it issue you a wildcard certificate, and all for free!
Issue this command to request a certificate (and you can use a wildcard if you want):
aws acm request-certificate --domain-name singularity.brandur.org
AWS will email the domain’s administrator to request approval to issue the certificate. Make sure to track down that email and accept the request.
CloudFront is Amazon’s CDN service. We’ll be using it to distribute our content to Amazon edge locations across the world so that it’s fast anywhere, and to terminate TLS connections to our custom domain name (with a little help from ACM).
Using the CLI here is a bit of a pain, so go to the CloudFront control panel and create a new distribution. If it asks you to choose between Web and RTMP, choose Web. Most options can be left default, but you should make a few changes:
After it’s created, you’ll get a domain name for your new CloudFront
distribution with a name like
da48dchlilyg8.cloudfront.net. It may take a few
minutes for the distribution to become available. You’ll need this to set up
Use Route53 or any other DNS provide of your choice to CNAME your custom domain
to the domain name of your new CloudFront distribution (once again, those look
You should now be able to visit your custom domain and see the fruit of your efforts!
Now that the basic static site is working, it’s time to lock down the deployment flow so that you’re not using your root IAM credentials to deploy.
Issue these commands to create a new IAM user:
aws iam create-user --user-name singularity-user
aws iam create-access-key --user-name singularity-user
Note that the second command will produce an access key and a secret key. Make note of these.
Save the following policy snippet to a local file called
sure to replace the S3 bucket name with the one you used above.
Now create the policy and attach it to your IAM user:
aws iam create-policy --policy-name singularity-policy --policy-document file://policy.json
# replace --policy-arn with the ARN produced from the command above
aws iam attach-user-policy --user-name singularity-user --policy-arn arn:aws:iam::551639669466:policy/singularity-policy
The policy and user combination that you’ve just created scopes access to just the S3 bucket containing your static site. If the worst should happen and this user’s credentials are leaked, an attacker may be able to take down this one static site, but won’t be able to probe any further into your Amazon account.
By putting file sychronization to our S3 bucket into a Make task, we’ve made deployments pretty easy, but we can do even better. By running that same task in a Travis build for the project, we’ll make sure that anytime new code gets merged into master, our static site will update accordingly with complete autonomy.
We start by giving installing AWS CLI into the build’s container and running
our Make task as the build’s main target. That’s accomplished by putting this
- pip install --user awscli
- make deploy
That gets us pretty close, but the build will need valid AWS credentials in
order to properly deploy. We don’t want to compromise our credentials by
putting them into our public repository’s
.travis.yml as plaintext, but
luckily Travis provides a facility for encrypted environment
variables. Get the Travis CLI and use it to secure the
IAM credentials for deployment that you generated above:
$ gem install travis
$ travis encrypt AWS_ACCESS_KEY_ID=access-key-from-iam-step-above
$ travis encrypt AWS_SECRET_ACCESS_KEY=secret-key-from-iam-step-above
After encrypting your AWS keys, add those values to your
env section (make sure to use the special
secure: prefix) so that our
build can pick them up:
# $AWS_ACCESS_KEY_ID (use the encrypted result from the command above)
- secure: HR577...
# $AWS_SECRET_ACCESS_KEY (use the encrypted result from the command above)
- secure: svmpm...
Note that the plaintext values of these secure keys are only available to builds that are happening on the master branch of your repository. If someone forks your repository and builds their own branch, these values won’t be present and the upload to S3 won’t occur.
Now that CI configuration is in place, you can push to a GitHub repository and activate Travis for it.
Builds that occur on the master branch will automatically deploy their results to S3 and they’ll be available immediately. Pull requests still get a build and have a test suite run, but because configured secrets are not available on non-master branches, the deploy phase gets skipped, but you need only merge them to master to have it run.
Update: While the use of Lambda is still possible (and is still the most flexible option), Travis now allows you to easily configure cron jobs. They’re functionally the same as rebuilds via Lambda, but will take much less time to set up. From the project’s page in the Travis web interface, navigate to Settings, scroll down, and create a cron job for the master branch with an interval like daily.
One final (and optional) step in the process is to set up an AWS lambda script
that will be triggered by a periodic cron and which will tell Travis to rebuild
your repository. If you tell Travis to notify you on build failures in
Then you’ll get an e-mail if that build ever fails. In case your content repository isn’t seeing very regular contributions, this will act as a canary to tell you when if your build starts failing for any reason. Say that your IAM credentials are accidentally invalidated for example.
First, you’ll need to acquire your Travis API token. Get it using their CLI:
gem install travis
travis login --org
Go to the Lambda console and select Create a Lambda
function (this is another one that’s a little awkward from the CLI). When
prompted to select a blueprint, click the Skip button at the bottom of the
page. Give the new function a name and copy in the script found
here. Add environmental variables
TRAVIS_TOKEN to include your GitHub repository’s name (in the form
handle/repo) and Travis API token acquired above. Under Role choose
Basic execution handler. Click through to the next page and create the
function. Use the Test button to make sure it works.
Now create a scheduled event so that the script will run periodically. Click the Triggers tab and then Add trigger. Click the dotted grey box and choose CloudWatch Events - Schedule. For Schedule expression put in something like rate(1 day). Note that Travis will rate limit you, and you really don’t need to be rebuilding very often, so a daily schedule is a reasonable choice.
Now you’re all set. AWS will handle triggering rebuilds, and if one fails, Travis will notify you by e-mail.
In short, we now have a set of static assets in S3 that are distributed around the globe by CloudFront, TLS termination with an evergreen certificate, nearly unlimited scalability, and a deployment process based on pull requests that’s so easy that within five years you’ll probably have forgotten how it works. And despite all of this, unless you’re running a hugely successful site, costs will probably run in the low single digits of dollars a month (or less).
Addendum – After originally writing this article, I’ve since converted this site to use the stack described here. The source code is available and I wrote some justification for the conversion from a dynamic site to static.