I recently finished the Cloud Resume Challenge: host a resume as a static site on a cloud provider, add a serverless visitor counter, define everything as code, and deploy it all through CI/CD. You can see the result at charliewinslow.com.
One goal drove most of my decisions: keep the bill as close to zero as possible. This post covers the architecture, how I kept costs down, and the things that broke along the way, which taught me more than the parts that worked on the first try.
The architecture
A visitor's browser resolves my domain through Route 53, which points at a CloudFront distribution. CloudFront serves the site over HTTPS using a free ACM certificate, and it pulls files from a private S3 bucket using Origin Access Control, so the bucket itself is never public.
When the page loads, a small script calls an API Gateway HTTP API. That triggers a Python Lambda function, which increments a counter in DynamoDB and returns the new count. The backend is defined in AWS SAM and lives in its own repository, separate from the frontend.
Keeping costs close to zero
- HTTP API instead of REST API. It's cheaper and simpler, and it was all I needed.
- On-demand DynamoDB. No provisioned capacity, so an idle table costs nothing.
- CloudFront's Free flat-rate plan. AWS recently introduced flat-rate plans. The Free tier covers far more traffic than a resume will ever see, so there's no usage-based billing to think about.
- A cheaper registrar. I compared domain prices and bought mine at Porkbun instead of Route 53, then delegated DNS to Route 53 so CloudFront aliases work cleanly.
The one recurring AWS charge is the Route 53 hosted zone, about fifty cents a month, plus the yearly domain renewal. Everything else runs in free-tier or no-cost territory at this traffic level.
Infrastructure as code
The backend (DynamoDB table, Lambda function, API, and IAM role) is one SAM template. The Lambda uses a DynamoDB update_item with ADD, which increments atomically, so two simultaneous visitors can't overwrite each other. Its IAM role allows exactly one action on exactly one table. I also marked the table DeletionPolicy: Retain so the count survives if the stack is ever deleted.
Unit tests run with pytest, with DynamoDB mocked by moto.
CI/CD without stored credentials
Both repos deploy with GitHub Actions. Instead of pasting AWS access keys into repository secrets, I used GitHub's OIDC provider. Each workflow requests a short-lived token that AWS exchanges for temporary credentials. Each repo has its own IAM role, and each role can only be assumed by runs on main of that specific repository.
The backend pipeline runs the tests and then deploys with sam build and sam deploy. The frontend pipeline syncs files to S3 and invalidates the CloudFront cache so changes appear within a couple of minutes.
What broke (and what I learned)
My CORS change never deployed
I tightened the API's CORS settings to allow only my own domains, deployed, and the API still returned a wildcard header. Checking the live Lambda's environment variables showed the old variable name. The cause was simple: I had edited the generated copy of the template inside .aws-sam/build instead of the real one at the project root, so every deploy rebuilt the old code. Lesson: when a deploy "succeeds" but behavior doesn't change, check what's actually running before assuming the new code is live.
That debugging also showed me that API Gateway's built-in CORS handling takes priority over headers set in the Lambda. For an origin that isn't allowed, it simply omits the header.
GitHub changed the OIDC subject claim
The first pipeline run failed with a "not authorized to assume role" error, even though my trust policy looked correct. I added a temporary step to the workflow that printed the token's claims. The subject now includes numeric IDs for my account and repository, not just their names, so my policy's expected string never matched. Updating the trust policy to the new format fixed it. Lesson: when an authentication error makes no sense, look at what's actually being sent, not what you assume is being sent.
CloudFormation used a different role than I expected
The next deploy failed, and the stack ended up in UPDATE_ROLLBACK_FAILED. The error named a CloudFormation service role attached to the stack. That role, not my GitHub role, does the actual resource changes, and it couldn't read the code package from the SAM bucket. I added the missing S3 permission, resumed the rollback with continue-update-rollback, and re-ran the pipeline. Lesson: know which identity is actually being denied. The error message names it.
A dry run caught a leak
Before the first frontend deploy, I ran aws s3 sync with --dryrun. It showed it was about to publish my IAM policy files, which I'd accidentally created inside the repo, along with editor config. They contained no secrets, but they would have exposed my account ID and permission layout. I moved them out and added exclusions. Lesson: preview anything that syncs or deletes.
What I'd do next
- Add a smoke test that calls the live API after each backend deploy.
- Add a billing alarm so a cost surprise gets caught early.
- Trim the GitHub deploy role further now that I understand which role does the work.
Final thoughts
The challenge is a great way to touch a lot of AWS at once: networking, security, serverless, infrastructure as code, and pipelines. The most useful parts weren't the working architecture but the debugging, because each failure forced me to understand how the pieces really fit together.

Top comments (0)