# Putting my AWS CCP into Practice

I completed the Cloud Resume Challenge!

What is the Cloud Resume Challenge? It's a challenge to build and deploy your resume as a website on AWS, and it makes you touch a LOT of things along the way: static hosting on S3, HTTPS through CloudFront, DNS with Route 53, a visitor counter built on API Gateway, Lambda, and DynamoDB, unit tests for your Python, infrastructure as code, and a CI/CD pipeline for both the frontend and the backend.

I used Claude Code to help me build this, which some people would say is outside the spirit of the challenge. Here's the pros and cons I weighed:

PROs: I'd be able to move much faster, and experience with AI assisted coding tools is seen as a plus in the industry more than a negative.

CONs: In my personal experience, building with one of these tools can leave me with a shallower understanding of my own project. Honestly, at this point I don't think I could write Terraform from scratch. I can read it and debug it, but having the syntax memorized just isn't something I personally gain while working with an AI assistant.

I decided the pros outweighed the cons, or at the very least that if I want to keep up in the modern world I'd better start practicing how to mitigate the cons myself so I can actually benefit from the speedup.

That turned out to be the real work.

**The part where I had to stop**

I'd notice I had quietly stopped learning and started just executing commands. I was making progress, but if you'd asked me why my CI pipeline could get AWS credentials without a stored password, I couldn't have told you. I still didn't really grasp the difference between an IAM user and an IAM role, what it meant to "assume" a role, how SSO actually worked, or why I had to log in through a specific web URL even when I was working from the CLI.

So I stopped building new infrastructure and went backwards. I asked for the mechanics behind IAM roles, OIDC trust, JWT signing, and Terraform's state file, one at a time, and I made it my goal to explain each one in my own words before moving forward. This cost me an afternoon. It's also the only reason I can write the rest of this post with any confidence instead of just describing commands I copied.

I also spent time diagramming the whole project at a high level, which helped a lot, at least from a bird's eye view:

