// Sep 2026 · build log · 7 min read

How this site is built: Reflex, S3, CloudFront and GitHub Actions

Pure PythonStatic exportS3 + CloudFrontOIDC, no keys~$0 / month

I wanted a portfolio I fully understood, from the first line of UI code to the server that answers the request. Two goals shaped it: write everything in Python, and deploy it on AWS properly instead of pressing a one-click deploy button. This post walks through how it came together and what each piece taught me.

Building the UI in pure Python with Reflex

Reflex lets you write a web app entirely in Python. Every piece of UI is a Python function that returns components, and Reflex compiles the whole thing into a React frontend. There is no JavaScript to write by hand.

I kept the content separate from the layout. All the text (experience, projects, even this post) lives in one data file as plain Python lists and dictionaries, and the page functions just loop over it. Updating the site usually means editing data, not layout code. The visual design is a single hand-written stylesheet.

def project(p: dict) -> rx.Component:
    return rx.el.article(
        rx.el.h3(p["title"]),
        rx.el.p(p["text"]),
        class_name="proj",
    )

The realisation: this site has no backend

A normal Reflex app runs two things: the compiled frontend, and a Python backend that holds state and talks to the browser over a websocket. My first plan was to put that in a Docker container on a virtual machine.

Then I looked at what the site actually does. It has no forms, no logins, no database and no event handlers. Every visitor gets the same pages. The Python backend would sit there doing nothing, costing money and needing security updates.

Reflex can export just the frontend as plain HTML, CSS and JavaScript. The first time I served that export on its own, the page rendered but quietly tried to open a websocket to a backend that did not exist, and flagged a connection error. Turning off Reflex's state system for the app fixed it, and the export became a truly static site.

app = rx.App(enable_state=False)

# builds plain static files, no server needed
$ reflex export --frontend-only --no-zip

Once the site is just files, the question becomes where to put files. On AWS the standard answer is S3 for storage and CloudFront, the CDN, in front of it.

  • No server to patch, restart or monitor. Nothing is running that can crash or be attacked.
  • CloudFront serves the site over HTTPS from locations close to each visitor.
  • It costs almost nothing. A small virtual machine running Docker would cost several dollars a month and need ongoing maintenance.

Securing the AWS account first

Before creating anything, I locked the account down: MFA on the root user, a zero-spend budget that emails me the moment anything costs money, and a separate everyday admin login through IAM Identity Center so the root user stays unused.

Two surprises taught me to read before clicking. Enabling Identity Center created an AWS Organization, which moved the account from the free plan to the pay-as-you-go plan. That was harmless here since the free allowances still apply, but I did not expect it. And CloudFront refused to create anything until AWS support manually verified my new account, a standard anti-abuse check that took one support ticket to clear.

S3: a private bucket

The bucket that holds the site is completely private, with every public-access block switched on. Many tutorials tell you to enable S3's static website hosting instead, but that only works with a public bucket and serves plain HTTP. Keeping the bucket private and putting CloudFront in front is both safer and simpler.

CloudFront: the front door

CloudFront is the only thing allowed to read the bucket. That is done with Origin Access Control: CloudFront signs every request it makes to S3, and the bucket policy only accepts reads signed by this one distribution. Visiting the bucket directly returns 403.

  • HTTP requests are redirected to HTTPS.
  • A managed security headers policy adds HSTS, clickjacking protection and other browser safeguards.
  • Custom error responses turn S3's 403 for missing files into a proper 404 page.
  • The flat-rate free plan includes a web application firewall and caps the cost at zero.

A bug that looked fine in the browser

On the homepage

After the first deploy, the homepage opened perfectly in my browser. But checking it with curl showed the root URL was returning 404. CloudFront did not know that / should mean index.html, so the request fell through to the error page, and the frontend router drew the homepage on top of it.

Humans never noticed, but search engines and link previews read the status code and would have seen a missing page. Setting the default root object to index.html fixed it. Lesson: check what the server returns, not just what the browser shows.

$ curl -sI https://<your-domain>/
HTTP/1.1 200 OK

On every other page

When I added a blog, the bug came back for every page except the homepage. Reflex exports each page as a folder with an index.html inside, like blog/index.html. But S3 is a key-value store, not a web server: a request for /blog looks for a file literally named blog, finds nothing, and returns 403. The default root object only helps with / itself, not with folders.

Again the browser rendered the page correctly through the error fallback, and again curl showed a 404. The fix was a CloudFront Function, a few lines of JavaScript that run at the edge before the cache lookup and rewrite clean URLs to the file that actually exists. Real files such as stylesheets and scripts contain a dot, so they pass through untouched.

function handler(event) {
    var request = event.request;
    if (request.uri.endsWith('/')) {
        request.uri += 'index.html';
    } else if (!request.uri.includes('.')) {
        request.uri += '/index.html';
    }
    return request;
}

CI/CD with GitHub Actions

