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
, andScan
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:
- Cloud Resume Challenge
- AWS DynamoDB Best Practices
- IAM Policies Best Practices
- AWS API Gateway REST API Docs
- S3 Versioning + Lifecycle Management
๐ซฐ๐ป 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!
- ๐ผ LinkedIn
- ๐ GitHub
- โ๏ธ Dev.to Blog
- โ๏ธ Email Me
Top comments (0)