First post, the stack. This is a static site deployed to AWS via a fully automated pipeline. No servers, no databases, no CMS. Every post is a Markdown file in a git repo.
Stack Overview
- Hugo - static site generator
- PaperMod - theme
- S3 - origin storage for built site files
- CloudFront - CDN, HTTPS, caching
- ACM - TLS certificate
- Route 53 - DNS
- GitHub Actions - CI/CD
- Terraform - all infrastructure as code
Hugo and PaperMod
Hugo is a static site generator written in Go. Write Markdown, get HTML. No runtime, no application server, nothing to patch or exploit.
The theme is PaperMod - clean, fast,
good syntax highlighting, dark mode. It lives in the repo as a git submodule so updates
are a single git submodule update rather than manually copying files.
AWS Architecture
S3
The built site sits in a private S3 bucket with all public access blocked. Nothing can reach it directly - only CloudFront can, enforced by a bucket policy scoped to the specific distribution ARN. This uses Origin Access Control (OAC) with SigV4 signed requests, which is the current AWS recommended pattern.
CloudFront
CloudFront handles everything the user actually touches - HTTPS termination, caching, and global distribution. There are a few specifics worth calling out:
Caching is split by content type. Static assets (CSS, JS, images) get a one-year
Cache-Control TTL. Hugo fingerprints these files so their URLs change when content
changes, making long cache lifetimes safe. HTML, XML, and JSON get five minutes since
they change with every post.
URL rewriting is handled by a CloudFront Function at the edge. Hugo generates clean
URLs like /posts/how-this-blog-works/ but S3 expects the full path
/posts/how-this-blog-works/index.html. The function appends index.html before the
request hits the origin.
Price class is set to PriceClass_100 covering the US, Canada, and Europe, which is
cheaper than serving from every edge location globally.
ACM
ACM provides the TLS certificate for
opscode.io and www.opscode.io. Free, auto-renews, fully managed by Terraform. One
quirk: CloudFront requires certificates to live in us-east-1 regardless of where the
rest of your infrastructure is located. AWS provider v6 handles this with a region argument
directly on the resource, so no aliased provider block is needed.
DNS validation is used - Terraform creates the required CNAME records in Route 53 and ACM validates and issues automatically.
Route 53
Terraform adds two records to the existing hosted zone:
AALIAS record foropscode.iopointing to CloudFrontAALIAS record forwww.opscode.iopointing to the same distribution
ALIAS records are an AWS-specific DNS extension that allow pointing a zone apex at another AWS resource. Standard CNAMEs cannot be used at the zone apex - that is a DNS protocol constraint, not an AWS one.
Terraform
All infrastructure is defined in a tf/ directory alongside the Hugo content in the same
repo:
tf/
├── .terraform-version # pins 1.14.6 via tfenv
├── .tflint.hcl # linter config
├── providers.tf # terraform block, S3 backend, AWS provider
├── variables.tf # domain, region, GitHub repo, tags
├── main.tf # all resources
└── outputs.tf # CloudFront ID, role ARN, bucket name
Remote state lives in a separate S3 bucket with versioning enabled and DynamoDB for state
locking. The AWS provider is pinned to ~> 6.0. The headline change in v6 is per-resource
region support - previously you needed a second aliased aws provider just to create an
ACM certificate in us-east-1. Now it is a single argument on the resource.
GitHub Actions
The pipeline in .github/workflows/deploy.yml triggers on every push to main:
checkout -> hugo build -> assume AWS role -> s3 sync -> cloudfront invalidation
Authentication uses OIDC. GitHub mints a short-lived JWT for each run, AWS exchanges it for temporary credentials scoped to a dedicated IAM role. No access keys stored anywhere, not in GitHub secrets, not in the repo.
The IAM role has exactly two permissions: S3 read/write on the site bucket and
cloudfront:CreateInvalidation on the distribution.
S3 sync runs in two passes, one for static assets with a one-year cache header, one for HTML/XML/JSON with a five-minute cache header.
CloudFront invalidation with --paths "/*" runs after sync to clear edge caches
immediately. Total pipeline runtime is about 90 seconds.
Cost
| Service | Monthly cost |
|---|---|
| S3 | ~$0.01 |
| CloudFront | ~$0.00 |
| ACM | $0.00 |
| Route 53 hosted zone | ~$0.50 |
| GitHub Actions | $0.00 |
Source, Terraform config, and workflow are all in the opscode-blog repo.