Three days after writing about passing the AWS Certified Cloud Practitioner exam, I had a system running in AWS to write about. I'd designed it myself and worked through the deployment.
The Cloud Resume Challenge provided the outline. My version became joshcodes.me: a Next.js portfolio with a serverless visitor counter and an automated route from a Git push to production.
I knew the services from studying and supporting existing systems. This time, the decisions about how they fitted together were mine.
The site and its visitor counter
The frontend is built with Next.js and exported as static files. Those files live in a private S3 bucket, with CloudFront acting as the public entry point. Route 53 handles the domain, while CloudFront serves the site over HTTPS and keeps the bucket away from direct public access.

That covers the static site, but the challenge also calls for something dynamic: a visitor counter. When the page loads, the frontend sends a request through API Gateway. A Lambda function handles the request and updates the count stored in DynamoDB before returning the result.
The finished request path is:
Browser -> API Gateway -> Lambda -> DynamoDB
The counter is a small feature that touches several services. Most of my work went into IAM permissions and request handling, including getting a response the browser could use.
Removing permanent AWS keys from the pipeline
Deploying the site manually would have met the hosting requirement, but it would also have left every future update dependent on me remembering a sequence of commands. I wanted the repository to own that process.
The GitHub Actions workflow runs when changes under frontend/ reach the main branch. It checks out the repository, installs the dependencies, builds the static Next.js export, synchronises the generated files to S3 and invalidates the CloudFront cache.
on:
push:
branches:
- main
paths:
- "frontend/**"
Restricting the workflow by path means a documentation-only or unrelated repository change does not redeploy the site.
The more important decision was how GitHub authenticated with AWS. I initially needed to prove that the workflow could assume the correct identity, so I reduced the problem to one command:
aws sts get-caller-identity
Once that worked, the deployment workflow could use GitHub's OpenID Connect provider to assume an IAM role. No AWS access key or secret access key needed to be stored in GitHub.
permissions:
id-token: write
contents: read
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ap-southeast-2
role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
role-duration-seconds: 900
GitHub receives temporary credentials for the job, uses them to perform the deployment and loses them when the session expires. That is both safer and easier to maintain than rotating a long-lived access-key pair.
Push, build, publish
With authentication in place, the last two stages are intentionally uneventful:
aws s3 sync frontend/out s3://joshcod.es
aws cloudfront create-invalidation \
--distribution-id "${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }}" \
--paths "/*"
The S3 sync publishes the new static build. The invalidation prevents CloudFront from continuing to serve an older cached version after the deployment completes.
The resulting delivery path is now:
Push to main
-> GitHub Actions builds the Next.js site
-> GitHub assumes a short-lived AWS role
-> Static output is synchronised to S3
-> CloudFront cache is invalidated
-> The updated site is live
It took several workflow revisions to get there. Each revision made me check another part of the deployment: GitHub's identity and the IAM trust policy, then the private S3 origin and CloudFront's cache.
The complete project is available in the cloud-portfolio repository.
It's… beautiful
The first successful end-to-end run did not need a lengthy announcement. The build completed, the files reached S3, CloudFront received its invalidation and the production site changed without a manual deployment.
At that moment, a green GitHub Actions workflow was close enough to a sunset over the ocean.