![](https://cdn.hashnode.com/uploads/covers/6a6623942c57b3cbbb4abfdd/25348e99-d71e-459b-b33d-646170eb3eb6.png align="center")

**A few things I actually learned from slowing down**

Assuming a role is a specific API call. When GitHub Actions assumes my deploy role, it's making a real request to AWS's token service (STS), handing over a signed identity token, and getting back short-lived credentials in return. Nothing is stored anywhere and the credentials expire.

I didn't understand what a "signed identity token" actually meant, so I spent a while grappling with that. Here's where I landed: GitHub has a private key and a public key. The private key is used to sign a hash of the message being sent. Anyone with the public key can check whether that signature genuinely corresponds to the message (after hashing it themselves with the known hash function). If it matches, the message is valid. The whole thing rests on the idea that some problems are extremely hard to solve but very fast to verify. That's how GitHub's private key stays private while anyone in the world can still confirm their messages are real.

I tried to deeply understand the specific math they use and that was over my head, so I'm leaving it to the cybersecurity folks for now. I look forward to my next battle with it though haha.

I also learned this same "easy to verify, hard to forge" idea is what RSA and HTTPS are built on, so I picked up a better understanding of some other security concepts as shrapnel!

Terraform's state file took me a second too. It's a record of what Terraform believes it created and what it currently looks like. So when I built something by hand in the AWS console, Terraform had no idea it existed. That's what `terraform import`is for: you point it at a real resource and it starts tracking it instead of trying to make a duplicate.

**The bugs**

I kept a running log of every real issue I hit while building this. Here are the ones worth telling:

**CORS failed silently because I never hit "Add."** I typed `*` into API Gateway's CORS configuration and just... never clicked the button that actually saves it. I spent so long debugging this haha, which was really on me, but it gave me a healthy fear of clicking around in the console GUI and made me appreciate IaC a lot more.

**A brand new GitHub platform change broke my OIDC trust policy.** GitHub recently started issuing immutable identity claims for new repos, tied to numeric org and repo IDs instead of just names, specifically so a deleted-and-recreated repo can't inherit an old trust relationship. My repo was new enough to get the new format. My Terraform only matched the old one. The fix was two lines, but finding it took forever, and I only got there by reading GitHub's own changelog instead of leaning on other people's writeups of this challenge, which were all just old enough to be wrong.

**A full destroy and rebuild taught me Terraform manages shape, not data.** I wanted to prove the whole stack could be torn down and rebuilt from nothing, so I ran `terraform destroy`. It mostly worked, except CloudFront refused to delete at all. AWS's newer flat-rate pricing plans block distribution deletion until you cancel the plan and wait out the billing cycle (I had picked that plan because it was a flat rate of free, which sounded pretty great at the time 😅). On rebuild, the visitor counter broke twice: once because the new API Gateway got a new ID that my frontend JavaScript still had hardcoded, and once because my new DynamoDB table came back completely empty, since the original seed item had only ever been created by hand in the console. Terraform recreates infrastructure. It has no idea about the data that used to live inside it.

Instead of reseeding the table by hand again, I changed the Lambda's DynamoDB update expression to use `if_not_exists`, so the very first request against an empty table creates and increments the counter in one atomic call. No manual setup step, ever again. I'm really glad I did the destroy and rebuild, because I ended up with something actually self-healing instead of just believing it was in theory.

**Scoping the backend's deploy permissions took longer than building the backend.** Once I automated `terraform apply` in CI, I had to figure out exactly which AWS permissions that role needed, one AccessDenied error at a time. Terraform refreshes its entire state on every run, which means the role needs read access across nearly every service in the stack, not just the one thing it's changing. After about a dozen rounds of "add this exact permission, try again," I switched to attaching AWS's own managed ReadOnlyAccess policy for the read side and kept a narrow custom policy for the actual write permissions. That's the real answer: don't hand-roll read permissions for a multi-service Terraform stack.

The safety net worked exactly as intended along the way, too. Every one of those permission gaps made Terraform fail loudly with AccessDenied instead of silently creating duplicate resources. A broader role would not have failed that safely.

**One thing about Terraform the official challenge never has to mention**

The official Cloud Resume Challenge is written around AWS SAM, which uses CloudFormation under the hood. CloudFormation stores your stack's state inside AWS itself, so any machine that deploys a SAM app is always asking AWS the same question and getting the same answer.

Terraform doesn't work that way. Its state file lives wherever you put it, and if that's your laptop, your CI pipeline has no idea any of your infrastructure exists. I found this out the hard way when my first automated deploy tried to create all 22 resources in my stack from scratch, because GitHub Actions had never seen my state file. The fix was an S3 bucket to hold shared state so my laptop and CI are always looking at the same reality. It's not really a Terraform flaw, it's a genuine architectural tradeoff that SAM users never hit, so the official instructions never have to bring it up.

**Where this leaves things**

Everything in this project (frontend, backend, DNS, and the pipelines themselves) is defined in Terraform with zero drift from what's actually deployed. I proved that with a real destroy and rebuild, including the parts that didn't go perfectly. Both the frontend and backend deploy automatically on push now, gated by tests on the backend side.

I kept the project in a single GitHub repo instead of the two the official challenge suggests. The main argument for splitting them is narrower, isolated permissions per pipeline, and I got that same isolation inside one repo using path-based triggers and two separately scoped IAM roles. For a solo project that felt like the right tradeoff, and I'd rather name that decision directly than pretend I followed the spec exactly.

If you're doing this challenge yourself, my honest advice is the thing that slowed me down and then sped me back up: when you notice you've stopped understanding what you're running, stop running it. Go back one concept at a time until you could actually explain it to someone else. It costs you an afternoon and it's worth more than the two weeks around it.

Different approaches work for different people, so do what works best for you! If you have any questions about the challenge or want to talk through any of this, feel free to send me a message on LinkedIn!

https://www.linkedin.com/in/jettbtirrell
