Visitors

Project

Cloud Resume

Serverless portfolio site with visitor counter — this site, in fact

S3CloudFrontLambdaAPI GatewayDynamoDBGitHub Actions
View on GitHub

Context

Forrest Brazeal's Cloud Resume Challenge — host a personal resume entirely on AWS using only serverless services, with end-to-end CI/CD and a real visitor counter that survives deploys. I used the project as my own AWS scratchpad: every service in the stack got touched, and every deployment edge case got hit.

The challenge

Static frontend on S3 + CloudFront with HTTPS via ACM. Serverless visitor counter (Lambda + DynamoDB + API Gateway) that can't lose count under concurrent load. Push-to-deploy via GitHub Actions, including a CloudFront invalidation so visitors see the new content immediately.

Approach

Built the frontend as a Next.js app with `output: 'export'` so the entire site is static HTML/JS/CSS — no Lambda@Edge, no SSR runtime. Origin is an S3 bucket; CloudFront sits in front with an ACM-issued cert for HTTPS and route-53 resolving resume.codenickk.com. Visitor counter is a Python Lambda that increments an atomic counter in DynamoDB using a single UpdateItem call with `ADD :one` — no read-modify-write, so concurrent traffic can't double-count or skip. CI: a push to master triggers `aws s3 sync --delete` then `aws cloudfront create-invalidation /*`.

  • Static export means every URL is a real HTML file in S3. No cold start, no compute charges, no version-skew between server and client.
  • DynamoDB UpdateItem with `ADD :one` is atomic — DynamoDB's API guarantees no read-modify-write race even under thousands of concurrent visitors.
  • ACM cert is issued via DNS validation — auto-renews indefinitely with no manual touch.
  • CloudFront invalidation is `/*` because the entire site is small. For larger sites you'd be more selective to stay within the 1000 free invalidations/month.
  • S3 bucket policy restricts access to the CloudFront origin access identity only — direct S3 URL access is blocked.

Architecture

The site is two independent paths: the static-content path (S3 → CloudFront → browser) and the API path (browser → API Gateway → Lambda → DynamoDB). Neither blocks the other.

Workflow diagram
Cloud Resume workflow diagram
  1. 01

    git push master

    GitHub Actions workflow `deploy.yml` triggers automatically on push to the master branch.

  2. 02

    npm ci + next build

    Action sets up Node 22, installs locked dependencies via `npm ci`, then runs `next build` which produces a static export in `web/out/`.

  3. 03

    aws s3 sync --delete

    Static output synced to the S3 bucket. The --delete flag removes any S3 objects no longer present in the build, so old artifacts don't accumulate.

  4. 04

    CloudFront invalidation

    An invalidation is issued for /* so CloudFront edge caches purge their copy of every object. Visitors see the new content within ~30 seconds.

  5. 05

    Visitor counter on page load

    On every page load the browser fetches /visits from API Gateway. The Lambda calls DynamoDB's UpdateItem with `ADD visits :one` — an atomic increment that's safe under any concurrency. The new total comes back and renders in the top-right toolbar.

Engineering decisions

Why DynamoDB ADD over GetItem then UpdateItem

ADD performs the increment server-side as an atomic operation — no read-modify-write race. Two concurrent visitors arriving in the same millisecond both get correctly counted. A naive GetItem-then-PutItem implementation would lose updates under any real concurrency.

Why static export, not Next.js server

A portfolio site has no per-user logic, no auth, no dynamic data. Every page is the same for every visitor. Static export means every URL is a real S3 object served from a CloudFront edge — no cold starts, no compute bill, sub-second TTFB worldwide.

Why CloudFront invalidation /* (not selective)

The site is a few hundred KB. A blanket /* invalidation is simple and well within the 1000 free invalidations/month. For larger sites you'd invalidate only changed paths to stay free-tier; for this size, simplicity wins.

Why API Gateway in front of Lambda, not Lambda Function URL

Function URLs work too, but API Gateway gives easy CORS configuration, request validation, throttling, and a custom domain — features that make the visitor-counter endpoint feel like a proper API, not a one-off shortcut.

Code highlights

Visitor counter Lambda (atomic DynamoDB increment)python
import json, boto3

ddb = boto3.resource("dynamodb")
table = ddb.Table("visitor-count")

def lambda_handler(_event, _ctx):
    res = table.update_item(
        Key={"id": "site"},
        UpdateExpression="ADD visits :one",
        ExpressionAttributeValues={":one": 1},
        ReturnValues="UPDATED_NEW",
    )
    visits = int(res["Attributes"]["visits"])
    return {
        "statusCode": 200,
        "headers": {
            "Access-Control-Allow-Origin": "*",
            "Content-Type": "application/json",
        },
        "body": json.dumps({"visits": visits}),
    }
GitHub Actions deploy workflow (excerpt)yaml
- name: Setup Node.js
  uses: actions/setup-node@v4
  with:
    node-version: '22'
    cache: 'npm'
    cache-dependency-path: web/package-lock.json

- name: Build
  working-directory: web
  run: npm ci && npm run build

- name: Sync to S3
  run: aws s3 sync web/out/ s3://nikhilsresumebucket --delete

- name: Invalidate CloudFront
  run: |
    aws cloudfront create-invalidation \
      --distribution-id E1PNSO5QUYT69 \
      --paths "/*"

Impact

Live at resume.codenickk.com with sub-second loads worldwide via CloudFront's edge. Visitor counter persists across deploys. Every commit to master auto-deploys without me touching anything. The site you're reading right now is the project.

© 2026 Nikhil Singh