Build a Three-Tier AWS Visitor Counter

Deploy a Terraform-managed EC2, RDS, and Lambda visitor counter on AWS.

Introduction

30 Second Summary

A web page can look healthy even when the data behind it is unreachable. The real test is whether every layer still connects when requests move between servers.

In this project, you will use Terraform to build a three-tier visitor counter on AWS. Two Amazon EC2 instances serve the page through an Application Load Balancer while Amazon RDS for PostgreSQL stores one shared count.

What You'll Build

At your own HTTPS subdomain, each refresh raises the shared visitor count even when a different EC2 instance serves the page.

By the end of this project, you'll have:

  • A live three-tier counter that displays its database status, shared visit number, and serving EC2 hostname.
  • A visible security test that first shows Database connection: blocked. After you add the security-group relationship, the page shows Database connection: connected.
  • A custom HTTPS entry point that redirects HTTP requests and serves the counter with a valid certificate.
  • Secret Mission: Route /api/runtime through the same load balancer and return JSON from AWS Lambda.

Are there any prerequisites?

You'll need a Linux computer.

You'll also need an AWS account with permission to create the services used in this project.

You must control a public subdomain whose DNS is managed outside Route 53.

Before We Start

Before any hands-on work begins, lock in what you are building and why it matters. This goal keeps every infrastructure decision tied to proving shared state across a three-tier application.

Set Up Terraform and AWS Access

The next deployment spans several AWS services. A failed deployment is difficult to diagnose when the local tools or active identity are uncertain.

This step verifies Terraform CLI 1.16.0. It also verifies AWS CLI v2 before any lab resource exists.

In this step, get ready to:
  • Verify Terraform CLI 1.16.0.
  • Prepare authenticated AWS CLI v2 access.
  • Create the ec2-rds-https workspace.
Verify Terraform 1.16.0

Terraform turns infrastructure configuration into managed resources. Checking the binary first separates local installation problems from deployment problems.

  • Open a terminal from your Linux applications menu.
  • Check the installed Terraform version plus its command list by running:
terraform version
terraform -help

What do these commands check?

  • The terraform version command reports the installed Terraform binary version.
  • The terraform -help command confirms that Terraform responds with its available commands.

✔️ I See Version 1.16.0

Good start. Terraform 1.16.0 is available.

ⓧ I See an Older Version

The project uses Terraform 1.16.0. Update the local binary before creating the workspace.

  • Install the current release on Debian or Ubuntu by running these commands:
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
terraform -help

What does this installation do?

These commands add HashiCorp's official APT repository. They install Terraform from that repository before checking its command list.

  • Use the matching Linux package-manager or binary option on the HashiCorp install page if your distribution is not Debian or Ubuntu.
  • Confirm the installed version by running:
terraform version
terraform -help

What should this confirm?

The first command should report Terraform 1.16.0. The second command should print Terraform's command list.

Still seeing the older version?

Start a new terminal session so your shell can find the updated binary.

If the older version still appears, help me find which Terraform binary my Linux shell is using.

ⓧ Command Not Found

Terraform needs to be installed before the project can create infrastructure. Debian and Ubuntu can use HashiCorp's official APT repository.

  • Install Terraform on Debian or Ubuntu by running these commands:
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
terraform -help

What does this installation do?

These commands trust HashiCorp's package-signing key. They add the official repository before installing Terraform.

  • Use the matching Linux package-manager or binary option on the HashiCorp install page if your distribution is not Debian or Ubuntu.
  • Start a new terminal session if your installer requests it.
  • Confirm the installation by running:
terraform version
terraform -help

What should this confirm?

Terraform should report version 1.16.0. Its help output should list the available commands.

Terraform still unavailable?

Start a fresh terminal session after installation. Your existing shell may still have the old command path cached.

If the command remains unavailable, help me diagnose why Terraform is not available in my Linux shell.

Verify AWS CLI v2 and authenticate

AWS CLI carries your local commands to the AWS account. Its active authentication determines which account Terraform can change.

  • Check the installed AWS CLI version by running:
aws --version

What does this command check?

The command reports the installed AWS CLI release. This project requires major version 2.

✔️ I See AWS CLI v2

Your AWS CLI is ready. The next check connects it to an approved AWS identity.

ⓧ I See AWS CLI v1

The installed CLI is behind the project's required major version. AWS provides a Linux installer for AWS CLI v2.

  • Install AWS CLI v2 by running:
curl -fsSL https://awscli.amazonaws.com/v2/install.sh | bash

What does this installer do?

The command downloads AWS's Linux installation script. The script installs AWS CLI v2 for supported Linux architectures.

  • Start a new terminal session if the installer requests it.
  • Confirm the updated major version by running:
aws --version

What should this confirm?

AWS CLI should now report major version 2.

Still seeing AWS CLI v1?

Start a new terminal session so your shell can locate the updated command.

If the older release still appears, help me find why my Linux shell still uses AWS CLI v1.

ⓧ Command Not Found

AWS CLI needs to be installed before you can confirm an account identity. The official Linux installer supports x86-64 plus ARM systems.

  • Install AWS CLI v2 by running:
curl -fsSL https://awscli.amazonaws.com/v2/install.sh | bash

What does this installer do?

The command downloads AWS's Linux installation script. The script installs the AWS CLI binary on your device.

  • Start a new terminal session if the installer requests it.
  • Confirm the installation by running:
