Introduction
I had been looking for a better way to share my background, skills, and SNS links with the people I meet at events and meetups. Paper business cards carry limited information, and pulling out a QR code every time felt a bit clunky.
So I built a digital business card where the URL of my portfolio site is written to an NFC tag β the other person just needs to tap their smartphone against it to open the page. In this article, I'll briefly walk through what I built, what the architecture looks like, and in particular how I set up the AWS infrastructure, CI/CD, and IaC.
Let's start exchanging business cards in a smarter way!
Note
The title says "zero operating cost," but it's actually almost zero. Please forgive me.
That said, in the roughly one month since I started running this site, the operating cost has genuinely been $0!Also, the architecture diagram and parts of the text in this article were created with the help of AI.
However, everything has been reviewed and refined by me, and the majority of the prose was written by a human (me).
What I Built
A picture is worth a thousand words, so let me start with the actual behavior.
Demo: From NFC tap to page rendered
Note: the GIF is heavily compressed to keep the file size down, so the image quality is a bit rough.
The site itself
A simple single-page layout with three sections:
- Hero (profile)
- Skills (skill list)
- Contact (SNS links)
For the Skills / Contact sections after scrolling, please take a look at the live site π
π Site URL: Digital Business Card Portfolio
The flow
The mechanism is very simple.
- Write the site's URL to an NFC tag
- When the other person taps their smartphone (iPhone / Android) on the tag, the browser opens the site automatically
Overall Architecture
Here's the big picture upfront.
Roughly speaking, there are three layers:
-
Frontend:
- Built with Next.js
- Static export via
output: "export"
-
Infrastructure:
- AWS S3 + CloudFront + OAC
- Access to S3 is restricted to CloudFront only
-
Operations:
- Terraform
- GitHub Actions + OIDC
- AWS Budgets for cost monitoring
AWS Architecture in Detail
Why I chose S3 + CloudFront + OAC
This site is fully static. No SSR, no DB required. Given that:
- S3 alone: Cannot terminate HTTPS directly, and operating a custom domain is inconvenient
- EC2 / ECS: Completely overkill. The always-on cost also hurts
- Amplify / Vercel: Easy, but I wanted to keep AWS-side control
From this comparison, I chose S3 + CloudFront + OAC. This is essentially the de facto standard for hosting a static site on AWS.
Caching strategy
CloudFront's cache TTL is clearly separated between HTML and static assets.
| Path | Browser TTL | CDN TTL | Reason |
|---|---|---|---|
HTML such as /, /index.html
|
300 sec | 86400 sec | Content gets updated on each deploy |
/_next/static/* |
1 year | 1 year (immutable) |
Filenames contain a hash, so permanent caching is safe |
Next.js embeds a content hash into the filenames of static assets. If the content changes, the URL changes, so it is safe to cache anything under /_next/static/ for a year without ever serving stale files. HTML, on the other hand, keeps the same URL while its content changes, so we use a shorter TTL.
Aiming for completely free operation
The monthly cost for this site is essentially $0 under normal operation. Here's a breakdown:
| Service | Estimated monthly cost | Note |
|---|---|---|
| S3 storage | < $0.01 | The whole site is a few MB |
| S3 requests | < $0.01 | Access goes through CloudFront, so S3 GETs are minimal |
| CloudFront data transfer | $0 ~ a few cents | The AWS Free Tier includes a permanent 1 TB/month allowance |
| Route 53 (if using a custom domain) | $0.50 | One hosted zone |
| AWS Budgets | $0 | Up to two budgets are free |
The key here is the CloudFront permanent free tier. Up to 1 TB/month of data transfer and 10 million requests/month are free, which is more than enough for a portfolio.
Preventing surprise bills with AWS Budgets
The scariest thing about personal operation is an unexpected bill out of nowhere. I have set an AWS Budget in budgets.tf with a $1 monthly threshold, sending email notifications via SNS at 80% / 100% actual / 100% forecast β three stages in total.
CI/CD Workflow
The overall job
In .github/workflows/deploy.yml, a push to the main branch triggers automatic deployment.
on:
push:
branches: [main]
permissions:
id-token: write # required for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: ap-northeast-1
- run: aws s3 sync ./out s3://$BUCKET --delete
- run: |
aws cloudfront create-invalidation \
--distribution-id $DIST_ID --paths "/*"
Keyless authentication with OIDC
One option for deploying to AWS from CI is "create an IAM user, issue an access key, and store it in GitHub Secrets," but long-lived credentials are the biggest source of leak risk.
So I went with keyless authentication via GitHub OIDC. The mechanism looks like this:
There are two key points:
- AssumeRoleWithWebIdentity: The OIDC token (a signed JWT) issued by GitHub Actions is exchanged with AWS STS for temporary credentials (usually valid for one hour). No long-lived access key is needed at all.
-
Conditions in the trust policy: The
subclaim is restricted to a specific repository + specific branch. Forks, other branches, and other repositories cannot AssumeRole.
Without permissions: id-token: write, the OIDC token itself isn't issued and the authentication step fails immediately. It's a small but easy-to-miss line, so make sure you include it.
For production use, it's a best practice to also add a StringEquals condition on aud (audience) β expected value: sts.amazonaws.com.
Deployment flow (build β S3 sync β CF invalidation)
The actual deployment is just three steps.
-
build:
npm run buildgenerates the static files inout/ -
S3 sync:
aws s3 sync ./out s3://$BUCKET --deleteuploads only the diff and deletes obsolete files -
CloudFront invalidation:
aws cloudfront create-invalidation --paths "/*"purges the CDN cache
Adding the --delete option prevents the bug where renamed or removed files keep lingering on S3 forever. CloudFront cache invalidation is free for up to 1000 paths per month, after which it's $0.005 per path, so bundling everything into a single /* is the most reasonable choice for personal projects.
Managing with IaC
Directory layout under infra/
I gather all the Terraform files into an infra/ directory and split them by responsibility.
infra/
βββ s3.tf # Bucket for the static site and OAC
βββ cloudfront.tf # Distribution and cache policies
βββ github_oidc.tf # OIDC provider and IAM role for GitHub Actions
βββ budgets.tf # Cost alerts
βββ variables.tf # Input variables
βββ outputs.tf # Output values (bucket name, distribution ID)
βββ ...
Splitting by resource type keeps the diff readable during PR review and avoids any single file ballooning. The region is Tokyo (ap-northeast-1). Note that the ACM certificate used by CloudFront must live in us-east-1 due to the service's requirements.
Why use Terraform for IaC
I picked Terraform this time for the following reasons:
- Infrastructure diffs are visible in PRs: Future-me will never remember what I clicked together in the console
-
High reproducibility: Migrating to a different account or rebuilding after a disaster is just a single
terraform apply -
You notice when manual operations corrupt the configuration:
terraform plansurfaces "unexpected diffs," so any sneaky console change (including ones from past-me) shows up immediately - I wanted to use Terraform!! (the real biggest reason)
State management policy
Terraform's tfstate stores resource IDs, ARNs, and parameters in plaintext. Leaving it on your local machine and committing it to git is a recipe for disaster.
For this project:
-
tfstatelives in an S3 remote backend (encrypted with SSE-S3) - A DynamoDB lock is used to prevent concurrent applies
- The state bucket is managed in a separate account with a separate lifecycle
I'm planning a separate, deeper article specifically on state operation design.
Summary
I built a digital business card to hand out via NFC using the following stack:
| Area | Technology / Service | Highlights |
|---|---|---|
| Frontend | Next.js 16 (Static Export) + React 19 + Tailwind CSS 4 + Framer Motion | Static export, no runtime needed |
| AWS infrastructure | S3 + CloudFront + OAC | Fully closed public access; only reachable via CloudFront |
| CI/CD | GitHub Actions + OIDC | Auto-deploy on main push, keyless authentication |
| IaC | Terraform | State managed safely via S3 + DynamoDB |
| Cost control | AWS Budgets | $1 threshold with email notifications |
| NFC | NDEF-format URI record | Just write the URL to the tag β no site-side implementation |
The key point is to keep the site's own implementation minimal and let "the experience of opening the page the instant you tap" be supported by AWS's CDN stack and GitHub Actions' automated deployment.
The whole thing is assembled from standard AWS building blocks.
If there's an architecture choice or implementation detail you're curious about, please let me know in the comments.




Top comments (0)