All cloud projects
LIVE us-east-1 2026
CLOUD PROJECT · 2026

Ephemeral PR preview environments

Every pull request gets its own throwaway deploy of the site at demos.andruxa.dev/previews/pr-<N>/, posted as a comment, and torn down when the PR closes. The interesting part isn't the preview — it's that a PR-triggered job authenticates with a dedicated OIDC role that can touch only the previews/ prefix and nothing else, and that fork PRs can never assume it.

GitHub ActionsOIDCIAMS3CloudFrontTerraform

The problem

Reviewing a frontend change from a diff is guessing. What you actually want is the built site, running, at a URL you can click — per pull request, created and destroyed automatically. Vercel and Netlify give you this for free, which is exactly why doing it yourself on raw AWS is the interesting exercise: the mechanism is simple, but the security model is where it’s easy to get wrong, and getting it right is the whole point.

The architecture

On a pull request, a workflow builds the site with a per-PR base path (/previews/pr-<N>/, so every asset and link resolves under that prefix), syncs the build to a dedicated previews S3 bucket, and serves it through the existing demos CloudFront distribution under a /previews/* behavior. It then posts (and keeps updated) a sticky PR comment with the URL. When the PR closes, a cleanup job deletes the prefix and invalidates it.

The part that matters is the IAM boundary. The job authenticates with GitHub OIDC — no stored AWS keys — but it does not reuse the production deploy role. It assumes a separate, dedicated role whose entire permission set is: write/delete objects under previews/*, list that prefix, and invalidate the demos distribution. It cannot read or write the production site bucket, cannot touch any other bucket, cannot reach the API or data. A PR — including one that runs attacker-authored code — is the least-trusted thing that touches this account, so it gets the least privilege.

Pull requestbuild (base=/pr-N/)previews OIDC rolepreviews/* ONLYpreviews bucketdemos CloudFront/previews/pr-N/sticky commentURL →
PR opened/updated → workflow builds with base /previews/pr-N/ → assumes the scoped previews OIDC role → syncs to the previews bucket → demos CloudFront /previews/* serves it → sticky PR comment. PR closed → cleanup job deletes the prefix.

The security model (the actual project)

Three decisions do the work:

pull_request, never pull_request_target. The classic preview-CI vulnerability is using pull_request_target (which runs with repo secrets) and then checking out and building the PR’s code — handing attacker code your credentials. This workflow uses plain pull_request: fork PRs run without secrets, and GitHub withholds the OIDC id-token from them entirely, so a fork simply cannot authenticate.

A same-repo guard, explicit. Every job is gated on github.event.pull_request.head.repo.full_name == github.repository. Fork PRs skip the jobs cleanly rather than relying only on the token behavior above. Defense in depth.

A dedicated, minimal role — and no environment:. The role’s trust policy accepts exactly one subject, repo:<owner>/<repo>:pull_request, and its permission policy is scoped to s3:*Object on previews/*, s3:ListBucket on that prefix, and cloudfront:CreateInvalidation on the demos distribution. Nothing else. Notably the workflow sets no GitHub environment: — doing so would flip the OIDC subject to environment:<name> and no longer match the trust policy. The result: even if the preview build were fully compromised, the blast radius is a throwaway prefix on a demo bucket.

Cost breakdown

ComponentMonthly cost
Previews S3 (throwaway builds, 14-day backstop expiry)fractional cents
CloudFront (demos distribution, shared)$0
GitHub Actions (public repo minutes)$0
Total≈ $0/mo

What I’d do differently at scale

For a team this grows a few features: preview builds seeded with branch-specific env/secrets (via environment protection rules and a matching OIDC subject), screenshot diffing (Playwright) posted to the PR alongside the link, and a TTL sweeper that reconciles orphaned prefixes against open PRs instead of trusting the close event. If previews needed real backends — not just static builds — each would spin up an isolated, namespaced stack (its own DynamoDB tables, its own queue) behind the same least-privilege, per-PR-scoped role, torn down on close. The invariant that carries over unchanged is the one this project is really about: the thing triggered by untrusted input gets the smallest possible credential.