aws --version

What should this confirm?

The command should report AWS CLI major version 2.

AWS CLI still unavailable?

Start a fresh terminal session after the installer completes. This lets your shell reload its command path.

If the command still fails, help me diagnose my AWS CLI v2 installation on Linux.

Authentication touches credentials, so use only the login method your organization approves. The project works with an approved profile, an SSO session, or an existing role.

  • Avoid creating long-term IAM user keys solely for this lab.
  • Choose the tab matching your approved authentication method.

IAM Identity Center

IAM Identity Center gives the CLI a temporary SSO session. The command starts an interactive setup using details supplied by your organization.

  • Configure the SSO session by running:
aws configure sso

What does this command do?

The command configures an AWS CLI profile for IAM Identity Center. It uses your organization's SSO details to establish authenticated access.

  • Complete the interactive setup with values supplied by your organization.
  • Finish the browser sign-in if your authentication flow opens one.

SSO authentication not completing?

Confirm the SSO details with your AWS administrator. Organization-specific values must match the account you are approved to use.

For more help, help me troubleshoot AWS CLI IAM Identity Center authentication.

Standard AWS Profile

A standard profile stores configuration for later AWS CLI commands. Use only credentials that your organization has already approved.

  • Configure the approved profile by running:
aws configure

What does this command do?

The command starts the AWS CLI profile setup. It stores the approved configuration for later commands.

  • Enter the values supplied by your organization.

Missing approved profile details?

Stop the setup until your AWS administrator supplies an approved access method. Creating permanent credentials for a short lab increases unnecessary risk.

For guidance, help me choose a safe AWS CLI authentication method.

Existing Role Profile

An existing role-based profile can supply temporary AWS permissions. Keep using the login process your organization already established.

  • Activate the existing role-based profile through your organization's normal login process.
  • Keep that authenticated session active in the current terminal.

Role session unavailable?

Confirm that the role is approved for this AWS account. Ask your AWS administrator to restore the established access path if the session has expired.

For more help, help me troubleshoot my existing AWS role profile.

Create the workspace and confirm the active identity

A dedicated workspace keeps the configuration from later steps in one place. The app subdirectory will hold the visitor-counter page.

  • Move to your Desktop by running this command:
cd ~/Desktop

What does this command do?

The command moves your terminal to the Desktop directory. This gives the new workspace a predictable location.

  • Create the workspace plus its application subdirectory by running these commands:
mkdir -p ec2-rds-https/app
cd ec2-rds-https

What do these commands create?

  • The first command creates the ec2-rds-https directory plus its nested app directory.
  • The second command moves your terminal into the new workspace.
  • Confirm the current location plus the new subdirectory by running:
pwd
ls

What do these commands confirm?

  • The pwd command prints your current directory.
  • The ls command lists the workspace contents.

You should see a path ending in /Desktop/ec2-rds-https. The listing should include app.

Before you run the final check, which AWS account plus role do you expect the CLI to report?

  • Confirm the complete local setup by running:
pwd
terraform -help
aws --version
aws sts get-caller-identity

What does the final check prove?

  • The pwd command confirms that your terminal is inside the workspace.
  • The terraform -help command proves that the Terraform CLI responds.
  • The aws --version command confirms that AWS CLI v2 is available.
  • The aws sts get-caller-identity command returns the active AWS account plus the user or role ARN.

You should see a path ending in /Desktop/ec2-rds-https. Terraform should print its command list.

AWS CLI should report major version 2. The identity response should show your AWS account plus ARN.

That is the local foundation complete. Your workspace can now send authenticated infrastructure commands to the intended AWS account.

Identity check not returning your account?

Repeat the approved authentication flow if your session has expired. Confirm the selected profile with your AWS administrator if the response identifies an unexpected account.

For focused help, help me troubleshoot my active AWS CLI identity.

Your toolchain plus AWS identity are ready. Next, you will launch the first live version of the three-tier application.

Launch the Incomplete App

Your AWS identity is ready. You can now turn an empty workspace into a running three-tier application.

This deployment uses Terraform to create the network, compute tier, database tier, load balancer, and certificate request. You will test the public request path and the private database path as separate parts of the architecture.

In this step, get ready to:
  • Create the Terraform configuration and visitor-counter application files.
  • Initialize the providers and apply the infrastructure.
  • Test the public page and begin certificate validation.
Create the infrastructure files

The configuration divides the application across an Amazon VPC, two Amazon EC2 instances, an Application Load Balancer, and a private Amazon RDS database. Each file owns one part of that architecture.

The two feature toggles keep the first deployment focused on HTTP. They also preserve the initial database boundary until you test it.

  • Enter your dedicated application subdomain here: app.example.com.
  • Select the full-code tab below.
  • Create each named file inside the existing ec2-rds-https workspace using its matching code block.
  • Create app/index.php inside the existing app subdirectory.

✔️ Awesome, I've got everything!

Great. Double-check that every file is saved before you initialize Terraform.

ⓧ I'd like to double check the full code

