How this site deploys itself

Hosting

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.

Request path: from a viewer to the private S3 bucket A viewer looks up nalinkaheshan.dev at Cloudflare DNS, which has its proxy off and points at CloudFront. The HTTPS request goes to the CloudFront distribution, which uses an ACM certificate. A viewer-request CloudFront Function rewrites /path to /path/index.html before the cache lookup. On a cache miss, Origin Access Control signs the request with SigV4 and reads the object from a private S3 bucket whose policy allows s3:GetObject only from this one distribution. Request path How a page request reaches a private S3 bucket through CloudFront, with no public bucket access AWS account · all defined in Terraform CloudFront · edge network Region · us-east-1 Viewer Browser Cloudflare DNS DNS only, proxy off CNAME → CloudFront CloudFront Distribution nalinkaheshan.dev Function Viewer request append-index-html Edge cache Cache lookup CachingOptimized Viewer policy Redirect to HTTPS TLS 1.2 minimum GET and HEAD only URI rewrite /cv becomes /cv/index.html Before cache lookup OAC Origin access sigv4 · always Direct bucket URL No public access Returns 403 Only CloudFront reads Cache-Control set at deploy _astro/* public, max-age=31536000, immutable *.html no-cache Nalinka_Heshan_CV.pdf no-cache ACM TLS certificate nalinkaheshan.dev S3 Private bucket nalinkaheshan.dev No website hosting Bucket policy Action s3:GetObject Principal CloudFront Source this distribution Public access blocked 1 2 HTTPS 3 4 5 miss 6 T Legend Edge · CloudFront Compute · Function Data · S3 Security · ACM, OAC Request Attached config Outside AWS Flow 1 DNS lookup at Cloudflare 2 HTTPS request to CloudFront 3 Rewrites /cv to /cv/index.html 4 A cache hit is served from here 5 On a miss, OAC signs with SigV4 6 s3:GetObject from the private bucket T ACM certificate on the distribution
Request path: from a viewer to the private S3 bucket A viewer looks up the domain at Cloudflare DNS, which has its proxy off, then sends an HTTPS request to CloudFront. A viewer-request CloudFront Function rewrites /path to /path/index.html, the edge cache is checked, and on a miss Origin Access Control signs a request to a private S3 bucket that only this distribution can read. Request path From a viewer to a private S3 bucket AWS account us-east-1 Defined in Terraform Viewer Browser nalinkaheshan.dev 1 DNS lookup at Cloudflare, proxy off 2 HTTPS CloudFront Distribution nalinkaheshan.dev ACM certificate · TLS 1.2 minimum 3 viewer request Function URI rewrite /cv → /cv/index.html 4 rewritten URI Edge cache Cache lookup Managed-CachingOptimized 5 cache miss OAC Origin access control sigv4 · always signs 6 signed GET S3 Private bucket nalinkaheshan.dev Only this distribution can read it Legend Edge Compute Data Security Request Outside AWS
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.

Deploy pipeline: from a push to master to a live site, with no stored AWS keys A push to master starts a GitHub Actions workflow that builds the site. The runner requests a short-lived OIDC token from GitHub and sends it to AWS STS. STS checks the IAM role trust policy, which requires the sts.amazonaws.com audience and a subject naming this repository and branch, then issues temporary credentials for the deploy role. With those, the workflow syncs the build to the S3 bucket, HTML last, and invalidates the CloudFront distribution. The role can only write to one bucket and invalidate one distribution. Deploy pipeline Push to master, build, exchange an OIDC token for temporary credentials, sync to S3, invalidate CloudFront GitHub · nalinka1/nalinka-heshan AWS account · all defined in Terraform Site · bucket and distribution IAM · identity and access git push Branch master Actions ubuntu-latest deploy.yml Build Astro, static npm run build OIDC token Short-lived JWT id-token: write S3 sync order _astro/* immutable Other assets default CV PDF no-cache *.html no-cache HTML goes last S3 Site bucket nalinkaheshan.dev CloudFront Invalidation --paths "/*" No stored keys No AWS keys in the repo or in GitHub secrets Credentials expire IAM role nalinkaheshan-dev gha-deploy- STS WebIdentity AssumeRoleWith Trust policy aud sts.amazonaws.com sub repo IDs + master Role permissions This bucket only s3:PutObject s3:DeleteObject s3:ListBucket This distribution only cloudfront: CreateInvalidation 1 2 3 4 signed JWT 5 6 7 Legend CI · GitHub Security · OIDC, STS, IAM Data · S3 Edge · CloudFront Deploy step Credential exchange Detail Outside AWS Flow 1 Push to master starts the workflow 2 Builds the site into dist/ 3 Requests an OIDC token from GitHub 4 Sends the token to AWS STS 5 Trust policy passes, role assumed 6 aws s3 sync in passes, HTML last 7 Invalidates /* on the distribution
Deploy pipeline: from a push to master to a live site, with no stored AWS keys A push to master starts a GitHub Actions workflow that builds the site and requests a short-lived OIDC token. AWS STS exchanges the token for temporary credentials on a deploy role that trusts only this repository and branch. The workflow then syncs the build to S3, HTML last, and invalidates CloudFront. Deploy pipeline From a push to a live site, no stored AWS keys GitHub nalinka1/nalinka-heshan AWS account us-east-1 Defined in Terraform git push Branch master 1 starts the workflow Actions ubuntu-latest deploy.yml 2 build Build Astro, static npm run build 3 request token OIDC token Short-lived JWT id-token: write 4 signed JWT STS Token exchange AssumeRoleWithWebIdentity 5 assume role IAM role Deploy role gha-deploy-nalinkaheshan-dev Trusts this repo and branch only 6 aws s3 sync S3 Site bucket nalinkaheshan.dev HTML is synced last, after its assets 7 invalidate CloudFront Invalidation --paths "/*" Legend CI Security Data Edge Deploy step Credential exchange Outside AWS
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.