← Back to blog

20 February 2026

How This Site Is Built

awsterraformdevops

Performance metrics

personal-site-performance.webp

Tech Stack

Layer Technology
Frontend Preact, Wouter, Tailwind CSS v4, DaisyUI v5
Build Vite 7 with prerendering
Hosting AWS S3 + CloudFront
Contact form API Gateway v2 → Lambda (Node 20) → SES
Infrastructure Terraform
CI/CD GitLab CI with OIDC auth to AWS

Hosting

The site is entirely static. Vite prerenders every route to HTML at build time, which means:

  • No server to manage or scale
  • Fast TTFB from CloudFront edge cache
  • Zero cold-start latency for page loads

The S3 bucket is private — CloudFront accesses it via Origin Access Control (OAC). HTML files are served with no-cache; hashed JS/CSS assets with immutable.

Contact form

The one dynamic piece is the contact form. The form POSTs JSON to an API Gateway endpoint, which triggers a Lambda function that sends the email via SES. The function checks a hidden honeypot field to filter obvious spam.

Blog

Blog posts are plain Markdown files with YAML frontmatter. There's no CMS, database, or build-time API calls — just files in src/content/blog/.

---
title: How This Site Is Built
date: 2026-02-20
description: The architecture behind craigharley.co.uk
draft: false
---

At build time, Vite's import.meta.glob eagerly loads every .md file. The frontmatter is parsed at runtime with a parseFrontmatter() function. Marked converts Markdown to HTML, and highlight.js handles syntax highlighting via a custom marked-highlight integration. Posts with draft: true are excluded from the index and sitemap automatically.

Infrastructure as code

Everything is Terraform, split into two workspaces:

  • bootstrap — S3 state bucket, DynamoDB lock table, GitLab OIDC provider, CI IAM role
  • prod — CloudFront distribution, S3 bucket, Route53 records, API Gateway, Lambda, SES

The S3 bucket is kept private; CloudFront accesses it through an Origin Access Control policy:

resource "aws_s3_bucket" "site" {
  bucket = "craigharley-co-uk"
}

resource "aws_cloudfront_origin_access_control" "site" {
  name                              = "site-oac"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

resource "aws_cloudfront_distribution" "site" {
  origin {
    domain_name              = aws_s3_bucket.site.bucket_regional_domain_name
    origin_id                = "s3-site"
    origin_access_control_id = aws_cloudfront_origin_access_control.site.id
  }

  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = "s3-site"
    viewer_protocol_policy = "redirect-to-https"
    cache_policy_id        = data.aws_cloudfront_cache_policy.caching_optimized.id
  }

  # Serve index.html for any 403/404 (SPA routing)
  custom_error_response {
    error_code         = 403
    response_code      = 200
    response_page_path = "/index.html"
  }
}

The Lambda contact handler is bundled with esbuild and deployed as a zip archive:

resource "aws_lambda_function" "contact" {
  filename         = data.archive_file.contact.output_path
  source_code_hash = data.archive_file.contact.output_base64sha256
  function_name    = "contact-handler"
  handler          = "contact.handler"
  runtime          = "nodejs20.x"
  role             = aws_iam_role.lambda.arn

  environment {
    variables = {
      TO_EMAIL   = var.to_email
      FROM_EMAIL = var.from_email
    }
  }
}

GitLab OIDC auth eliminates the need for stored AWS credentials. The CI role is scoped to only what the pipeline needs:

resource "aws_iam_role" "gitlab_ci" {
  name = "gitlab-ci"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Federated = aws_iam_openid_connect_provider.gitlab.arn }
      Action    = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringLike = {
          "gitlab.com:sub" = "project_path:CraigHarley/craigharley.co.uk:*"
        }
      }
    }]
  })
}

CI/CD

GitLab CI runs six stages: lint → test → build → tf plan → tf apply (manual gate) → deploy. AWS credentials are never stored, the GitLab runner assumes an IAM role via OIDC.

Still todo:

  • Auto yes on terraform apply if there are no infra changes

Cost

For a low-traffic personal site, the monthly bill is almost entirely Route53:

Service Monthly cost
Route53 hosted zone ~$0.50
S3 + CloudFront < $0.01
Lambda + API Gateway Free tier
SES Free tier
Total ~$0.50–$1

The AWS free tier covers CloudFront (10 TB transfer, 10M requests/month) and Lambda (1M requests, 400K GB-seconds/month) — well beyond what a personal site generates.

Considered using Cloudflare for DNS as it's cheaper, but would involve quite a bit more terraform with having two providers.