Cloud Resume Challenge – Chunk 2 – Building the API



This content originally appeared on DEV Community and was authored by Trinity Klein

After getting my front-end live on S3 + CloudFront in Chunk 1, it was time to give it some brains. 🧠

The goal for this stage:
👉 Add a visitor counter (hit counter → visitor counter) to my portfolio website.

This wasn’t just about displaying a number, it was about learning how to stitch together AWS Lambda, API Gateway, DynamoDB, and IAM into a working serverless backend.

🗄 Designing the Visitor Counter

The stack I chose:

  • DynamoDB → store visitor data (IPs + visit counts).
  • Lambda → serverless compute that updates/query the table.
  • API Gateway → REST API to expose the Lambda function securely.
  • IAM Roles → restrict who/what can read/write from DynamoDB.

Here’s the flow:

Browser → API Gateway → Lambda → DynamoDB

On page load, the API Gateway calls the Lambda function, which fetches & updates the DynamoDB table. The number is then displayed in my site’s footer.

If the data can’t be fetched, the site gracefully falls back to showing “Loading…”.

🔁 From Hit Counter → Visitor Counter

Originally, this was just a simple hit counter, every page refresh added +1. But I refactored it into a proper visitor counter with:

  • Total visits (every page load).
  • Unique visitors (per IP, per 24-hour window).

This required:

  • A new DynamoDB table to store hashed IP addresses.
  • A smarter Lambda function (see below).
  • Test data (dummy items) to validate in production.

📝 The Lambda Function (v2)

Here’s the core Lambda function I deployed (Python 3.9):

# Version 2 of lambda function
# Stores IP addresses in a one-way hash
# Only counts unique visits once per 24 hours

import json
import boto3
import os
from datetime import datetime, timedelta
import hashlib

dynamodb = boto3.client('dynamodb')
TABLE_NAME = os.environ.get('TABLE_NAME', 'VisitorCounter')
UNIQUE_VISITOR_WINDOW_HOURS = 24  # uniqueness window

def handler(event, context):
    print("Incoming event:", json.dumps(event, indent=2))

    # Extract and hash IP
    ip_address = get_ip_address(event)
    if ip_address == "0.0.0.0":
        return error_response("Unable to determine IP")

    hashed_ip = hash_ip(ip_address)
    now = datetime.utcnow()
    now_str = now.isoformat()

    try:
        response = dynamodb.get_item(
            TableName=TABLE_NAME,
            Key={'ip_address': {'S': hashed_ip}}
        )

        is_new_visitor = False

        if 'Item' in response:
            last_visit = response['Item'].get('last_visit', {}).get('S')
            last_visit_time = datetime.fromisoformat(last_visit)

            if now - last_visit_time >= timedelta(hours=UNIQUE_VISITOR_WINDOW_HOURS):
                is_new_visitor = True
                dynamodb.update_item(
                    TableName=TABLE_NAME,
                    Key={'ip_address': {'S': hashed_ip}},
                    UpdateExpression="SET visit_count = visit_count + :inc, last_visit = :now",
                    ExpressionAttributeValues={
                        ":inc": {"N": "1"},
                        ":now": {"S": now_str}
                    }
                )
        else:
            is_new_visitor = True
            dynamodb.put_item(
                TableName=TABLE_NAME,
                Item={
                    "ip_address": {"S": hashed_ip},
                    "visit_count": {"N": "1"},
                    "first_visit": {"S": now_str},
                    "last_visit": {"S": now_str}
                }
            )

        scan = dynamodb.scan(
            TableName=TABLE_NAME,
            AttributesToGet=["visit_count"]
        )
        total_visits = sum(int(item["visit_count"]["N"]) for item in scan["Items"])
        unique_visitors = len(scan["Items"])

        return {
            "statusCode": 200,
            "headers": {
                "Access-Control-Allow-Origin": "*",
                "Content-Type": "application/json"
            },
            "body": json.dumps({
                "unique_visitors": unique_visitors,
                "total_visits": total_visits,
                "is_new_visitor": is_new_visitor
            })
        }

    except Exception as e:
        print("Error:", str(e))
        return error_response("Internal server error")

def get_ip_address(event):
    headers = event.get("headers", {})
    ip_sources = [
        event.get("requestContext", {}).get("http", {}).get("sourceIp"),
        event.get("requestContext", {}).get("identity", {}).get("sourceIp"),
        headers.get("x-forwarded-for", "").split(",")[0].strip(),
        headers.get("X-Forwarded-For", "").split(",")[0].strip(),
        headers.get("x-real-ip"),
        headers.get("X-Real-IP"),
        headers.get("cf-connecting-ip"),
        headers.get("CF-Connecting-IP"),
    ]
    for ip in ip_sources:
        if ip:
            return ip
    return "0.0.0.0"

def hash_ip(ip):
    return hashlib.sha256(ip.encode()).hexdigest()

def error_response(message):
    return {
        "statusCode": 500,
        "body": json.dumps({"error": message})
    }

Key features:

  • 🔒 Privacy-first → IP addresses are SHA-256 hashed.
  • 🕒 Uniqueness window → Only 1 count per IP in 24 hours.
  • 📊 Metrics returned → total visits, unique visitors, is_new_visitor.

🛡 IAM Role Setup

To keep things secure:

  • The Lambda function only has dynamodb:GetItem, PutItem, UpdateItem, and Scan permissions for the specific table.
  • API Gateway was given permission to invoke the Lambda.
  • No overly broad permissions, least privilege only.
# IAM Policy Example
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["dynamodb:UpdateItem", "dynamodb:GetItem"],
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/VisitorCounter"
    }
  ]
}

📦 S3 Bucket Improvements

While working on the API, I also hardened my S3 setup:

  • ✅ Versioning enabled → recover files in case of accidental overwrite.
  • ✅ Lifecycle policies → move old versions to cheaper storage.

This gave me resilience + cost control.

🔄 REST API vs Real-Time

I briefly considered building a real-time visitor counter using:

  • API Gateway (WebSockets)
  • DynamoDB Streams

But here’s the truth:

  • ⚠ It would be more expensive at scale.
  • ⚠ It would be much more complex to set up and optimize.
  • ✅ And… the user experience would be basically the same.

So I stuck with a REST API, simple, reliable, and cost-effective.

🚀 Lessons Learned in Chunk 2

By the end of this chunk, I achieved:
✅ Built an API Gateway + Lambda + DynamoDB visitor counter
✅ Upgraded from hit counter → unique visitor counter
✅ Secured roles with IAM least privilege
✅ Added resilience with bucket versioning + lifecycle policies
✅ Made intentional design decisions (REST > WebSocket for this use case)

This was the first time my project felt full-stack serverless, front-end + backend working together. 💡

🌟 What’s Next?

Next up: Chunk 3, adding a CI/CD pipeline so that deployments are automated, tested, and production-ready.

That’s when the project will really level up. ⚡

Drop a comment with how you approached your visitor counter, did you try REST, WebSockets, or something else? I would love to know how you approached it.

📚 Helpful Resources

Here’s what helped me in Chunk 2:

🫰🏻 Let’s Connect

If you’re following this challenge, or just passing by, I’d love to connect!

I’m always happy to help if you need guidance, want to swap ideas, or just chat about tech. 🚀

I’m also open to new opportunities, so if you have any inquiries or collaborations in mind, let me know!


This content originally appeared on DEV Community and was authored by Trinity Klein