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:
| Record | Meaning |
|---|---|
NS → GoDaddy | GoDaddy runs DNS for the whole domain |
MX + SPF TXT | Email. Must not change |
A for the apex and www | A registrar parking page, nothing of mine |
labs | Unused |
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:
| Unit | Creates |
|---|---|
dns-zone | Route 53 hosted zone for the subdomain |
certificate | ACM certificate, DNS-validated in that zone |
site | Private bucket, CloudFront with origin access control, an edge function, DNS alias records |
| Astro site + deploy script | The 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:
| Resource | Role |
|---|---|
aws_acm_certificate | Requests the cert |
aws_route53_record (for_each over the validation options) | Publishes ACM’s proof-of-control CNAME |
aws_acm_certificate_validation | Creates 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
AccessDeniedXML, 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  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:
- builds the site
- uploads the content-hashed assets (
/_astro/*) with a one-yearimmutablecache - uploads the pages with
max-age=0, must-revalidate, deleting anything removed - invalidates CloudFront’s cache (
/*, which counts as one path)
Did it work?
| Check | Result |
|---|---|
https://labs.aicloudops.cloud/ | 200, valid Amazon certificate |
http:// | 301 to https:// |
/labs/<post> with no trailing slash | 200, via the edge function |
| A page that doesn’t exist | 404 with the site’s own page |
| The bucket, asked directly | 403 |
| Email DNS on the parent domain | Unchanged |
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_destroyon anything whose replacement breaks something outside Terraform, like name servers that another provider points at.