terraform {
  required_version = ">= 1.16.0, < 1.17.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "6.62.0"
    }
    random = {
      source  = "hashicorp/random"
      version = "3.9.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

What does this file do?

  • The Terraform constraint keeps the project on version 1.16.0.
  • The provider constraints select AWS provider 6.62.0 and Random provider 3.9.0.
  • The provider block sends AWS resources to the configured region.
variable "aws_region" {
  description = "AWS Region for the lab"
  type        = string
}

variable "domain_name" {
  description = "Dedicated application subdomain, such as app.example.com"
  type        = string
}

variable "enable_db_access" {
  description = "Creates the EC2-to-RDS security group rule"
  type        = bool
}

variable "enable_https" {
  description = "Replaces temporary HTTP forwarding with HTTPS and an HTTP redirect"
  type        = bool
}

What do these variables control?

  • The region and domain variables identify where the stack runs and which hostname it serves.
  • The two Boolean variables control database access and HTTPS independently.
aws_region = "us-east-1"
domain_name = "[[DOMAIN_NAME="app.example.com"]]"
enable_db_access = false
enable_https = false

Why are both toggles false?

The first deployment uses a temporary HTTP listener. It also leaves the application-to-database ingress rule absent so you can test that boundary directly.

.terraform/
*.tfstate
*.tfstate.*
.terraform.lock.hcl
lambda_function.zip

What does this file exclude?

These patterns keep generated Terraform data, state files, the dependency lock file, and the optional Lambda archive out of version control.

data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "visitor-counter-vpc"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "visitor-counter-igw"
  }
}

resource "aws_subnet" "public" {
  count = 2

  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.${count.index + 1}.0/24"
  availability_zone       = data.aws_availability_zones.available.names[count.index]
  map_public_ip_on_launch = true

  tags = {
    Name = "visitor-public-${count.index + 1}"
  }
}

resource "aws_subnet" "private" {
  count = 2

  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.${count.index + 11}.0/24"
  availability_zone = data.aws_availability_zones.available.names[count.index]

  tags = {
    Name = "visitor-private-${count.index + 1}"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }

  tags = {
    Name = "visitor-public-routes"
  }
}

