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:ListBucketon the target buckets3:GetObjectands3:PutObjecton all objects in the buckets3:DeleteObjecton 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:
- Create an AWS Lambda function with a Python or Node.js runtime that runs a build script to regenerate the site.
- Grant the Lambda execution role permission to invoke the build function and write to S3.
- In the Lambda code, fetches external data, builds the site into a temporary directory, uploads the result to S3, and creates a CloudFront invalidation.
- Set the Lambda timeout appropriately (e.g., a few minutes) and increase the memory limit if the build is memory-intensive.
- 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.



