← Home

Building this website like a production service

Terraform, OIDC and a strict CSP

· Read the case study

How I deploy my portfolio to AWS without any stored key, and why its Content Security Policy needs no exceptions.

The idea

A portfolio is a simple thing: a few static pages. I wanted to host mine the way I would host a service at work, so that the infrastructure, the deployment and the security are part of what it shows. The site is built with Astro, stored in a private S3 bucket and served by CloudFront. Every AWS resource is described in Terraform, and GitLab CI builds and deploys it.

Three choices carry most of the weight: a strict Content Security Policy, a deployment with no long-lived credential, and Terraform state that is stored and locked remotely. This article walks through each one, then the things that went wrong.

A Content Security Policy with no exceptions

A strict CSP is easy to write and hard to keep, because most sites rely on inline scripts or styles that it would block. So I started from the other end: I built the site so that nothing inline exists, then wrote the policy.

Two Astro settings do most of the work. inlineStylesheets: 'never' forces every stylesheet into a file, and assetsInlineLimit: 0 does the same for scripts, so the build never inlines code. The policy can then start from default-src 'none' and allow each type of resource from my own origin only.

The policy served by CloudFront
default-src 'none'; script-src 'self'; style-src 'self';
img-src 'self' data:; font-src 'self'; connect-src 'self';
manifest-src 'self'; base-uri 'none'; form-action 'none';
frame-ancestors 'none'; upgrade-insecure-requests

The dark mode toggle was the one place where I was tempted to add an inline script, to apply the saved theme before the page paints and avoid a flash. Instead, it is a small external file loaded synchronously in the head. It is an ordinary script-src 'self' script, and the flash is gone.

The JSON-LD block I added for search engines is a data block, not executable code, so the policy does not affect it. The result: Mozilla Observatory grades the headers A+ (130 out of 100, all 12 tests passed), and there is no 'unsafe-inline' anywhere.

Deploying without an AWS key

The classic way to deploy from CI to AWS is to create an IAM user, generate an access key and store it as a CI variable. That key never expires, and whoever steals it gets the same rights as the pipeline.

With OIDC, GitLab gives each job a short-lived signed token that says which project and which branch it comes from. AWS checks the token against a trust policy and hands the job temporary credentials. Nothing secret is stored in GitLab: only the role ARN, the bucket name and the distribution ID, which are identifiers, not secrets.

The important part is the trust policy. It accepts exactly one project on exactly one branch:

Trust policy condition (Terraform)
condition {
  test     = "StringEquals"
  variable = "${local.gitlab_host}:sub"
  values   = ["project_path:${var.gitlab_project_path}:ref_type:branch:ref:${var.gitlab_branch}"]
}

A merge request or another branch produces a different sub claim and is refused. The role itself can only list and write objects in this one bucket and invalidate this one distribution, and the credentials it yields last at most one hour.

In practice a merge request runs every check but cannot deploy, and only main can assume the role.

Terraform state you can trust

Terraform keeps a state file that maps the code to the real resources. Kept on one laptop it is fragile: lose the file and Terraform forgets what it created. The state now lives in a versioned, encrypted S3 bucket with native locking (use_lockfile, available since Terraform 1.10), so two runs can never write it at once, and no DynamoDB table is needed.

The bucket that holds the state cannot be created by the configuration that uses it, so a second, tiny Terraform configuration called bootstrap creates it once.

CI never applies anything. It checks the formatting, validates the code and runs Checkov on every pipeline, and I run terraform apply myself after reading the plan. Each Checkov rule I skip is documented with its reason (cost, or not applicable to a static site), and any new finding fails the build.

What went wrong along the way

Sub-pages returned errors. The default root object of CloudFront only applies to the root URL, so /projects/this-website/ asked S3 for a folder that does not exist. A tiny CloudFront Function now rewrites those requests to index.html.

The link checker blocked its own deployment. Every page declares a canonical URL on the real domain, and the checker followed it. For a new page, that URL does not exist until the deployment, which waits for the checker. I now tell the checker to skip my own domain; internal links are still checked in the build output.

Region limits. My AWS organisation only allows eu-north-1, apart from global services. CloudFront certificates must live in us-east-1, so the certificate is the one thing created there.

Checkov passed on my laptop and failed in CI. My local variables file is not in Git, so CI saw different defaults. A small ci.tfvars with non-secret values makes both runs identical.

What is next

The remaining idea is to quote a recommendation once I have one. The full list of decisions, with the architecture diagram, is in the case study.

Contact

Email meLinkedIn

Based in Lyon, France

Last updated

Download CVContact