← blog ·
How this site is built (and why a resume needs a threat model)
A resume site is a tiny system, so it’s a good place to practice the habits I’d expect on a big one. Every decision below answers one of two questions: what’s the smallest thing that can be exposed? and what happens when a piece of the pipeline is compromised?
The shape
visitor ──TLS 1.2+──> CloudFront ──OAC (SigV4)──> private S3 bucket
│
└── /api/contact ──> HTTP API (throttled) ──> Lambda ──> SES ──> my inbox
There’s no web server, no container and no database. The only code that runs server-side is about 130 lines of Python behind the contact form.
The bucket is not a website
S3 can host a website directly, but the website endpoint is HTTP-only and the bucket has to be public.
Here the bucket has Block Public Access on, ACLs disabled, and a bucket policy that allows one
principal: cloudfront.amazonaws.com, conditioned on the ARN of this distribution. CloudFront signs
each origin request with SigV4 through Origin Access Control. A second statement denies any request that
isn’t using TLS.
Because the bucket doesn’t grant s3:ListBucket, a missing object returns 403 instead of 404. That way
nobody can enumerate keys, and CloudFront turns the 403 into a friendly 404 page.
Headers do most of the work
A CloudFront response headers policy attaches the following to every response:
| Header | Value (abridged) | Why |
|---|---|---|
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
.dev is already HSTS-preloaded as a TLD; this is belt and braces |
Content-Security-Policy |
default-src 'self'; script-src 'self'; style-src 'self'; frame-ancestors 'none'; … |
No inline script, no third-party anything |
Permissions-Policy |
camera=(), microphone=(), geolocation=(), … |
The site never needs device APIs |
Cross-Origin-Opener-Policy |
same-origin |
Isolates the browsing context |
X-Content-Type-Options |
nosniff |
No MIME confusion |
Getting to script-src 'self' with no 'unsafe-inline' took some build changes. Astro bundles every
script into an external file, stylesheets are never inlined, and Markdown code highlighting uses Prism’s
CSS classes rather than inline style attributes. The terminal and the hero animation build their output
with DOM text nodes and never use innerHTML, so nothing a visitor types can become markup.
A contact form that can’t leak an inbox
I didn’t want my email address on the page, and a static site can’t send mail. So the form posts to
/api/contact on the same origin. CloudFront routes that path to an API Gateway HTTP API, which means
there’s no CORS and no extra origin in the CSP.
- Throttling: the API stage allows one request per second with a burst of five. Anything beyond that gets a
429. - Origin secret: CloudFront adds a random header to every API request, and the Lambda rejects requests
without it. Finding the
execute-apiURL doesn’t let anyone bypass the edge. - Honeypot: there’s a hidden field that only bots fill in. They get a cheerful
200, and no email is sent. - Header injection: CR/LF characters are stripped from anything that ends up in a mail header, and the reply-to address is validated.
- Least privilege: the function’s role can call
ses:SendEmailonly on the site’s identities, and only when theFromaddress isnoreply@this domain. The role also carries a permissions boundary, so even the pipeline that created it couldn’t have given it more.
The mail is DKIM-signed with a 2048-bit key, uses a custom MAIL FROM domain so SPF aligns, and the domain
publishes DMARC p=quarantine.
Terraform, in two stacks
- bootstrap runs once from a laptop. It creates the state bucket (versioned, encrypted, TLS-only, protected from destroy), the Route 53 zone, the GitHub OIDC provider and two IAM roles.
- main contains everything else, and only GitHub Actions applies it.
State uses S3’s native lock file, so there’s no DynamoDB table. Account IDs, the state bucket name and
the recipient address are never committed. They live in a git-ignored backend.hcl locally and in
GitHub secrets in CI.
No keys in CI
GitHub Actions authenticates to AWS with OIDC, so there are no access keys to leak or rotate:
- Pull requests can assume only a plan role (
ReadOnlyAccessplus the state lock), and only when the token’ssubisrepo:<this repo>:pull_request. - The apply role trusts only
repo:<this repo>:environment:production. That GitHub environment is restricted tomainand requires my approval. - The apply role’s permissions are scoped to the
resume-site-*prefix. It can create IAM roles only with the boundary attached, and an explicit deny stops it from removing boundaries or editing policies.
Plan output contains ARNs, so the workflow never prints it. The job summary lists only resource addresses and actions.
A build that refuses to leak me
My source resume contains a phone number and home address. The build renders the PDF from the same redacted data as the site. Then a scanner checks every built file, the PDF’s text, and every file git would commit for:
- email addresses and phone-shaped numbers
- street addresses and 12-digit account IDs
- the specific values from my real resume, matched by salted hash so the values themselves never live in the repo
If anything turns up, the deploy stops.
What it costs
At resume-site traffic it’s close to nothing. Route 53 is $0.50/month for the zone. CloudFront, Lambda, API Gateway and SES fit comfortably in the free tiers. There’s deliberately no WAF subscription: throttling, the origin secret and the honeypot cover this threat model.
Try it
Press </kbd> anywhere on the site and run arch, or threat-model E. And yes, try sudo`.