The All-Amazon Static Site Stack

Amazon Web Services has long been a strong option for static hosting, but the missing piece for many was frictionless HTTPS on a custom domain. With AWS Certificate Manager (ACM), that gap is closed. Composing S3, CloudFront, ACM, and Route53 now delivers a stack that combines effectively unlimited scalability, automatic certificate renewal, a global CDN, and a deployment flow that runs from a standard GitHub pull request workflow.

Building the site

The actual static site generator is your choice. Many solid frameworks exist, but writing your own build script can be just as fast as learning a new framework's conventions. A custom script trades long-term maintenance burden for flexibility. The singularity example repository uses a Go build script, paired with a small standard-library web server and fswatch for a fast local development loop.

Setting up the AWS services

All commands use the AWS CLI. Install it, then run aws configure with your access key, secret key, and a default region.

S3 for storage

Create a bucket named after your custom domain:

$ export S3_BUCKET=singularity.brandur.org
$ aws s3 mb s3://$S3_BUCKET

Deployment is a Makefile task. Assets are uploaded in two passes: HTML files get an explicit text/html content type, which enables extensionless URLs like /hello; all other files rely on extension-based detection.

# Makefile

deploy:
ifdef AWS_ACCESS_KEY_ID
	aws --version

	# 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
else
	# No AWS access key. Skipping deploy.
endif

Run the task with make deploy:

$ export $AWS_ACCESS_KEY_ID=access-key-from-aws-configure-above
$ export $AWS_SECRET_ACCESS_KEY=secret-key-from-aws-configure-above
$ make deploy

The credentials configured for the CLI will work for now, but they are too broad for production use. Limiting exposure comes later with IAM.

ACM for certificates

ACM issues a certificate for your domain, attaches it to CloudFront, and handles renewal automatically. Wildcard certificates are supported, and the service is free.

aws acm request-certificate --domain-name singularity.brandur.org

Approval happens via email sent to the domain administrator; accept that request before proceeding.

CloudFront for the CDN

CloudFront distributes content across Amazon's edge locations and terminates TLS for your custom domain. Create a distribution via the console (the CLI is awkward here) and choose Web when prompted. Defaults mostly apply, with these changes:

  • Origin Domain Name: select your S3 bucket.
  • Viewer Protocol Policy: choose Redirect HTTP to HTTPS.
  • Alternate Domain Names (CNAMEs): add your custom domain.
  • SSL Certificate: choose Custom SSL Certificate and pick the ACM-issued one.
  • Default Root Object: set the S3 path served at the domain root, e.g., index.html.

When ready, the distribution gets a domain like da48dchlilyg8.cloudfront.net. Availability can take a few minutes.

Route53 or any DNS

Point a CNAME record at your CloudFront distribution domain. Your site should now resolve over HTTPS.

IAM for restricted access

Deploying with root credentials is risky. Create a dedicated IAM user:

aws iam create-user --user-name singularity-user
aws iam create-access-key --user-name singularity-user

The output includes an access key and secret key—save them.

Next, create a policy file that scopes access to just the S3 bucket holding your site. Save this as policy.json, replacing the bucket name with your own:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "s3:*",
            "Resource": "arn:aws:s3:::singularity.brandur.org"
        },
        {
            "Effect": "Allow",
            "Action": "s3:*",
            "Resource": "arn:aws:s3:::singularity.brandur.org/*"
        }
    ]
}

Create the policy and attach it to the 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

This user can only touch that one bucket. If the credentials leak, the attacker could disrupt your static site but nothing else in the account.

Automated deployments

With synchronization in a Make task, deployments are one command. Running that same task inside Travis CI makes every merge to master a deployment.

Travis configuration

Install the AWS CLI in the build container and run the Make task. The .travis.yml looks like this:

# travis.yml

install:
  - pip install --user awscli

script:
  - make deploy

Credentials must not appear as plaintext in a public repository. Use Travis's encrypted environment variables via their CLI:

$ 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

Add the encrypted values to the env section with the secure: prefix:

# travis.yml

env:
  global:
    - S3_BUCKET=singularity.brandur.org

    # $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...

These secrets only exist for builds on the repository's master branch. Forked builds and pull request branches simply skip the deploy.

GitHub workflow

Push to GitHub and enable Travis for the repository. Master builds deploy automatically. Pull requests get a build with the test suite, but the encrypted secrets are absent, so deployment is skipped. Merge to master to publish.

Periodic rebuilds

