Weekend Labs
All labs
Lab 00·S3 · CloudFront · Route 53 · Astro

Building this blog with Terragrunt

A private S3 bucket behind CloudFront, a subdomain handed to Route 53 without touching the email on the parent domain, and an Astro site that reads its posts straight from the lab folders.

Terragrunt units
3 + site
AWS resources
13
Monthly cost
~$0.50
Cert issued in
~40 s
Deploy
1 command
Teardown
stays up

Every lab on this site is “build real AWS infrastructure with Terragrunt, then write it up”. So when I needed somewhere to publish, the blog became lab 00: a static Astro site in a private S3 bucket behind CloudFront, at labs.aicloudops.cloud, built with the same Terragrunt setup as everything else. It’s also the one lab that stays deployed.

The constraint: don’t break my email

I’ve owned aicloudops.cloud for a while, but only for email. Before deciding anything I looked at its public DNS with dig, which only reads:

RecordMeaning
NS → GoDaddyGoDaddy runs DNS for the whole domain
MX + SPF TXTEmail. Must not change
A for the apex and wwwA registrar parking page, nothing of mine
labsUnused

CloudFront on the bare aicloudops.cloud would mean moving the entire zone to Route 53, email records included. Instead I gave Route 53 just one subdomain: labs.aicloudops.cloud. The parent zone stays at GoDaddy, and email never notices.

The architecture

flowchart LR
    reader([Reader]) -->|labs.aicloudops.cloud| r53["Route 53 zone<br/>labs.aicloudops.cloud"]
    parent["GoDaddy DNS<br/>aicloudops.cloud<br/>(email records stay here)"] -. "NS delegation for 'labs'" .-> r53
    r53 -->|alias A/AAAA| cf["CloudFront<br/>+ ACM certificate"]
    cf -->|origin access control| s3[("Private S3 bucket<br/>Astro build")]
    dev["site/ (Astro)"] -->|deploy-site.sh| s3

Four units, each depending on the last:

UnitCreates
dns-zoneRoute 53 hosted zone for the subdomain
certificateACM certificate, DNS-validated in that zone
sitePrivate bucket, CloudFront with origin access control, an edge function, DNS alias records
Astro site + deploy scriptThe pages you’re reading, plus one script to publish them

Unit 1: a zone for one subdomain

The first Terragrunt lesson was how little changed. This is the repo’s first prod unit (everything before lived under sandbox), and root.hcl didn’t need a single edit. The state key is built from the folder path, so this unit’s state lands at prod/us-east-1/00-weekend-labs-blog/dns-zone/terraform.tfstate, separate from the sandbox labs. A new environment is just a new folder.

The second lesson was a prod-only safety net:

resource "aws_route53_zone" "this" {
  name = var.zone_name

  lifecycle {
    # A recreated zone gets NEW name servers, silently breaking the
    # delegation at the parent. Terraform refuses any plan that destroys it.
    prevent_destroy = true
  }
}

Route 53 gave the zone four name servers spread across .com, .org, .co.uk and .net, so one top-level domain having a bad day can’t take the zone down. Asked directly, they already answered for labs. The rest of the internet didn’t know yet.

The one manual step

The parent zone has to point at those name servers: four NS records with host labs, added in GoDaddy’s DNS records.

Gotcha: GoDaddy has two things called “nameservers”. DNS → Add record → NS delegates one subdomain, which is what I wanted. The domain-level Nameservers setting replaces DNS for the whole domain, and would have taken email with it.

Before touching anything I saved a snapshot of the parent’s records, read straight from GoDaddy’s name server. Afterwards, a diff showed MX, SPF, NS, the apex and www all identical. Only the zone’s SOA serial had moved, which every edit does. A diff where only the serial changes is the proof that email was untouched. Within minutes, Cloudflare’s and Google’s public resolvers were both returning the four AWS name servers.

Unit 2: a certificate that’s ready before anything uses it

ACM certificates are free, and DNS validation is one CNAME record that Terraform can write into the new zone itself. The interesting resource is the third one:

ResourceRole
aws_acm_certificateRequests the cert
aws_route53_record (for_each over the validation options)Publishes ACM’s proof-of-control CNAME
aws_acm_certificate_validationCreates nothing in AWS. It makes apply wait until the cert is issued

The module outputs the ARN from the validation resource, so CloudFront can never be handed a certificate that’s still pending. With the zone already delegated, the certificate went from requested to issued in about 40 seconds.

One thing that looked alarming and wasn’t: ACM reported renewal as INELIGIBLE. ACM only auto-renews certificates that something is using, and this one wasn’t attached to anything yet.

Unit 3: a bucket nobody can read except CloudFront

The bucket has public access blocked, ACLs off and no website hosting. CloudFront reaches it through origin access control: it signs every request, and the bucket policy accepts s3:GetObject from the CloudFront service only when the request comes from this distribution (AWS:SourceArn). A direct request to the bucket gets a 403.

Without S3 website hosting, nothing turns /labs/foo/ into /labs/foo/index.html, which is how Astro lays out pages. A CloudFront Function fixes that at the edge:

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

The rest is deliberately boring: AWS’s managed caching and security-header policies (HSTS, nosniff, frame options), TLS 1.2 or newer, HTTP/2 and HTTP/3, IPv6, and the cheapest price class. This unit has two dependency blocks, one for the zone and one for the certificate, so terragrunt run --all builds the three units in order on its own.

Gotcha: with origin access control, a missing file comes back as 403, not 404. CloudFront isn’t allowed to list the bucket, so S3 won’t admit the object doesn’t exist. Both codes map to the site’s 404 page. And until that page exists, visitors see S3’s raw AccessDenied XML, which is exactly what my first visit showed.

Unit 4: the site itself

The design started as a one-page mockup and became an Astro project in site/: a layout with a light/auto/dark switch, a Medium-style post list, a sidebar, and a page template for posts.

The decision I like most: posts live with their labs, not in the site project. Each lab folder has a post.md next to its img/ folder of console screenshots, and Astro’s content collection reads labs/*/post*.md from outside the project. Each screenshot exists in exactly one place, and the build converts them all to WebP at about 15% of the original size. A 30-line plugin turns ![alt](shot.png "caption") into a figure with a caption.

Publishing is one script. It reads the bucket name and distribution ID from Terragrunt’s outputs, so nothing is hard-coded, and then:

  1. builds the site
  2. uploads the content-hashed assets (/_astro/*) with a one-year immutable cache
  3. uploads the pages with max-age=0, must-revalidate, deleting anything removed
  4. invalidates CloudFront’s cache (/*, which counts as one path)

Did it work?

CheckResult
https://labs.aicloudops.cloud/200, valid Amazon certificate
http://301 to https://
/labs/<post> with no trailing slash200, via the edge function
A page that doesn’t exist404 with the site’s own page
The bucket, asked directly403
Email DNS on the parent domainUnchanged

What it costs

About $0.50 a month, nearly all of it the Route 53 hosted zone. The certificate is free, a blog’s traffic fits comfortably inside CloudFront’s always-free tier, and S3 storage for half a megabyte is a rounding error.

What I’d tell someone doing the same

  • Delegate a subdomain if the parent domain already does something important, like email. It’s four records at the old DNS host, and nothing else there changes.
  • Snapshot DNS before you touch it, then diff afterwards. Expect only the SOA serial to move.
  • Read the certificate ARN from the validation resource so nothing downstream gets a pending cert.
  • Keep the bucket private and let origin access control do the work, and remember that “missing” will look like 403.
  • Put prevent_destroy on anything whose replacement breaks something outside Terraform, like name servers that another provider points at.