Back to Home

Serverless Visitor Counter

Create a production-grade serverless visitor counter using AWS Lambda, API Gateway, and DynamoDB, proxied through CloudFront for custom domain masking.

1. DynamoDB Table Creation

Create a pay-per-request NoSQL table in your region and seed the initial view counter record.

aws dynamodb create-table \
  --table-name visitor-counter \
  --attribute-definitions AttributeName=id,AttributeType=S \
  --key-schema AttributeName=id,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST \
  --region us-west-2

Seed the initial counter record:

aws dynamodb put-item \
  --table-name visitor-counter \
  --item '{"id": {"S": "site"}, "count": {"N": "0"}}' \
  --region us-west-2

2. IAM Execution Role & Access Policies

Create a trust policy and IAM execution role allowing Lambda to output CloudWatch logs and execute atomic updates on DynamoDB.

cat <<EOF > lambda-trust-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "lambda.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF

aws iam create-role \
  --role-name visitor-counter-lambda-role \
  --assume-role-policy-document file://lambda-trust-policy.json

aws iam attach-role-policy \
  --role-name visitor-counter-lambda-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

cat <<EOF > lambda-dynamo-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "dynamodb:UpdateItem",
        "dynamodb:GetItem"
      ],
      "Resource": "arn:aws:dynamodb:*:*:table/visitor-counter"
    }
  ]
}
EOF

aws iam put-role-policy \
  --role-name visitor-counter-lambda-role \
  --policy-name DynamoDBVisitorCounterAccess \
  --policy-document file://lambda-dynamo-policy.json

3. Lambda Function Code

This Python script handles atomic updates on DynamoDB and enforces global CORS headers on all HTTP responses.

cat << 'EOF' > lambda_function.py
import json
import boto3

def lambda_handler(event, context):
    cors_headers = {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Headers': '*',
        'Access-Control-Allow-Methods': 'GET,POST,OPTIONS'
    }
    
    try:
        dynamodb = boto3.resource('dynamodb', region_name='us-west-2')
        table = dynamodb.Table('visitor-counter')
        
        response = table.update_item(
            Key={'id': 'site'},
            UpdateExpression='ADD #c :val',
            ExpressionAttributeNames={'#c': 'count'},
            ExpressionAttributeValues={':val': 1},
            ReturnValues='UPDATED_NEW'
        )
        
        views = int(response['Attributes']['count'])
        
        return {
            'statusCode': 200,
            'headers': cors_headers,
            'body': json.dumps({'views': views})
        }
        
    except Exception as e:
        return {
            'statusCode': 200,
            'headers': cors_headers,
            'body': json.dumps({'views': 'Error', 'details': str(e)})
        }
EOF

zip function.zip lambda_function.py

aws lambda update-function-code \
  --function-name increment-visitor-counter \
  --zip-file fileb://function.zip \
  --region us-west-2

4. HTTP API Gateway Setup & Routing

Create an HTTP API Gateway, assign global CORS parameters, configure the $default catch-all route, and attach execution permissions.

ACCOUNT_ID=$(aws sts get-caller-identity --query "Account" --output text)

# 1. Create HTTP API
API_ID=$(aws apigatewayv2 create-api \
    --name visitor-counter-api \
    --protocol-type HTTP \
    --target arn:aws:lambda:us-west-2:${ACCOUNT_ID}:function:increment-visitor-counter \
    --region us-west-2 \
    --query "ApiId" --output text)

# 2. Configure Global CORS
aws apigatewayv2 update-api \
    --api-id ${API_ID} \
    --cors-configuration "AllowOrigins=*,AllowMethods=GET,POST,OPTIONS,AllowHeaders=*" \
    --region us-west-2

# 3. Add $default Route to prevent 404 path drops
INTEGRATION_ID=$(aws apigatewayv2 get-integrations --api-id ${API_ID} --region us-west-2 --query "Items[0].IntegrationId" --output text)

aws apigatewayv2 create-route \
    --api-id ${API_ID} \
    --route-key '$default' \
    --target "integrations/${INTEGRATION_ID}" \
    --region us-west-2

# 4. Grant Invoke Permission
aws lambda add-permission \
    --function-name increment-visitor-counter \
    --statement-id apigateway-invoke-perm \
    --action lambda:InvokeFunction \
    --principal apigateway.amazonaws.com \
    --source-arn "arn:aws:execute-api:us-west-2:${ACCOUNT_ID}:${API_ID}/*/*" \
    --region us-west-2

5. Frontend Integration & URL Masking

Proxy raw API requests through CloudFront to hide raw AWS endpoints under a clean path (/api/), then consume the data on the client side.

CloudFront Behavior Configuration:

  • Origin Domain: Your API Gateway domain (e.g., oasi55hqkb.execute-api.us-west-2.amazonaws.com)
  • Path Pattern: /api/*
  • Cache Policy: Set to CachingDisabled to keep counts real-time.

HTML Element:

<p>Total Visitors: <span id="visitor-count">Loading...</span></p>

JavaScript (script.js):

const apiEndpoint = "/api/";

async function updateVisitorCount() {
  try {
    const response = await fetch(apiEndpoint);
    const data = await response.json();
    document.getElementById("visitor-count").innerText = data.views;
  } catch (error) {
    console.error("Error fetching visitor count:", error);
    document.getElementById("visitor-count").innerText = "N/A";
  }
}

updateVisitorCount();

6. Deployment & CloudFront Cache Invalidation

Force CloudFront to fetch fresh site assets immediately after deploying updates to S3.

aws cloudfront create-invalidation \
  --distribution-id d1nrzvrxt6zac1 \
  --paths "/*"

7. Pro-Tips & Key Architectural Insights

  • CORS & 404 Disconnect: If Chrome reports a CORS failure, check your API routes first. Unmapped paths return 404s without CORS headers, which browsers misinterpret as CORS violations.
  • Atomic Increments: Using DynamoDB's ADD #c :val syntax prevents race conditions when multiple site visits happen simultaneously.
  • Region Explicit Messaging: When working across multiple regions, explicitly define boto3.resource('dynamodb', region_name='...') in Lambda code to avoid silent cross-region lookup failures.