Every push to main builds and deploys the site automatically. The workflow has two jobs. The build job installs the exact locked dependencies with uv, runs the Reflex export and saves the output as an artifact. It also runs on pull requests, so a broken change is caught before it is merged. The deploy job only runs on main: it takes that artifact, uploads it to S3 and clears the CloudFront cache.

# build job
- run: uv sync --frozen
- run: uv run reflex export --frontend-only --no-zip

# deploy job
- run: aws s3 sync site s3://<bucket> --delete
- run: aws cloudfront create-invalidation --distribution-id <id> --paths "/*"

Deploying without storing any AWS keys

This was the part I learnt the most from. The old way to let CI deploy to AWS is to create an access key and paste it into GitHub secrets. That key works forever, for anyone who gets hold of it.

Instead the pipeline uses OIDC federation, and no AWS keys exist anywhere:

  • An identity provider in IAM tells AWS to trust tokens signed by GitHub.
  • An IAM role describes what a deploy is allowed to do. It has no password or key of its own.
  • The role's trust policy says who may use it: only jobs from my repository running in its production environment.
  • The role's permission policy says what it can do: upload and delete files in one bucket, and clear one distribution's cache. Nothing else.
  • On each run, GitHub gives the job a short-lived signed token. AWS STS checks it against the trust policy and hands back temporary credentials that expire within an hour.

Debugging the trust policy

The first deploy failed with "Not authorized to perform sts:AssumeRoleWithWebIdentity". Everything looked right. To see what AWS was actually comparing, I added a temporary step that decoded the token's claims, without ever printing the token itself.

The subject claim did not match the usual repo:owner/repo format. It included GitHub's numeric IDs for the account and the repository. That format is safer, because a deleted and recreated repository with the same name gets a new ID and cannot reuse the role. Matching the trust policy to the real claim fixed it.

repo:<owner>@<owner-id>/<repo>@<repo-id>:environment:production

Caching that does not go stale

The build produces JavaScript and CSS files with a content hash in their names. When the content changes, the name changes, so those files are cached for a year and marked immutable. Pages and other files keep the same names between deploys, so browsers revalidate them on every visit. After each upload the pipeline invalidates the CloudFront cache, and a new version is live within about a minute of pushing.

Putting it on a custom domain

The last step was moving from a long cloudfront.net address to my own .dev domain. It came down to two problems: browsers need to find the site, which is a DNS job, and they need to trust it, which is a certificate job.

  • Certificate: AWS Certificate Manager issues free TLS certificates. For CloudFront it has to be requested in the us-east-1 region, whatever region the bucket is in, because CloudFront only reads certificates from there. One certificate covers both the bare domain and www.
  • Proving ownership: before signing, ACM asks you to add a specific random CNAME record to your DNS, which only the real owner can do. Those records stay forever, because ACM checks them again to renew the certificate automatically every year.
  • Telling CloudFront: the domain names are added to the distribution along with the certificate, so it accepts requests for them and presents the certificate during the HTTPS handshake.
  • Pointing DNS at CloudFront: www is a normal CNAME to the CloudFront address. The bare domain cannot be a CNAME, because DNS rules forbid CNAMEs at the root of a domain. An ALIAS record solves this: the DNS provider looks up CloudFront's current IP addresses itself and answers with them. A fixed IP address would break, because CloudFront's addresses change.
@           ALIAS   <distribution>.cloudfront.net
www         CNAME   <distribution>.cloudfront.net
_<random>   CNAME   _<random>.acm-validations.aws   # certificate validation

What it costs

CloudFront is on the free plan, S3 stores a few megabytes, and IAM, STS, Identity Center and the certificate are free. The monthly bill is effectively zero, with a budget alert watching in case that ever changes. The only real cost is the domain's yearly renewal.

Could someone spam the site and run up a bill? CloudFront is on a flat-rate plan with no overage charges, AWS managed firewall rules block known bad IPs and common attacks, and a per-IP rate limit blocks anyone sending far more requests than a real visitor ever would. The only remaining cost, S3 requests for pages that do not exist, is a fraction of a cent per thousand, and a budget alert would flag anything unusual within a day.

What I learnt

  • Work out what actually needs to run before choosing infrastructure. A site with no backend does not need a server.
  • Private storage behind a CDN is safer than public buckets, and not harder.
  • Temporary credentials through OIDC beat long-lived keys, and least-privilege policies limit the damage if anything goes wrong.
  • When auth fails, look at the real claims instead of guessing.
  • Check HTTP status codes, not only what the browser renders.
  • S3 stores files by exact name. Anything a web server normally does for you, like serving index.html for a folder, has to be added at the CDN.
  • DNS has rules that matter in practice: a CNAME cannot sit at the root of a domain, which is what ALIAS records are for.
stack
PythonReflexuvGitHub ActionsAWS S3CloudFrontCloudFront FunctionsACMIAM OIDCDNS