# Build a serverless hit counter on AWS using Terraform

In this easy-to-follow guide, you will learn how to create a hit counter for your website. You will need to be familiar with AWS serverless services and have a basic understanding of Terraform to be able to easily follow this guide. At the end of this guide, you will build a hit counter API that you can integrate into your web pages. See a working demo here. [https://demos.nquayson.com/hitcounter/index.html](https://demos.nquayson.com/hitcounter/index.html)

Why build a hit counter from scratch on AWS when other services exist for the purpose?

Consider this as a fun little hands-on project that hones your AWS, IaC and Python programming skills!

# Overview

Here is a quick overview of all the services that will be deployed in this project:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1695786668692/15561aa1-895f-43b0-9a5c-7cefad86979f.png align="center")

* S3 bucket for hosting the static assets of the web application
    
* ACM certificate for enabling secure communication over HTTPS
    
* Cloudfront for serving the static assets across edge locations for our global user base
    
* DynamoDB will be our NoSQL database choice
    
* Lambda function for running our backend logic and eventually getting and setting the values in the database.
    
* Lambda function URL will be used rather than an API Gateway
    

## Prerequisites

* An AWS account and some AWS experience
    
* Some Infrastructure as Code (IaC) knowledge
    
* An [AWS CLI profile](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-quickstart.html) configured for your development environment to access your AWS account
    

## Fork the repository

Before we start, [make your fork](https://github.com/nquayson/aws-hitcounter/fork) of the repository containing the full source code. This will be your own copy giving you the flexibility to experiment and explore.

## Lambda function logic

The handler function will be the entry point when lambda is invoked. The handler calls the update\_hit() function which updates the 'hit\_count' attribute of a specific item (key = "1") in the DynamoDB table. The `UpdateExpression` parameter defines the update operation to perform. Here, it uses the 'ADD' action to increment the value of the 'hit\_count' attribute by 1. The updated value of 'hit\_count' is returned and we expect that as a string. The string zfill() method is used to pad a copy of this string with zeroes on the left. Resulting in the transformation "234" -&gt; "00234"

%[https://gist.github.com/nquayson/e35eceb97db7432915cc97a2f89f3dd9] 

# Terraform provider

Time to write some Terraform for provisioning our resources. First, we declare the provider block in `provider.tf` file. The default\_tags mean that all resources created with this provider will be tagged with the provided map.

```apache
provider "aws" {
  profile = "YOUR_CLI_PROFILE"
  region  = "us-east-1"
  default_tags {
    tags = {Name = var.name}
  }
}
```

## Variables

We define some variables we need in `variables.tf`. We also declare the full\_name local variable, which is a concatenation of `name` and `env` variables. This variable will be mainly used as the (aws-end) name of major resources. This way we can easily replicate the project for a different environment, such as another region within the same account, without worrying about name collisions.

```apache
variable "name" {
  description = "Name of application"
  default = "demo"
} 
variable "env" {
  description = "Environment name"
  default = "env"
}
locals {
  full_name = "${var.env}-${var.name}"
}
```

## Lambda Function

The rest of the code, except output, is defined in `main.tf`. Here we provision the lambda function that runs on Python3.8, using a deployment package in a zip archive. We also define an IAM role that the lambda function can assume. The role provides AWSLambdaBasicExecutionRole and an inline policy that allows the lambda function read and write access to the DynamoDB database.

```nginx
resource "aws_lambda_function" "myfunc" {
  filename         = data.archive_file.zip.output_path
  source_code_hash = data.archive_file.zip.output_base64sha256
  function_name    = local.full_name
  description      = "Hit counter demo"
  role             = aws_iam_role.iam_for_lambda.arn
  handler          = "func.handler" #filename.handlermethod
  runtime          = "python3.8"
  environment {
    variables = {
      TABLE_NAME = aws_dynamodb_table.hitcount.name
    }
  }
}
resource "aws_iam_role" "iam_for_lambda" {
  name = local.full_name
  assume_role_policy = data.aws_iam_policy_document.allow-lambda-assume.json
  managed_policy_arns = [
    "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
  ]
  inline_policy {
    name   = "ddbreadwrite"
    policy = data.aws_iam_policy_document.ddbreadwrite.json
  }
}
data "archive_file" "zip" {
  type        = "zip"
  source_dir = "${path.module}/lambda/"
  output_path = "${path.module}/packedlambda.zip"
}
data "aws_iam_policy_document" "allow-lambda-assume" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]
    principals {
      identifiers = ["lambda.amazonaws.com"]
      type        = "Service"
    }
  }
}
```

## Database

The database is DynamoDB with a single table; an `id` and `hitcount` columns. We can store as many website pages as ids and their corresponding hit counts in the `hitcount` column.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1692598721505/a24289ab-130b-4f43-9509-2c8f40a4f565.png align="center")

```nginx
data "aws_iam_policy_document" "ddbreadwrite" {
  statement {
    sid       = "ddbreadwrite"
    effect    = "Allow"
    actions   = ["dynamodb:Scan", "dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:UpdateItem"]
    resources = ["*"]
  }
}
resource "aws_dynamodb_table" "hitcount" {
  name           = local.full_name
  billing_mode   = "PAY_PER_REQUEST"
  hash_key       = "id"
  attribute {
    name = "id"
    type = "S"
  }
}
```

## The API

A lambda function URL will serve as the https endpoint to our lambda function. This is a quick way to create an API, but usually not suitable for production use.

```nginx
resource "aws_lambda_function_url" "url1" {
  function_name      = aws_lambda_function.myfunc.function_name
  authorization_type = "NONE"
  cors {
    allow_credentials = true
    allow_origins     = ["*"]
    allow_methods     = ["*"]
    allow_headers     = ["date", "keep-alive"]
    expose_headers    = ["keep-alive", "date"]
    max_age           = 86400
  }
}
```

Here is the full [**GitHub code**](https://github.com/nquayson/aws-hitcounter) for the project.

# **Let's run Terraform**

To run Terraform we go through the workflow steps:

```bash
terraform init
terraform plan
terraform apply
```

## Outputs

Our resources should now be created in the AWS account. We see a terminal output for the new function URL endpoint. Making a get request to this endpoint should return the hit count value.

```bash
Changes to Outputs:
  + function_endpoint = "https://4pxyxy0e.execute-api.us-east-2.amazonaws.com/prod/api"
```

# What's next: Embedding the counter API into a webpage

Now that our API is successfully deployed, we can integrate it into a webpage by writing some Javascript in our frontend.

```javascript
url = "https://4pxyxy0e.execute-api.us-east-2.amazonaws.com/prod/api"
let mcount = ""
fetch(url)
  .then(response => response.text())
  .then((response) => {
      console.log(response)
      mcount = response
      for (let i = 0; i < mcount.length; i++) {
        var newSpan = document.createElement('span');
        newSpan.innerHTML = mcount[i];
        document.getElementById('mydiv').appendChild(newSpan);
      }
  })
  .catch(err => console.log(err))
```

The code fetches data from our API endpoint via a GET request and then appends each character of the text response to an HTML span on the webpage.

See the full [working demo here](https://demos.nquayson.com/hitcounter/index.html).

Let me know your thoughts in the comment section.
