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, andScanpermissions 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)