<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Charlie Winslow</title>
    <description>The latest articles on DEV Community by Charlie Winslow (@cwinslow22).</description>
    <link>https://dev.to/cwinslow22</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4201235%2Fd0c13163-e3c3-497b-878d-86c4dabb7dc0.jpg</url>
      <title>DEV Community: Charlie Winslow</title>
      <link>https://dev.to/cwinslow22</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cwinslow22"/>
    <language>en</language>
    <item>
      <title>Building My Cloud Resume Challenge on AWS (Almost for Free)</title>
      <dc:creator>Charlie Winslow</dc:creator>
      <pubDate>Thu, 22 Oct 2026 21:21:19 +0000</pubDate>
      <link>https://dev.to/cwinslow22/building-my-cloud-resume-challenge-on-aws-almost-for-free-6po</link>
      <guid>https://dev.to/cwinslow22/building-my-cloud-resume-challenge-on-aws-almost-for-free-6po</guid>
      <description>&lt;p&gt;I recently finished the &lt;a href="https://cloudresumechallenge.dev/" rel="noopener noreferrer"&gt;Cloud Resume Challenge&lt;/a&gt;: 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 &lt;a href="https://charliewinslow.com" rel="noopener noreferrer"&gt;charliewinslow.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0xvuaswtgd4tur3p6m74.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0xvuaswtgd4tur3p6m74.png" alt="Architecture diagram: static site path and visitor counter path" width="799" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping costs close to zero
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HTTP API instead of REST API.&lt;/strong&gt; It's cheaper and simpler, and it was all I needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-demand DynamoDB.&lt;/strong&gt; No provisioned capacity, so an idle table costs nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudFront's Free flat-rate plan.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cheaper registrar.&lt;/strong&gt; I compared domain prices and bought mine at Porkbun instead of Route 53, then delegated DNS to Route 53 so CloudFront aliases work cleanly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infrastructure as code
&lt;/h2&gt;

&lt;p&gt;The backend (DynamoDB table, Lambda function, API, and IAM role) is one SAM template. The Lambda uses a DynamoDB &lt;code&gt;update_item&lt;/code&gt; with &lt;code&gt;ADD&lt;/code&gt;, 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 &lt;code&gt;DeletionPolicy: Retain&lt;/code&gt; so the count survives if the stack is ever deleted.&lt;/p&gt;

&lt;p&gt;Unit tests run with pytest, with DynamoDB mocked by moto.&lt;/p&gt;

&lt;h2&gt;
  
  
  CI/CD without stored credentials
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;main&lt;/code&gt; of that specific repository.&lt;/p&gt;

&lt;p&gt;The backend pipeline runs the tests and then deploys with &lt;code&gt;sam build&lt;/code&gt; and &lt;code&gt;sam deploy&lt;/code&gt;. The frontend pipeline syncs files to S3 and invalidates the CloudFront cache so changes appear within a couple of minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke (and what I learned)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  My CORS change never deployed
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;.aws-sam/build&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  GitHub changed the OIDC subject claim
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  CloudFormation used a different role than I expected
&lt;/h3&gt;

&lt;p&gt;The next deploy failed, and the stack ended up in &lt;code&gt;UPDATE_ROLLBACK_FAILED&lt;/code&gt;. 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 &lt;code&gt;continue-update-rollback&lt;/code&gt;, and re-ran the pipeline. Lesson: know which identity is actually being denied. The error message names it.&lt;/p&gt;

&lt;h3&gt;
  
  
  A dry run caught a leak
&lt;/h3&gt;

&lt;p&gt;Before the first frontend deploy, I ran &lt;code&gt;aws s3 sync&lt;/code&gt; with &lt;code&gt;--dryrun&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Add a smoke test that calls the live API after each backend deploy.&lt;/li&gt;
&lt;li&gt;Add a billing alarm so a cost surprise gets caught early.&lt;/li&gt;
&lt;li&gt;Trim the GitHub deploy role further now that I understand which role does the work.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The code is on GitHub: &lt;a href="https://github.com/cwinslow22/cloud-resume" rel="noopener noreferrer"&gt;frontend&lt;/a&gt; and &lt;a href="https://github.com/cwinslow22/cloud-resume-backend" rel="noopener noreferrer"&gt;backend&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>cloud</category>
    </item>
  </channel>
</rss>