Note: Lambda still works, but Travis now offers cron jobs, which are functionally equivalent and quicker to set up. In the Travis web interface, go to Settings, scroll down, and create a cron job for the master branch with an interval like daily.

A scheduled rebuild acts as a failure canary: if credentials get invalidated or a dependency breaks, you are alerted. Configure Travis to notify on build failure:

notifications:
  email:
    on_success: never

If you prefer Lambda, get your Travis API token first:

gem install travis
travis login --org
travis token

Then in the Lambda console, create a function, skipping the blueprint. Name it, paste in the script from the example repository, and add environmental variables REPOSITORY (as handle/repo) and TRAVIS_TOKEN. Choose Basic execution handler as the role. Test the function, then add a trigger using CloudWatch Events - Schedule with an expression like rate(1 day). Daily is reasonable; Travis rate limits apply.

The Bottom Line on the Static Stack

The result of this setup is a set of static assets in S3, served globally through CloudFront with automatic TLS termination and an evergreen certificate. The architecture offers effectively unlimited scalability, and the pull-request-based deployment process is simple enough that it may soon become second nature. For all but the most heavily trafficked sites, the monthly cost typically lands in the low single digits of dollars—often less.

Since this article was first published, the author has migrated their own site to this exact stack. The source code is available on GitHub, along with notes on the rationale for moving from a dynamic site to a static one.

Setting Up the AWS Services

S3 for Storage

The first step is creating an S3 bucket to hold the compiled site. Enable static website hosting on the bucket, and configure it with a bucket policy that grants public read access to all objects. This setup avoids the need for CloudFront to authenticate against the bucket origin, which simplifies the configuration.

AWS Certificate Manager

Request a certificate for your domain through ACM. Choose your primary domain and add any subdomains you intend to use (including the bare apex domain if applicable). The certificate must be issued in the US East (N. Virginia) region, us-east-1, because that is the only region CloudFront supports for ACM certificates. Validation is performed via DNS, which requires adding a CNAME record to your DNS provider.

CloudFront Distribution

Create a CloudFront distribution with the S3 website endpoint as the origin. Set the viewer protocol policy to redirect HTTP to HTTPS, and add the domain names that will be used (e.g., example.com and www.example.com) as alternate domain names. Attach the ACM certificate to the distribution. Choose a price class that fits your audience; for a global reach, you can keep the default all-edge-locations option.

DNS with Route53

In Route53 (or any DNS provider), create records pointing your domain to the CloudFront distribution. For the preferred setup, use an A record with alias to the CloudFront domain, and a second A record for the www subdomain. If you prefer the www domain as canonical, configure a redirect on the non-www domain to forward to the www variant.

IAM for Deployment

To allow automated deployments, create a dedicated IAM user with the least-privilege policy needed. The policy should allow:

  • s3:ListBucket on the target bucket
  • s3:GetObject and s3:PutObject on all objects in the bucket
  • s3:DeleteObject on all objects, so old files can be cleaned up

Generate an access key ID and secret access key for this user, and store them as environment variables for your CI system. For extra security, restrict the user to a single IP range if your build environment has a stable egress address.

Automating the Pipeline

Build Matrix on Travis

Configure Travis to watch the master branch and any pull requests. For pull requests, run a build that verifies the site compiles correctly with no broken internal links. On merges to master, execute the full deployment: build the site, sync the output to S3 (using a sync command that deletes remote files no longer present locally), and issue a CloudFront cache invalidation for the root path /* to ensure new content is served promptly.

GitHub Status Checks

To keep contributions smooth, add a lightweight Travis status check on pull requests. Pushing a commit triggers a Travis build that emits a success or failure notification back to GitHub. This provides contributors with immediate feedback on whether their changes compile and pass link checks, without requiring them to set up a local build environment.

Periodic Rebuilds with Lambda

Static sites can go stale if they depend on external data fetched at build time (e.g., a list of recent GitHub repositories). To address this without manual intervention, schedule a periodic rebuild:

  1. Create an AWS Lambda function with a Python or Node.js runtime that runs a build script to regenerate the site.
  2. Grant the Lambda execution role permission to invoke the build function and write to S3.
  3. In the Lambda code, fetches external data, builds the site into a temporary directory, uploads the result to S3, and creates a CloudFront invalidation.
  4. Set the Lambda timeout appropriately (e.g., a few minutes) and increase the memory limit if the build is memory-intensive.
  5. Create a CloudWatch Events rule with a schedule expression (e.g., every 6 hours) targeting the Lambda function.

This pattern keeps the site reasonably fresh while preserving the simplicity and cost-effectiveness of static hosting.