Static website
CloudFront, S3 per PR
Day Two #
Yesterday we proved the mechanism: GitHub can assume an AWS role via OIDC, CDK can deploy on every pull request, and teardown happens automatically.
That was the hard part. The remaining step is to make the result useful to humans.
Today we’ll keep the same pipeline shape and swap the invisible S3 proof for a real static website you can click, review, and share, still created per PR and destroyed when the PR closes.
Goal #
For every pull request:
- Upload a small static site into a private S3 bucket
- Serve it via CloudFront (HTTPS, fast, globally cached)
- Output a preview URL
- Comment that URL on the PR
- Tear it all down on PR close
This keeps the “ephemeral environment” promise while staying cheap and simple.
Prerequisites #
- You completed “Account & bootstrap” and are using a CDK TypeScript app.
- You completed “CI/CD” and your repo can deploy/destroy via GitHub OIDC (no long‑lived AWS keys).
- Your preview stack already uses a PR number (
-c pr=...) to name the stack deterministically.
We’ll extend the same preview stack entrypoint (bin/preview.ts) so the workflows stay essentially the same. This chapter changes what the stack contains, not how trust or teardown work.
The shape of the preview site #
We want three properties:
- Private origin: the S3 bucket is not public.
- Public edge: CloudFront serves the site over HTTPS.
- Deterministic outputs: the preview URL is stable for a given PR.
This is the minimum “real app” footprint that still feels like infrastructure you can build on.
Here is the shape of that preview path:
Developer pushes to PR
|
v
+----------------------+
| GitHub Actions |
| deploy preview stack |
+----------------------+
|
+------------------------------+
| |
v v
+----------------------+ +----------------------+
| CDK | | PR comment |
| Stack-PR<number> | | PreviewUrl output |
+----------------------+ +----------------------+
|
+------------------------------+
| |
v v
+------------------+ +----------------------+
| Private S3 bucket|<---------| BucketDeployment |
| static assets | | uploads local site/ |
+------------------+ +----------------------+
^
|
+------------------+
| CloudFront |
| HTTPS preview URL|
+------------------+
^
|
Browser
CDK - CloudFront backed site #
In your preview stack, add:
- An S3 bucket to hold static assets
- A CloudFront distribution with the S3 bucket as its origin
- A small deployment step that uploads local
site/files into the bucket - Outputs: bucket name + CloudFront domain
You can keep this as an evolution of your existing lib/stack.ts (or split it into lib/preview-site-stack.ts if you prefer). The important part is that the stack identity stays deterministic for each PR.
Example (illustrative) stack body:
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
export class Stack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const pr = String(this.node.tryGetContext('pr') ?? 'local');
const bucket = new s3.Bucket(this, 'SiteBucket', {
autoDeleteObjects: true,
removalPolicy: cdk.RemovalPolicy.DESTROY,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
});
const distribution = new cloudfront.Distribution(this, 'SiteDistribution', {
defaultRootObject: 'index.html',
defaultBehavior: {
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
},
errorResponses: [
// Nice for SPAs (optional)
{ httpStatus: 403, responseHttpStatus: 200, responsePagePath: '/index.html' },
{ httpStatus: 404, responseHttpStatus: 200, responsePagePath: '/index.html' },
],
});
new s3deploy.BucketDeployment(this, 'DeploySite', {
destinationBucket: bucket,
sources: [s3deploy.Source.asset('site')],
distribution,
distributionPaths: ['/*'],
});
new cdk.CfnOutput(this, 'PreviewUrl', {
value: `https://${distribution.domainName}/?pr=${encodeURIComponent(pr)}`,
});
}
}
A tiny site to deploy #
Create a site/ folder at the repo root with at least:
site/index.html
Keep it small. This is a proof, not a product. If you already have a frontend build, you can point the deployment at your build output folder instead.
CI - capture outputs and comment on the PR #
CDK can write stack outputs into a JSON file. That file is the bridge between “infrastructure happened” and “GitHub should tell humans where to click.”
In your PR deploy workflow, change the deploy command to include:
cdk deploy \
... \
--outputs-file cdk-outputs.json \
--require-approval never
Then add a step to post a PR comment using the PreviewUrl output:
- name: Comment preview URL
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const outputs = JSON.parse(fs.readFileSync('cdk-outputs.json', 'utf8'));
const stackName = `Stack-PR${process.env.PR_NUMBER}`;
const url = outputs?.[stackName]?.PreviewUrl;
if (!url) throw new Error(`Missing PreviewUrl for ${stackName}`);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Preview: ${url}`,
});
Tip: if you’d rather avoid a new comment on every push, you can:
- Search for an existing “Preview:” comment and update it, or
- Use PR checks/statuses instead of comments
We’ll keep it simple first. The output file becomes the contract between the infrastructure layer and the review experience.
No domain yet #
(Use the CloudFront URL as "prod")
This is an optional next step. It does not change the per‑PR preview flow above.
If you don’t have a domain name yet, you can still ship a real public site. The trick is to separate "preview" from "production":
- Preview stacks are ephemeral (per PR, destroyed on close).
- A prod stack is long‑lived (one stack, updated on every merge to
main).
CloudFront will give your prod distribution a URL like:
https://d123456abcdef8.cloudfront.net/
It’s not pretty, but it is HTTPS and it can be stable for months/years—as long as you keep the same distribution and update it in place.
A separate prod entrypoint #
Add a new CDK entrypoint that always deploys the same stack name, e.g. ProdStack.
bin/prod.ts
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { Stack } from '../lib/stack';
const app = new cdk.App();
const account = process.env.CDK_DEFAULT_ACCOUNT;
const region = process.env.CDK_DEFAULT_REGION;
const env = account && region ? { account, region } : undefined;
new Stack(app, 'ProdStack', { env });
You can reuse the same Stack implementation, but consider making prod safer:
- remove
RemovalPolicy.DESTROY/autoDeleteObjectsfor prod, or - guard them behind a context flag like
-c stage=preview|prod
A prod workflow #
Create a third GitHub Environment (example: aws-prod) and a workflow that runs on pushes to main.
That workflow should:
- assume the same AWS role via OIDC
- run
cdk deployforProdStack - (optionally) capture the
PreviewUrloutput and publish it somewhere obvious (PR comment is not relevant here; a build summary is fine)
Now you have:
- per‑PR preview URLs for review
- one stable CloudFront URL you can treat as your “public site” until you buy a domain
When you do buy a domain, you’ll replace the CloudFront URL with your own hostname by adding an ACM certificate + DNS records—without changing the basic pipeline shape.
Teardown notes #
S3 buckets destroy cleanly when autoDeleteObjects is on.
CloudFront sometimes takes longer to delete than other services. If you see teardown delays, that’s normal: CloudFront invalidations and distribution disable/delete can be asynchronous. For a tutorial, accept the latency; for a production workflow, you may want a more deliberate teardown strategy.
Notes and hardening #
- Least privilege: the GitHub Actions role can start with broad access, but tighten it once the pattern works (S3 + CloudFront + CloudFormation + IAM read + STS).
- Origin protection: keep the bucket private; use CloudFront’s origin access control (not a public bucket policy).
- Naming: keep stack IDs deterministic (
Stack-PR123) socdk destroynever has to guess. - Outputs as interface: treat CDK stack outputs as your CI contract (URLs, ARNs, bucket names).
- Governance: protect workflow files and the OIDC trust policy with CODEOWNERS + branch protection.
What you should see #
- A PR opens → workflow runs → CloudFront deploys → a PR comment appears with a URL.
- The URL loads a static page over HTTPS.
- PR closes → teardown workflow runs → stack is destroyed, bucket emptied and deleted, distribution removed.
Well‑Architected Framework #
- Security:
- private S3 origin, public access only through CloudFront
- no long‑lived credentials in CI thanks to OIDC
- easy to add WAF later.
- Operational Excellence:
- reproducible previews on every PR
- outputs and PR comments create a clean feedback loop for reviewers.
- Reliability:
- CDK/CloudFormation give idempotent deploys and safe rollbacks
- deterministic naming reduces operator error.
- CDK/CloudFormation give idempotent deploys and safe rollbacks
- Cost Optimization:
- resources exist only while the PR is alive
- CloudFront + S3 keep costs low for static content.
- resources exist only while the PR is alive
- Performance Efficiency
- CloudFront caches globally by default
- static assets are fast and simple.
- Sustainability:
- ephemeral environments minimize idle infrastructure and prevent forgotten resources.