This site is a static Astro build - plain HTML and CSS, no client-side framework. It sits in a private S3 bucket, not using S3's static website hosting feature. CloudFront reads it through Origin Access Control, and the bucket policy only allows reads from this one distribution's ARN, not from any CloudFront distribution in the account.
Because the bucket isn't an S3 website endpoint, S3 won't serve /architecture/index.html for a request to /architecture. A CloudFront Function does it instead. It runs on every viewer request, before the cache lookup, and rewrites any path without a file extension:/path becomes /path/index.html, and a trailing slash gets index.html appended. Requests for files, like/Nalinka_Heshan_CV.pdf, pass through untouched.
The only route from a viewer to the bucket is through CloudFront and Origin Access Control, never directly.
Deploys
Pushing to master triggers a GitHub Actions workflow: it builds the site, assumes an IAM role over OIDC, syncs the build to the bucket, and invalidates the CloudFront distribution.
There are no AWS access keys stored in the repository or in GitHub secrets. GitHub Actions authenticates by requesting a short-lived OIDC token from GitHub's own token endpoint and exchanging it for temporary credentials scoped to one IAM role. The role's trust policy only accepts that token for this exact repository and branch, and its permissions cover writing to one S3 bucket and invalidating one CloudFront distribution - nothing else.
The teal arrows are the only place a credential exists: a short-lived token exchanged for temporary STS credentials, never stored.
Infrastructure
The infrastructure is defined in Terraform, not clicked together by hand in the AWS console: the S3 bucket and its bucket policy, the CloudFront distribution with its Origin Access Control and the URL-rewrite function, the ACM certificate, the GitHub OIDC identity provider, and the IAM role that trusts it. State is local, not remote - one operator and a handful of resources, so there is nothing to lock against.
DNS
DNS is Cloudflare, not Route 53. The domain is registered through Cloudflare Registrar, and its registration agreement requires Cloudflare's own nameservers at every plan tier - there's no delegating to Route 53. The ACM validation record and the apex record pointing at CloudFront are both added by hand in Cloudflare, with the proxy switched off. The apex record is a CNAME, which standard DNS doesn't allow at the apex - Cloudflare flattens it and answers with A records. With the proxy on, visitors would see Cloudflare's certificate and every request would pass through two CDNs. The ACM certificate would only secure the hop between them.
OIDC subject claim
GitHub changed how it writes identity into the OIDC token: for repositories created, renamed or transferred after 15 July 2026, the subject claim embeds the numeric owner and repository IDs, not just their names - repo:nalinka1@35029715/nalinka-heshan@1358027744:ref:refs/heads/master, not the plain repo:nalinka1/nalinka-heshan:ref:refs/heads/master that most OIDC tutorials still assume. I found this out by decoding the token from a live workflow run after role assumption kept failing against a trust policy that looked correct.