How this blog is built and deployed

#aws #cloudfront #s3 #github-actions

Table of Contents

I wanted to create a simple static HTML blog. Among many technologies, I rapidly settled for Hugo’s ease of use and large user base. Architecture-wise, this site is a set of static files generated by Hugo, stored in a private S3 bucket and served through CloudFront. Publishing a post means pushing a Markdown file to GitHub, and launching a CI/CD pipeline ; everything after that is handled automatically.

Why a static site

I used to have a VPS at Infomaniak. While I like their sustainability approach, I wanted ot try out the ease-of-use of the serverless framework without having to rent a dedicated VM.
A blog does not need a server. Every page can be generated ahead of time, which leaves nothing to patch, nothing to scale and almost nothing to pay for. I wanted three things:

  • the source in Git: every change is versioned (I am a big proponent of versioning and declarative approaches in general) ;
  • reduce friction: deployment on commit, so writing is the only manual step: I can write an article anywhere and have it posted from anywhere ;
  • hosting on AWS managed services, with no machine to look after.

The pieces

PieceRole
Hugo, with the TeXify3 themeTurns Markdown into HTML
GitHubStores the source code
GitHub ActionsBuilds and deploys on every push
Amazon S3Stores the generated files in a private bucket
Amazon CloudFrontServes the site over HTTPS from edge locations
AWS Certificate ManagerProvides the TLS certificate
AWS IAMLets GitHub Actions deploy without stored access keys
Cloudflare DNSPoints the domain at CloudFront

How a page reaches a visitor

flowchart LR V["Visitor"] -->|"DNS lookup"| CF["Cloudflare DNS"] V -->|"HTTPS request"| CDN["CloudFront"] CDN --> FN["CloudFront Function
adds index.html"] FN --> S3["Private S3 bucket"] ACM["ACM certificate"] -.-> CDN

CloudFront is the only service allowed to read from the S3 bucket, through an origin access control and a bucket policy that names this one distribution.

That choice has a side effect. Hugo writes each page as a folder with an index.html inside, such as /posts/hello/index.html. A private S3 origin does not resolve /posts/hello/ to that file by itself, so a small CloudFront Function rewrites the request on the way in:

function handler(event) {
  var request = event.request;
  var uri = request.uri;
  if (uri.endsWith('/')) {
    request.uri += 'index.html';
  } else if (!uri.split('/').pop().includes('.')) {
    request.uri += '/index.html';
  }
  return request;
}

The certificate comes from AWS Certificate Manager and it has to be requested in the us-east-1 region for CloudFront to accept it, whatever the region in which the bucket lives in, and it is validated with a single DNS record.

How a post gets published

sequenceDiagram participant Me participant GitHub participant Actions as GitHub Actions participant IAM as AWS IAM participant S3 participant CloudFront Me->>GitHub: git push to main GitHub->>Actions: start workflow Actions->>Actions: build site with Hugo Actions->>IAM: present OIDC token IAM-->>Actions: temporary credentials Actions->>S3: sync generated files Actions->>CloudFront: invalidate cache

The workflow installs Hugo, Dart Sass and the theme’s PostCSS dependencies, builds the site, copies the output to the bucket and clears the CloudFront cache. A full run takes less than a minute.

Deploying without stored keys

No AWS access key is saved in the repository. GitHub Actions proves its identity to AWS with a short-lived OpenID Connect token, and IAM exchanges it for temporary credentials tied to one role.

flowchart TD T["OIDC token from GitHub"] --> C{"Trust policy:
right repository
and branch?"} C -->|"yes"| R["Assume deploy role"] C -->|"no"| D["Access denied"] R --> P["Allowed actions only"] P --> A["Write to one S3 bucket"] P --> B["Invalidate one distribution"]

The role can do two things: write to this bucket and invalidate this distribution. If the workflow were ever compromised, that is the limit of the damage.

Cost

For a site this size the bill should stay close to zero. Storage is a few megabytes, each deploy uploads a couple of hundred small files, and the only part that grows with usage is traffic through CloudFront.

What’s next

More articles coming soon now that publishing is one commit away!
I might also redeploy this website through Terraform in the near future.