Performance metrics

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 applyif 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.