resource "aws_route_table_association" "public" {
  count = 2

  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

How is the network divided?

  • The VPC uses 10.0.0.0/16 as its address range.
  • Two public subnets host the load balancer and EC2 instances across separate Availability Zones.
  • Two private subnets give RDS a database subnet group without public IP assignment.
  • The public route table sends internet traffic through the internet gateway.
resource "aws_security_group" "alb" {
  name        = "visitor-alb-sg"
  description = "Public HTTP and HTTPS entry point"
  vpc_id      = aws_vpc.main.id
}

resource "aws_security_group" "app" {
  name        = "visitor-app-sg"
  description = "Application traffic from the ALB only"
  vpc_id      = aws_vpc.main.id
}

resource "aws_security_group" "db" {
  name        = "visitor-db-sg"
  description = "PostgreSQL traffic from the application tier only"
  vpc_id      = aws_vpc.main.id
}

resource "aws_vpc_security_group_ingress_rule" "alb_http" {
  security_group_id = aws_security_group.alb.id
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 80
  to_port           = 80
  ip_protocol       = "tcp"
}

resource "aws_vpc_security_group_ingress_rule" "alb_https" {
  security_group_id = aws_security_group.alb.id
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 443
  to_port           = 443
  ip_protocol       = "tcp"
}

resource "aws_vpc_security_group_egress_rule" "alb_to_app" {
  security_group_id            = aws_security_group.alb.id
  referenced_security_group_id = aws_security_group.app.id
  from_port                    = 8080
  to_port                      = 8080
  ip_protocol                  = "tcp"
}

resource "aws_vpc_security_group_ingress_rule" "app_from_alb" {
  security_group_id            = aws_security_group.app.id
  referenced_security_group_id = aws_security_group.alb.id
  from_port                    = 8080
  to_port                      = 8080
  ip_protocol                  = "tcp"
}

resource "aws_vpc_security_group_egress_rule" "app_outbound" {
  security_group_id = aws_security_group.app.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
  count = var.enable_db_access ? 1 : 0

  security_group_id            = aws_security_group.db.id
  referenced_security_group_id = aws_security_group.app.id
  from_port                    = 5432
  to_port                      = 5432
  ip_protocol                  = "tcp"
}

How does traffic move between tiers?

  • The load balancer accepts public HTTP and HTTPS traffic.
  • Security-group references limit port 8080 traffic to the load balancer and application tiers.
  • The database rule would permit PostgreSQL traffic on port 5432 when its toggle is enabled.
resource "random_password" "db" {
  length  = 24
  special = false
}

resource "aws_db_subnet_group" "app" {
  name       = "visitor-counter-db-subnets"
  subnet_ids = aws_subnet.private[*].id

  tags = {
    Name = "visitor-counter-db-subnets"
  }
}

resource "aws_db_instance" "app" {
  identifier = "visitor-counter-db"

  allocated_storage      = 20
  storage_type           = "gp3"
  storage_encrypted      = true
  engine                 = "postgres"
  engine_version         = "17.11"
  auto_minor_version_upgrade = false
  instance_class         = "db.t4g.micro"

  db_name  = "visitorcounter"
  username = "appuser"
  password = random_password.db.result
  port     = 5432

  db_subnet_group_name   = aws_db_subnet_group.app.name
  vpc_security_group_ids = [aws_security_group.db.id]
  publicly_accessible    = false
  multi_az               = false

  backup_retention_period = 0
  deletion_protection     = false
  skip_final_snapshot     = true
  apply_immediately       = true

  tags = {
    Name = "visitor-counter-db"
  }
}

How is the database configured?

  • The Random provider creates a 24-character database password without special characters.
  • The database uses encrypted gp3 storage and PostgreSQL 17.11.
  • The private subnet group and disabled public access keep the database away from the public internet.
  • The disposable lab configuration removes the database without a final snapshot during teardown.
resource "aws_instance" "app" {
  count = 2

  ami                         = "resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
  instance_type               = "t3.micro"
  subnet_id                   = aws_subnet.public[count.index].id
  vpc_security_group_ids      = [aws_security_group.app.id]
  associate_public_ip_address = true
  user_data_replace_on_change = true

  user_data = templatefile("${path.module}/user_data.sh.tftpl", {
    app_base64 = filebase64("${path.module}/app/index.php")
    db_host    = aws_db_instance.app.address
    db_name    = aws_db_instance.app.db_name
    db_user    = aws_db_instance.app.username
    db_password = random_password.db.result
  })

  metadata_options {
    http_endpoint = "enabled"
    http_tokens   = "required"
  }

  root_block_device {
    encrypted   = true
    volume_size = 8
    volume_type = "gp3"
  }

  depends_on = [aws_internet_gateway.main]

  tags = {
    Name = "visitor-app-${count.index + 1}"
  }
}

resource "aws_lb" "app" {
  name               = "visitor-counter-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = aws_subnet.public[*].id

  tags = {
    Name = "visitor-counter-alb"
  }
}

resource "aws_lb_target_group" "app" {
  name        = "visitor-counter-tg"
  port        = 8080
  protocol    = "HTTP"
  target_type = "instance"
  vpc_id      = aws_vpc.main.id

  health_check {
    enabled = true
    path    = "/health"
    matcher = "200"
  }
}

resource "aws_lb_target_group_attachment" "app" {
  count = 2

  target_group_arn = aws_lb_target_group.app.arn
  target_id        = aws_instance.app[count.index].id
  port             = 8080
}

resource "aws_lb_listener" "http_forward" {
  count = var.enable_https ? 0 : 1

  load_balancer_arn = aws_lb.app.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

resource "aws_lb_listener" "http_redirect" {
  count = var.enable_https ? 1 : 0

  load_balancer_arn = aws_lb.app.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "redirect"

    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

How does the compute tier work?

  • Two Amazon Linux 2023 instances run in separate public subnets with encrypted root volumes.
  • The load balancer forwards HTTP requests to port 8080 on healthy instances.
  • The health check requests /health before an instance receives application traffic.
  • The HTTP forwarding listener exists while HTTPS remains disabled.
resource "aws_acm_certificate" "app" {
  domain_name       = var.domain_name
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }

  tags = {
    Name = "visitor-counter-certificate"
  }
}

resource "aws_lb_listener" "https" {
  count = var.enable_https ? 1 : 0

  load_balancer_arn = aws_lb.app.arn
  port              = 443
  protocol          = "HTTPS"
  certificate_arn   = aws_acm_certificate.app.arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

Why request the certificate now?

AWS Certificate Manager can begin DNS validation while you test the HTTP application. The HTTPS listener stays absent until its feature toggle is enabled.

output "http_url" {
  value = "http://${aws_lb.app.dns_name}"
}

output "alb_dns_name" {
  value = aws_lb.app.dns_name
}

output "certificate_arn" {
  value = aws_acm_certificate.app.arn
}

output "acm_validation_records" {
  value = [
    for option in aws_acm_certificate.app.domain_validation_options : {
      name  = option.resource_record_name
      type  = option.resource_record_type
      value = option.resource_record_value
    }
  ]
}

output "https_url" {
  value = "https://${var.domain_name}"
}

What do these outputs expose?

The outputs provide the temporary HTTP address, load balancer hostname, certificate ARN, DNS validation record, and future HTTPS address. They avoid exposing the generated database password.

#!/bin/bash
set -euxo pipefail

dnf install -y php8.4-cli php8.4-pgsql

useradd --system --home-dir /opt/visitor --shell /sbin/nologin visitor || true
mkdir -p /opt/visitor
printf '%s' '${app_base64}' | base64 --decode > /opt/visitor/index.php
chown -R visitor:visitor /opt/visitor

cat > /etc/visitor.env <<EOF
DB_HOST=${db_host}
DB_NAME=${db_name}
DB_USER=${db_user}
DB_PASSWORD=${db_password}
EOF
chown root:visitor /etc/visitor.env
chmod 640 /etc/visitor.env

PHP_BIN="$(command -v php)"
cat > /etc/systemd/system/visitor.service <<EOF
[Unit]
Description=Visitor Counter PHP Service
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=visitor
Group=visitor
EnvironmentFile=/etc/visitor.env
ExecStart=$PHP_BIN -S 0.0.0.0:8080 -t /opt/visitor
Restart=on-failure

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now visitor.service

What does the startup template do?

  • The template installs PHP 8.4 with PostgreSQL support.
  • It writes the application and database settings to their runtime locations.
  • It creates a system service that listens on port 8080.
<?php
$requestPath = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

if ($requestPath === '/health') {
    header('Content-Type: text/plain');
    http_response_code(200);
    echo 'ok';
    exit;
}

$hostname = gethostname() ?: 'unknown';
$status = 'blocked';
$visitNumber = 'not available';

$connectionString = sprintf(
    'host=%s port=5432 dbname=%s user=%s password=%s connect_timeout=3 sslmode=require',
    getenv('DB_HOST'),
    getenv('DB_NAME'),
    getenv('DB_USER'),
    getenv('DB_PASSWORD')
);

$connection = @pg_connect($connectionString);

if ($connection) {
    $tableResult = @pg_query(
        $connection,
        'CREATE TABLE IF NOT EXISTS visits (id BIGSERIAL PRIMARY KEY, visited_at TIMESTAMPTZ NOT NULL DEFAULT NOW())'
    );

    $visitResult = $tableResult
        ? @pg_query($connection, 'INSERT INTO visits DEFAULT VALUES RETURNING id')
        : false;

    if ($visitResult) {
        $status = 'connected';
        $visitNumber = pg_fetch_result($visitResult, 0, 0);
    }
}
?>
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>AWS Visitor Counter</title>
    <style>
        body { font-family: system-ui, sans-serif; background: #eef2ff; color: #172554; margin: 0; }
        main { max-width: 720px; margin: 10vh auto; background: white; padding: 2rem; border-radius: 1rem; box-shadow: 0 1rem 3rem #1e3a8a22; }
        h1 { margin-top: 0; }
        .status { display: inline-block; padding: .35rem .7rem; border-radius: 999px; background: <?= $status === 'connected' ? '#dcfce7' : '#fee2e2' ?>; }
        .count { font-size: 4rem; font-weight: 800; margin: 1rem 0; }
        code { background: #eff6ff; padding: .2rem .4rem; border-radius: .3rem; }
    </style>
</head>
<body>
<main>
    <h1>Three-Tier AWS Visitor Counter</h1>
    <p class="status">Database connection: <?= htmlspecialchars($status, ENT_QUOTES, 'UTF-8') ?></p>
    <div class="count"><?= htmlspecialchars((string) $visitNumber, ENT_QUOTES, 'UTF-8') ?></div>
    <p>Served by EC2 host <code><?= htmlspecialchars($hostname, ENT_QUOTES, 'UTF-8') ?></code></p>
    <p>The count is stored in private Amazon RDS PostgreSQL.</p>
</main>
</body>
</html>

How does the visitor counter respond?

  • Requests to /health return plain text with an HTTP 200 status.
  • Other requests attempt a TLS-encrypted PostgreSQL connection with a three-second timeout.
  • A successful connection creates the visits table and inserts one row.
  • The page displays the database status, visit number, and serving EC2 hostname.
  • Save every file in the ec2-rds-https workspace.
  • Confirm the workspace file list includes versions.tf, variables.tf, terraform.tfvars, and .gitignore.
  • Confirm the workspace file list includes network.tf, security.tf, database.tf, and compute.tf.
  • Confirm the workspace file list includes certificate.tf, outputs.tf, and user_data.sh.tftpl.
  • Confirm the app subdirectory contains index.php.

Missing a file or seeing the wrong domain?

Check each filename for spelling and confirm app/index.php sits inside the existing app subdirectory. Confirm terraform.tfvars contains your dedicated subdomain with both feature toggles set to false.

Ask for help checking the file layout: help me compare my Terraform workspace with the required file structure

Initialize and apply the stack

Initialization downloads the provider plugins selected in versions.tf. Validation then checks whether the files form an internally consistent Terraform configuration.

  • Initialize the ec2-rds-https workspace by running this command:
terraform init

What does this command do?

Terraform reads the provider requirements and downloads the selected AWS and Random provider plugins. It also prepares the current directory for later planning and application.

A successful initialization confirms that Terraform can resolve the required providers.

Provider download failed?

Check that your Linux device has internet access. Confirm that the provider source names and version numbers in versions.tf match the supplied code.

Ask for help diagnosing initialization: help me troubleshoot this Terraform initialization failure

  • Validate the complete configuration by running this command:
terraform validate

What does validation prove?

Terraform checks the configuration syntax and internal references after initialization. A successful result confirms that resource names and expressions connect correctly.

Validation found a problem?

Use the filename and line number in the validation output to locate the issue. Compare that section with the matching full-file reference above.

Ask for help reading the validation result: help me fix this Terraform validation error

This deployment can incur charges

This apply creates metered load balancing, compute, database, storage, and public IPv4 resources. Keep the lab run short and complete the cleanup section when you finish.

Provisioning can take several minutes because the database, load balancer, and two instances are created together. A quiet terminal during parts of this process does not mean the apply has stopped.

  • Start the infrastructure deployment by running this command:
  • Review the proposed resource changes when Terraform displays the plan.
  • Enter yes when Terraform asks for approval.
terraform apply

What happens during the apply?

  • Terraform creates the network and security boundaries first.
  • It provisions the private PostgreSQL database and two EC2 instances.
  • It registers both instances with the load balancer target group.
  • It requests the DNS-validated certificate for your subdomain.

Once the command completes, the terminal confirms that the planned resources were added. That is the infrastructure layer running under Terraform control.

Apply failed or stopped early?

Read the first resource-specific error in the terminal. Common causes include missing AWS permissions, unsupported resources in the selected region, or an expired authenticated session.

Ask for help with the failed resource: help me diagnose this Terraform apply failure

Test the page and add the validation record

The load balancer output gives you a public address for the new application. The EC2 instances must finish their startup scripts and pass the target-group health check before they can receive requests.

  • Print the temporary application address by running this command:
terraform output -raw http_url

What does this output provide?

The raw output prints the load balancer URL without Terraform formatting. This is the public HTTP entry point for the first deployment.

  • Record the printed address here: http://your-load-balancer-dns-name.

Before you load the page, which parts of the traffic path do you expect to respond successfully?

  • Paste http://your-load-balancer-dns-name into your web browser address bar.
  • Load the application page.

You should see the Three-Tier AWS Visitor Counter page with Database connection: blocked and a Served by EC2 host value. The public load balancer and EC2 path works while the private database connection remains blocked.

You have reached the designed boundary. The application knows where RDS is located, but the database security group does not yet trust the application security group.

Page not available yet?

The instances may still be installing PHP or waiting to pass their initial load balancer health checks. Wait briefly before refreshing the same URL.

Ask for help checking the public path: help me troubleshoot why my ALB page is unavailable

The certificate request now needs proof that you control the subdomain. AWS Certificate Manager provides an exact DNS record for that proof.

  • Display the Terraform outputs by running this command:
terraform output

Which output do you need?

Find acm_validation_records in the output. Its name and value form the CNAME record that proves control of your subdomain.

  • Record the CNAME name here: the ACM CNAME name.
  • Record the CNAME value here: the ACM CNAME value.
  • Sign in to the external DNS provider that manages your public domain.
  • Open the DNS records for your public domain.
  • Create a CNAME record for certificate validation.
  • Enter the ACM CNAME name in the record name field.
  • Enter the ACM CNAME value in the record value field.
  • Save the DNS record.

Your DNS provider should now show the saved CNAME record. ACM can remain pending for up to 30 minutes after the record is created.

Certificate validation record rejected?

Compare the record name and value character for character with acm_validation_records. Some DNS providers automatically append the domain name, so check the final saved hostname for duplication.

Ask for help checking the DNS record: help me compare my ACM validation CNAME with my DNS record

Your public request path is live, and the blocked database status has exposed the missing trust relationship. Next, you will open that private path and watch both EC2 instances share one visitor count.

Open the Database Path

Your public request path already reaches both Amazon EC2 instances through the Application Load Balancer.

The visitor counter still stops at the Amazon RDS for PostgreSQL security group. The shared data tier remains out of reach.

Terraform already defines a conditional ingress rule from visitor-app-sg to visitor-db-sg on TCP port 5432. Enabling it gives the app tier a private TLS-protected path to PostgreSQL.

In this step, get ready to:
  • Enable the existing database ingress rule with the Terraform toggle.
  • Apply the one-resource security group change.
  • Prove both EC2 instances share one increasing database-backed count.
Locate the database control

The security.tf file uses count to control whether Terraform creates the database ingress rule. The false value kept the initial failure visible.

  • Return to terraform.tfvars in the file editor you used earlier.
  • Find the line where enable_db_access is set to false.
  • Make the toggle create its conditional rule with this replacement:
enable_db_access = true

What Changes Here?

  • The true value changes the conditional resource count from zero to one.
  • The aws_vpc_security_group_ingress_rule.db_from_app resource attaches to visitor-db-sg.
  • The rule accepts TCP traffic on port 5432 only from visitor-app-sg.
  • Save terraform.tfvars.

✔️ Awesome, I've got everything!

Your saved database toggle is ready for the Terraform plan.

ⓧ I'd like to double check the full code

  • Compare terraform.tfvars with this reference.
  • Keep your dedicated subdomain in the domain_name value.
aws_region = "us-east-1"
domain_name = "THE_LEARNER_DEDICATED_SUBDOMAIN"
enable_db_access = true
enable_https = false

What Should Match?

The enable_db_access value is the only setting changed in this step. The enable_https value stays false so the temporary HTTP listener remains in place.

The domain_name value must still contain the dedicated subdomain you entered earlier.

Apply the security group change

Terraform compares the saved toggle with the resources already running in AWS. The plan should contain one new database ingress rule.

  • Create the plan for this saved change by running:
terraform apply

What Does This Command Do?

The terraform apply command compares the saved configuration with the current AWS resources. It pauses for approval before creating the rule.

  • Review the proposed changes.
  • Confirm the plan adds only aws_vpc_security_group_ingress_rule.db_from_app.
  • Do not approve the plan if it includes other changes.
  • Enter yes when Terraform asks for approval.

Terraform reports that one resource was added.

You've cleared the blocked path. Amazon RDS now accepts PostgreSQL traffic from the application security group.

Apply Did Not Complete?

  • Compare terraform.tfvars with the reference above if the plan includes unexpected changes.
  • Reauthenticate with the same AWS method from Step 1 if Terraform reports expired credentials.

Help me diagnose why my Terraform apply did not add only the database ingress rule.

Verify the shared visitor counter

The app/index.php application already connects to the private endpoint with sslmode=require. The new rule lets that request reach PostgreSQL without exposing RDS publicly.

  • Return to the HTTP load balancer page from the previous step.

Before you refresh, consider which result you expect: the blocked state or the first shared visit.

  • Refresh the page once.

You'll see Database connection: connected. A numeric visit count replaces not available.

  • Read the displayed visit number.
  • Refresh the page several times.
  • Compare each new visit number with the previous one.
  • Check the Served by EC2 host value after each refresh.

The visit number rises with each refresh. The EC2 hostname may change without resetting the count.

You made the three tiers work together. Both EC2 instances now share one private PostgreSQL-backed counter.

Still Seeing the Blocked State?

  • Confirm enable_db_access is set to true in terraform.tfvars.
  • Confirm the latest Terraform apply added aws_vpc_security_group_ingress_rule.db_from_app.

Help me diagnose why the visitor counter still shows a blocked database connection.

Your three-tier data path is working. Next, you will put the same counter behind your custom domain with HTTPS.

Enable Your Domain and HTTPS

Your visitor counter now shares one PostgreSQL-backed count across two EC2 instances.

The public Application Load Balancer address works. A showable application also needs a trusted name.

HTTPS gives visitors an encrypted entry point. AWS Certificate Manager confirms that your certificate is ready for that connection.

External DNS maps your subdomain to the load balancer. Terraform then forces HTTP requests onto HTTPS.

In this step, get ready to:
  • Confirm that the ACM certificate is issued.
  • Map your application subdomain to the load balancer.
  • Enable the HTTPS listener and the HTTP redirect.
Confirm the ACM certificate is issued

An HTTPS listener needs an issued certificate. AWS can issue it after the validation CNAME proves that you control the subdomain.

  • Display the current Terraform outputs by running this command:
terraform output

What Do These Outputs Tell You?

  • The certificate_arn output identifies the ACM certificate created for your subdomain.
  • The alb_dns_name output identifies the load balancer destination that your application CNAME needs.
  • Record the value beside certificate_arn here: your ACM certificate ARN.

Before you check the certificate, what status do you expect ACM to return?

  • Check the certificate status by replacing YOUR_CERTIFICATE_ARN with your ACM certificate ARN in this command:
aws acm describe-certificate --certificate-arn YOUR_CERTIFICATE_ARN --query 'Certificate.Status' --output text

What Does This Command Check?

This command asks ACM for the current status of the certificate identified by the ARN. The query limits the response to the status value.

You should see ISSUED. This confirms that the certificate can be attached to the HTTPS listener.

Certificate Still Pending?

ACM can remain in PENDING_VALIDATION for up to 30 minutes after you create the validation CNAME. The wait is normal while DNS changes propagate.

  • Compare the validation CNAME at your external DNS provider with the name and value in acm_validation_records.
  • Leave the validation CNAME in place so ACM can renew the certificate.
  • Run the status command again after the DNS record has propagated.

Get help checking why my ACM certificate remains pending.

Point your subdomain at the load balancer

A CNAME sends requests for your application subdomain to the load balancer hostname. DNS provider screens label these fields differently, so this part can feel fiddly.

  • Record the value beside alb_dns_name from the Terraform output here: your ALB DNS name.
  • Record the existing domain_name value from terraform.tfvars here: your dedicated application subdomain.
  • Return to the external DNS provider you used for the ACM validation record.
  • Create a second CNAME record.
  • Set the record name to your dedicated application subdomain.
  • Set the record value to your ALB DNS name.
  • Save the DNS record.
  • Confirm the DNS record list shows the application CNAME beside the ACM validation CNAME.
Switch the load balancer to HTTPS

The enable_https toggle controls which listeners Terraform manages. Turning it on replaces the temporary HTTP forwarding path.

  • In the editor you used for the project files, find enable_https = false in terraform.tfvars.
  • Replace that line with the value below:
enable_https = true

What Does This Toggle Change?

  • Terraform removes aws_lb_listener.http_forward from port 80.
  • Terraform creates aws_lb_listener.http_redirect with an HTTP_301 redirect to port 443.
  • Terraform creates aws_lb_listener.https with the issued ACM certificate.
  • The HTTPS listener forwards requests to visitor-counter-tg.

✔️ Awesome, I've got everything!

Your file keeps database access enabled while activating HTTPS.

ⓧ I'd like to double check the full code

aws_region       = "us-east-1"
domain_name      = "[[YOUR_SUBDOMAIN="your dedicated application subdomain"]]"
enable_db_access = true
enable_https     = true

What Should You Compare?

  • The domain_name value keeps your dedicated application subdomain.
  • The enable_db_access value remains true.
  • The enable_https value is now true.

Terraform shows a plan before changing the listeners. The command waits for your approval.

  • Save terraform.tfvars.
  • Apply the updated configuration by running this command:
terraform apply

What Does This Apply Do?

Terraform compares the enabled HTTPS configuration with the listeners already attached to the load balancer. The plan shows the listener transition before AWS changes the traffic path.

  • Confirm the plan removes aws_lb_listener.http_forward.
  • Confirm the plan creates aws_lb_listener.http_redirect.
  • Confirm the plan creates aws_lb_listener.https.
  • Enter yes when Terraform asks for approval.

That listener switch is complete. Your load balancer can now accept encrypted requests through your certificate.

Terraform Apply Failed?

  • Confirm the ACM status check returned ISSUED.
  • Confirm terraform.tfvars contains enable_https = true.
  • Run terraform apply again after correcting the certificate or toggle.

Help me diagnose my HTTPS listener apply failure.

Before you test the public address, do you expect the browser to stay on HTTP or move to HTTPS?

  • Enter http:// followed by your dedicated application subdomain in your browser address bar.
  • Press Enter to load the page.

Your browser should land on the https:// version of the same subdomain. The browser should show a valid certificate for the connection.

The visitor-counter page should report Database connection: connected.

  • Refresh the HTTPS page.

The shared visit number should increase while the page remains on your secure custom domain.

Redirect or Certificate Not Working?

  • Confirm the application CNAME points to your ALB DNS name.
  • Confirm the browser address uses your dedicated application subdomain.
  • Allow time for the application CNAME to propagate before testing again.

Help me troubleshoot my custom-domain HTTPS redirect.

You made the complete public path work. Visitors now reach the connected counter through your domain over HTTPS.

Secret mission

Add a Lambda Route

Your HTTPS domain currently sends every request to EC2. Add a path-based route that sends `/api/runtime` through the same load balancer to AWS Lambda and returns a JSON response.

Clean Up Your Resources

Clean Up Your Resources

This project uses metered Amazon EC2, an Application Load Balancer, Amazon RDS, EBS, and public IPv4 resources. Decide whether to keep the stack running, pause its main compute resources, or delete everything.

Cost warning

Leaving the stack active continues metered charges for the load balancer, EC2 instances, RDS database, EBS storage, and public IPv4 addresses. Your external DNS provider may charge separately.

Pausing reduces EC2 and RDS compute charges. The load balancer, storage, and some networking resources can still incur charges.

Delete is the safest option if you have finished the lab. The non-exportable public certificate has no additional certificate charge while used with the load balancer.

Resources you used:

  • The Terraform-managed network layer, including the Amazon VPC, four subnets, internet gateway, public route table, and three security groups.
  • The application layer, including two EC2 instances, their EBS root volumes, public IPv4 addresses, load balancer, listeners, and target groups.
  • The data and certificate layer, including the private PostgreSQL database and the AWS Certificate Manager certificate.
  • The AWS Lambda route, including the Lambda function, IAM role, invocation permission, target group, target attachment, and HTTPS listener rule.
  • The application CNAME and ACM validation CNAME at your external DNS provider.
  • The local ec2-rds-https workspace, including the Terraform state and generated lambda_function.zip archive.

Keep everything running

No action needed. Choose this if you are still testing the visitor counter or its Lambda route.

  • Leave the Terraform-managed AWS stack unchanged.
  • Keep the application CNAME so your custom HTTPS address continues resolving to the load balancer.
  • Keep the ACM validation CNAME so certificate renewal can continue.
  • Monitor your AWS billing because every running hour can add metered charges.

Pause - I'll come back to this later

Shut down the main compute resources to reduce charges while keeping the project available for later.

  • Stop the two EC2 instances named visitor-app-1 and visitor-app-2 through Amazon EC2.
  • Stop the database named visitor-counter-db through Amazon RDS.
  • Leave both external DNS CNAME records unchanged.
  • Start the same EC2 instances when you return.
  • Start visitor-counter-db when you return.

Your site becomes unavailable while EC2 and RDS are stopped. The load balancer, storage, public IPv4 addresses, and external DNS can continue accruing charges.

These manual stop states sit outside this Terraform configuration. Delete the stack if you do not plan to return soon.

Delete - I don't want to use this again

Remove all project resources to stop ongoing AWS charges. Deletion permanently removes the visitor counter data because skip_final_snapshot = true.

Deleting the stack is a one-way cleanup. Terraform shows the complete destroy plan before it accepts your approval.

  • Delete the application CNAME that points your dedicated subdomain to the alb_dns_name value at your external DNS provider.
  • Delete the ACM validation CNAME whose name and value came from acm_validation_records.
  • Confirm both CNAME records no longer appear in your DNS zone.
  • Switch back to the terminal in ec2-rds-https.
  • Start the Terraform teardown by running this command:
terraform destroy

What does this command remove?

Terraform reads the current state and proposes removing every AWS object managed by this workspace. This includes the network, EC2 instances, load balancer, RDS database, certificate, Lambda route, and IAM resources.

The database has no final snapshot because the lab enables immediate deletion. Its visitor records disappear with the database instance.

  • Review the destroy plan for every resource marked for removal.
  • Enter yes when you are ready to approve the teardown.
  • Wait while AWS removes the database, load balancer, compute resources, and network.

You should see Terraform finish with no managed infrastructure left in the workspace state. That confirms the metered AWS stack has been removed.

Destroy did not finish?

  • Refresh your organization’s AWS authentication if Terraform can no longer call AWS.
  • Resolve the first reported dependency if one resource refuses deletion.
  • Run the teardown command again after fixing the reported cause.

Help me diagnose the Terraform teardown failure.

This final local cleanup permanently removes your project files. Keep the folder if you want to study the configuration after the AWS resources are gone.

  • Remove the local ec2-rds-https workspace by running these commands from its terminal:
cd ..
rm -rf ec2-rds-https

What does this command remove?

The first line moves outside the workspace. The second permanently removes the ec2-rds-https folder with its configuration, Terraform state, PHP app, Lambda code, and generated archive.

  • Confirm ec2-rds-https no longer appears in its parent folder.

Nice Work!

Nice Work!

You did it! Your Terraform-managed visitor counter now serves a shared database-backed page over HTTPS.

The same Application Load Balancer routes /api/runtime to AWS Lambda.

You learned how to:

  • Build a directly managed EC2 tier with two instances behind an Application Load Balancer.
  • Diagnose a blocked private database path before opening it through a security-group reference to Amazon RDS for PostgreSQL.
  • Publish a custom HTTPS domain with an AWS Certificate Manager certificate plus forced HTTP redirection.
  • Secret Mission: Route /api/runtime through the same load balancer to a Lambda function that returns runtime details as JSON.

Ready to quiz yourself?