Secure Multi-Tier Architecture with Terraform

Deploy an encrypted, auto-scaling multi-tier AWS app using Terraform IaC.

Introduction

30 Second Summary

Think about how banks protect sensitive customer data. They use layered security: vaults inside rooms inside buildings, each with its own access controls.

In this project, you will build a multi-tier web architecture on AWS using Terraform. Your infrastructure encrypts sensitive data at the application level, so only authorized users can see the real information.

What You'll Build

A production-style AWS architecture with a Python Flask API behind an Application Load Balancer, a database tier with encrypted records, and shared storage across auto-scaling instances.

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

  • A complete Terraform codebase that provisions a secure multi-tier AWS architecture in a single terraform apply command.
  • A working application-level encryption flow where authorized users see decrypted data and unauthorized requests see only encrypted blobs.
  • Layered security with 4 chained security groups, IAM least-privilege roles, and encrypted secrets in SSM Parameter Store.
  • Secret Mission: Add a target-tracking Auto Scaling policy and stress-test to watch your architecture scale dynamically while maintaining full functionality.

Are there any prerequisites?

You need an AWS free tier account with the AWS CLI configured and Terraform installed on your machine. A code editor like VS Code is also required for editing the configuration files.

Before We Start

Before We Start...

Set Up the Terraform Project Structure

With your AWS CLI configured and Terraform installed, you're ready to start defining infrastructure as code. Terraform uses declarative configuration files to describe what resources you want, then handles creation and wiring automatically.

In this step, you'll create the core Terraform files that configure the AWS provider, define the input variables your project needs, and prepare the outputs you'll reference later when testing.

In this step, get ready to:
  • Create the provider configuration to connect Terraform to AWS.
  • Define input variables for the region, network CIDR, encryption key, and database password.
  • Run terraform init to download the AWS provider plugin.
Create the provider configuration

Terraform needs to know which cloud provider you're targeting and which plugin version to download. The providers.tf file handles both.

  • In your code editor, create a new file called providers.tf inside your project directory.
  • Add the following configuration:
terraform {
  required_version = ">= 1.15"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.86"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

What does this code do?

The terraform block sets two constraints: your Terraform CLI must be version 1.15 or higher, and the AWS provider must match version ~> 5.86 (any 5.x release from 5.86 onward).

The provider "aws" block tells Terraform to deploy resources into whichever region the aws_region variable holds. You'll define that variable next.

  • Save providers.tf.
Define input variables and create the tfvars file

Terraform variables let you parameterize your configuration so sensitive values and environment-specific settings stay out of the main code. You'll define four variables: the AWS region, the VPC network range, an encryption key, and a database password.

  • Create a new file called variables.tf in the same directory.
  • Add the following variable definitions:
variable "aws_region" {
  description = "AWS region to deploy into"
  type        = string
  default     = "us-east-1"
}

variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "encryption_key" {
  description = "Fernet encryption key for application-level encryption"
  type        = string
  sensitive   = true
}

variable "db_password" {
  description = "MariaDB root password"
  type        = string
  sensitive   = true
}

What does this code do?

Each variable block declares an input that Terraform expects at runtime. The aws_region and vpc_cidr variables have defaults, so you only need to override them if you want a different region or network range.

The encryption_key and db_password variables are marked sensitive = true. This tells Terraform to redact their values in any plan or apply output, preventing accidental exposure in logs.

  • Save variables.tf.

Now you need to supply actual values for the two sensitive variables. Terraform reads values from a file called terraform.tfvars automatically.

  • Generate a Fernet encryption key by running this command in your terminal:
python3 -c "import base64, os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())"

What is a Fernet key?

Fernet is a symmetric encryption scheme from the Python cryptography library. The key is 32 random bytes encoded as a URL-safe base64 string (44 characters). Your Flask app will use this key to encrypt and decrypt sensitive database records.

  • Copy the 44-character string that the command prints.
  • Create a new file called terraform.tfvars in your project directory.
  • Add the following content, replacing the placeholder values with your generated key and a password you choose:
aws_region     = "us-east-1"
encryption_key = "your-generated-fernet-key-here"
db_password    = "your-chosen-password-here"

Choose a memorable password

Your db_password can be any string. For this learning project, something simple like SecurePass123 works fine. In production you would use a generated secret.

  • Save terraform.tfvars.

Key generation command not working?

If you see a command not found error, make sure Python 3 is installed and accessible as python3 on your system. On Windows, try python instead.

If the command returns nothing, check that you copied it exactly. The command uses standard library modules only, so no extra packages are needed. help me generate a Fernet encryption key for my Terraform project

Finally, you'll set up an outputs file. Outputs let Terraform display important values after a deployment completes.

  • Create a new file called outputs.tf in your project directory.
  • Add the following output definitions:
output "alb_dns_name" {
  description = "DNS name of the Application Load Balancer"
  value       = aws_lb.web.dns_name
}

output "db_private_ip" {
  description = "Private IP of the MariaDB instance"
  value       = aws_instance.db.private_ip
}

What does this code do?

These outputs tell Terraform to print the ALB's public DNS name and the database server's private IP after every terraform apply. You'll use the ALB DNS to test your API later. The resource references (aws_lb.web and aws_instance.db) don't exist yet, but Terraform won't complain until you run terraform plan.

  • Save outputs.tf.
  • Confirm all four files exist by running:
ls
  • You should see providers.tf, variables.tf, terraform.tfvars, and outputs.tf in the output.

✔️ Awesome, I've got everything!

Great. Double check you've saved all four files before moving on.

ⓧ I'd like to double check the full code

Here are all the files you should have so far:

terraform {
  required_version = ">= 1.15"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.86"
    }
  }
}

provider "aws" {
  region = var.aws_region
}
variable "aws_region" {
  description = "AWS region to deploy into"
  type        = string
  default     = "us-east-1"
}

variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "encryption_key" {
  description = "Fernet encryption key for application-level encryption"
  type        = string
  sensitive   = true
}

variable "db_password" {
  description = "MariaDB root password"
  type        = string
  sensitive   = true
}
aws_region     = "us-east-1"
encryption_key = "your-fernet-key-here"
db_password    = "your-db-password-here"
output "alb_dns_name" {
  description = "DNS name of the Application Load Balancer"
  value       = aws_lb.web.dns_name
}

output "db_private_ip" {
  description = "Private IP of the MariaDB instance"
  value       = aws_instance.db.private_ip
}
Initialize the Terraform workspace

Terraform needs to download the AWS provider plugin before it can plan or apply any configuration. The terraform init command handles this automatically.

  • Initialize your workspace by running:
terraform init

What does this command do?

The terraform init command reads your providers.tf file, finds the hashicorp/aws provider requirement, downloads the matching plugin from the Terraform Registry, and stores it in a hidden .terraform directory.

It also creates a .terraform.lock.hcl lock file that pins the exact provider version for reproducible builds.

  • You should see output that includes:
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.86"...
- Installing hashicorp/aws v5.86.0...
- Installed hashicorp/aws v5.86.0 (signed by HashiCorp)

Terraform has been successfully initialized!

Seeing an error during init?

If you see a message about missing required variables, check that your providers.tf file does not reference any variables that aren't defined in variables.tf. The terraform init command should not prompt for variable values.

If you see a network timeout, check your internet connection. Terraform needs to reach registry.terraform.io to download the provider. help me fix my terraform init error

Your Terraform workspace is initialized and ready to provision resources. Next up, you'll build the network and security layers that form the foundation of your multi-tier architecture.

Build the Network and Security Layers

Your Terraform workspace is initialized and ready to accept resource definitions. Now you need the network foundation that every other resource in your architecture depends on.

In this step, you will define the VPC, subnets, security groups, shared storage, and access controls that form the backbone of your multi-tier architecture. Each layer restricts traffic to only what the next layer needs, creating a defense-in-depth approach.

In this step, get ready to:
  • Define the VPC with public subnets across two availability zones and one database subnet, plus an encrypted EFS file system.
  • Create four chained security groups that control traffic flow between the ALB, web instances, database, and EFS.
  • Set up an IAM role with least-privilege SSM access and store the encryption key as a SecureString parameter.
Define the VPC and EFS file system

Every AWS resource needs a network to live in. The VPC defines the IP address space, subnets divide it into tiers, and the internet gateway provides a path to the outside world.

  • Create a new file called vpc.tf in your project directory and add the following code:
data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = { Name = "secure-multi-tier-vpc" }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "secure-multi-tier-igw" }
}

resource "aws_subnet" "web" {
  count                   = 2
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, count.index)
  availability_zone       = data.aws_availability_zones.available.names[count.index]
  map_public_ip_on_launch = true

  tags = { Name = "web-subnet-${count.index}" }
}

resource "aws_subnet" "db" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, 10)
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = true

  tags = { Name = "db-subnet" }
}

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 = "public-rt" }
}

resource "aws_route_table_association" "web" {
  count          = 2
  subnet_id      = aws_subnet.web[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "db" {
  subnet_id      = aws_subnet.db.id
  route_table_id = aws_route_table.public.id
}

What does this code do?

The data block queries AWS for available availability zones so subnets are placed dynamically. The VPC gets the full 10.0.0.0/16 address space with DNS enabled for internal name resolution.

Two web subnets (10.0.0.0/24 and 10.0.1.0/24) span two AZs for high availability. The DB subnet (10.0.10.0/24) sits in a single AZ. The route table sends all outbound traffic through the internet gateway.

  • Save vpc.tf.
  • Now create a new file called efs.tf and add the following code to define the shared file system:
resource "aws_efs_file_system" "app" {
  creation_token = "secure-app-efs"
  encrypted      = true

  tags = { Name = "secure-app-efs" }
}

resource "aws_efs_mount_target" "web" {
  count           = 2
  file_system_id  = aws_efs_file_system.app.id
  subnet_id       = aws_subnet.web[count.index].id
  security_groups = [aws_security_group.efs.id]
}

What does this code do?

Amazon EFS provides a shared file system that multiple EC2 instances can mount simultaneously. Your web instances will share application files through this mount.

Setting encrypted = true enables encryption at rest using the default AWS-managed KMS key. The two mount targets place access points in each web subnet so instances in either AZ can reach the file system.

  • Save efs.tf.
  • Confirm both files exist by running:
ls vpc.tf efs.tf

You should see both filenames listed in the output with no errors.

Don't see one of the files?

Make sure you created the files inside your project directory (the same folder where providers.tf lives). If ls reports a file not found, create the missing file and paste the code again.

Help me troubleshoot missing Terraform files in my project directory.

✔️ Awesome, I've got everything!

Great. Double check you have saved vpc.tf and move on.

ⓧ I'd like to double check the full code

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

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = { Name = "secure-multi-tier-vpc" }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "secure-multi-tier-igw" }
}

resource "aws_subnet" "web" {
  count                   = 2
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, count.index)
  availability_zone       = data.aws_availability_zones.available.names[count.index]
  map_public_ip_on_launch = true

  tags = { Name = "web-subnet-${count.index}" }
}

resource "aws_subnet" "db" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, 10)
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = true

  tags = { Name = "db-subnet" }
}

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 = "public-rt" }
}

resource "aws_route_table_association" "web" {
  count          = 2
  subnet_id      = aws_subnet.web[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "db" {
  subnet_id      = aws_subnet.db.id
  route_table_id = aws_route_table.public.id
}

✔️ Awesome, I've got everything!

Great. Double check you have saved efs.tf and move on.

ⓧ I'd like to double check the full code

resource "aws_efs_file_system" "app" {
  creation_token = "secure-app-efs"
  encrypted      = true

  tags = { Name = "secure-app-efs" }
}

resource "aws_efs_mount_target" "web" {
  count           = 2
  file_system_id  = aws_efs_file_system.app.id
  subnet_id       = aws_subnet.web[count.index].id
  security_groups = [aws_security_group.efs.id]
}
Create the chained security groups

Security groups act as virtual firewalls for your resources. In this architecture, they form a chain where each group only accepts traffic from the group before it.

  • Create a new file called security_groups.tf and add the following code:
resource "aws_security_group" "alb" {
  name_prefix = "alb-sg-"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = "alb-sg" }
}

resource "aws_security_group" "web" {
  name_prefix = "web-sg-"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = "web-sg" }
}

resource "aws_security_group" "db" {
  name_prefix = "db-sg-"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port       = 3306
    to_port         = 3306
    protocol        = "tcp"
    security_groups = [aws_security_group.web.id]
  }

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = "db-sg" }
}

resource "aws_security_group" "efs" {
  name_prefix = "efs-sg-"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port       = 2049
    to_port         = 2049
    protocol        = "tcp"
    security_groups = [aws_security_group.web.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = "efs-sg" }
}

How does the security group chain work?

Each group references the one before it as its allowed traffic source:

  • ALB-SG accepts HTTP (port 80) from the entire internet. This is the only entry point.
  • Web-SG accepts HTTP only from the ALB-SG, plus SSH (port 22) for debugging.
  • DB-SG accepts MySQL (port 3306) only from the Web-SG. No internet traffic can reach the database directly.
  • EFS-SG accepts NFS (port 2049) only from the Web-SG, keeping the shared storage private to your web tier.
  • Save security_groups.tf.

You will not see a change yet. The next substep adds the IAM and SSM resources, then you will validate everything together.

Seeing a syntax error in your editor?

Check that each ingress and egress block has matching curly braces. A common mistake is forgetting the closing } on a nested block, which causes Terraform to misinterpret the next resource.

Help me find a syntax error in my Terraform security groups file.

✔️ Awesome, I've got everything!

Great. Double check you have saved security_groups.tf and move on.

ⓧ I'd like to double check the full code

resource "aws_security_group" "alb" {
  name_prefix = "alb-sg-"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = "alb-sg" }
}

resource "aws_security_group" "web" {
  name_prefix = "web-sg-"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = "web-sg" }
}

resource "aws_security_group" "db" {
  name_prefix = "db-sg-"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port       = 3306
    to_port         = 3306
    protocol        = "tcp"
    security_groups = [aws_security_group.web.id]
  }

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = "db-sg" }
}

resource "aws_security_group" "efs" {
  name_prefix = "efs-sg-"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port       = 2049
    to_port         = 2049
    protocol        = "tcp"
    security_groups = [aws_security_group.web.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = "efs-sg" }
}
Set up IAM and SSM Parameter Store

Your web instances need permission to retrieve the encryption key from SSM Parameter Store. Following the principle of least privilege, the IAM role grants only the single action needed on the exact parameter path.

  • Create a new file called iam.tf and add the following code:
resource "aws_iam_role" "web" {
  name_prefix = "web-role-"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "ssm_access" {
  name_prefix = "ssm-access-"
  role        = aws_iam_role.web.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["ssm:GetParameter"]
      Resource = aws_ssm_parameter.encryption_key.arn
    }]
  })
}

resource "aws_iam_instance_profile" "web" {
  name_prefix = "web-profile-"
  role        = aws_iam_role.web.name
}

What does this code do?

The IAM role allows EC2 instances to assume it via the assume_role_policy. The inline policy grants exactly one permission: ssm:GetParameter on the specific encryption key ARN.

The instance profile wraps the role so it can be attached to EC2 instances at launch. This means your web servers can decrypt data without hardcoding any keys.

  • Save iam.tf.
  • Create a new file called ssm.tf and add the following code:
resource "aws_ssm_parameter" "encryption_key" {
  name        = "/secure-app/encryption-key"
  description = "Fernet encryption key for application-level data encryption"
  type        = "SecureString"
  value       = var.encryption_key

  tags = { Name = "app-encryption-key" }
}

Why SecureString instead of plain String?

The SecureString type encrypts the parameter value at rest using a KMS key. When key_id is omitted, AWS uses the default aws/ssm managed key at no extra cost.

This means your encryption key is never stored as plaintext in AWS, even though it passes through Terraform state. At runtime, the web instances call GetParameter with WithDecryption=True to retrieve the plaintext value.

  • Save ssm.tf.
  • Validate that Terraform can parse all your new files by running:
terraform plan

You should see output ending with a summary line showing resources to add (marked with a + symbol). Look for the VPC, subnets, security groups, EFS file system, mount targets, IAM role, and SSM parameter in the plan output.

Seeing an error instead of a plan?

If Terraform reports a reference error about aws_security_group.efs not being found, make sure you saved security_groups.tf with all four security group definitions. The efs.tf file references this group.

If you see a variable-related error, confirm your terraform.tfvars file contains values for encryption_key and db_password.

Help me debug my terraform plan error for the network and security layer.

✔️ Awesome, I've got everything!

Great. Double check you have saved iam.tf and move on.

ⓧ I'd like to double check the full code

resource "aws_iam_role" "web" {
  name_prefix = "web-role-"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "ssm_access" {
  name_prefix = "ssm-access-"
  role        = aws_iam_role.web.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["ssm:GetParameter"]
      Resource = aws_ssm_parameter.encryption_key.arn
    }]
  })
}

resource "aws_iam_instance_profile" "web" {
  name_prefix = "web-profile-"
  role        = aws_iam_role.web.name
}

✔️ Awesome, I've got everything!

Great. Double check you have saved ssm.tf and move on.

ⓧ I'd like to double check the full code

resource "aws_ssm_parameter" "encryption_key" {
  name        = "/secure-app/encryption-key"
  description = "Fernet encryption key for application-level data encryption"
  type        = "SecureString"
  value       = var.encryption_key

  tags = { Name = "app-encryption-key" }
}

Your entire network and security foundation is defined. Next up, you will provision the database tier that stores encrypted records and is only accessible from the web subnet.

Provision the Database Tier

Your network and security layers are in place. Every subnet, security group, and IAM role is ready to accept the infrastructure that actually runs your application.

Now you need a database server that stores sensitive data in an encrypted format. The database tier is a dedicated EC2 instance running MariaDB, placed in the restricted DB subnet where only web-tier instances can reach it.

In this step, get ready to:
  • Define the DB EC2 instance with an Amazon Linux 2023 AMI and user data script.
  • Write a bootstrap script that installs MariaDB and inserts Fernet-encrypted sample records.
  • Verify network isolation by confirming the DB security group restricts inbound traffic.
Define the DB instance and MariaDB setup script

The database instance needs two things: a Terraform resource that provisions the EC2 instance, and a shell script that runs automatically when the instance boots to install and configure MariaDB.

  • Create a scripts directory inside your project folder by running:
mkdir scripts
  • Confirm the directory exists by running:
ls

You should see scripts listed alongside your .tf files.

  • Create a new file called db_userdata.sh inside the scripts directory and paste the following code:
#!/bin/bash
dnf update -y
dnf install -y mariadb105-server python3 python3-pip

systemctl start mariadb
systemctl enable mariadb

mysql -u root <<EOF
ALTER USER 'root'@'localhost' IDENTIFIED BY '${db_password}';
CREATE DATABASE secureapp;
CREATE USER 'appuser'@'%' IDENTIFIED BY '${db_password}';
GRANT ALL PRIVILEGES ON secureapp.* TO 'appuser'@'%';
FLUSH PRIVILEGES;

USE secureapp;
CREATE TABLE sensitive_data (
  id INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(100),
  encrypted_value TEXT
);
EOF

What does this script do?

This is a user data script. AWS runs it automatically when the EC2 instance first boots.

  • dnf install -y mariadb105-server python3 python3-pip installs the MariaDB database server and Python (needed later for encryption).
  • systemctl start mariadb starts the database service. systemctl enable mariadb ensures it restarts on reboot.
  • The mysql -u root <<EOF block sets a root password, creates the secureapp database, creates an appuser for the web tier to connect with, and defines the sensitive_data table.
  • ${db_password} is a Terraform template variable. Terraform injects the actual password value at plan/apply time.
  • Save scripts/db_userdata.sh.
Add encrypted sample data to the bootstrap script

The database needs sample records that are encrypted at the application level. This means the data stored in MariaDB is unreadable without the correct Fernet decryption key.

  • In scripts/db_userdata.sh, add the following code below the closing EOF line:
pip3 install cryptography

python3 -c "
from cryptography.fernet import Fernet
import subprocess

key = b'${encryption_key}'
f = Fernet(key)

records = [
    ('Alice Johnson', 'SSN: 123-45-6789'),
    ('Bob Smith', 'Credit Card: 4111-1111-1111-1111'),
    ('Carol White', 'Account: 9876543210'),
]

for name, secret in records:
    encrypted = f.encrypt(secret.encode()).decode()
    sql = f\"INSERT INTO secureapp.sensitive_data (name, encrypted_value) VALUES ('{name}', '{encrypted}');\"    subprocess.run(['mysql', '-u', 'root', '-p${db_password}', '-e', sql])
"

What does the encryption code do?

This Python one-liner uses the cryptography library to encrypt sensitive values before they ever touch the database.

  • ${encryption_key} is injected by Terraform from your terraform.tfvars file. This is the same key stored in SSM Parameter Store.
  • Fernet(key) creates a symmetric encryption object. f.encrypt() produces a URL-safe base64-encoded ciphertext.
  • Three sample records (Alice, Bob, Carol) each have their sensitive data encrypted before insertion. The database only ever stores the encrypted blob.

Why encrypt at the application level?

Database-level encryption (like RDS encryption at rest) protects data if someone steals the physical disk. Application-level encryption goes further. Even if someone gains direct SQL access to the database, they see meaningless ciphertext without the decryption key.

Your web tier will retrieve that key from SSM Parameter Store at runtime, ensuring only authorized requests can decrypt the data.

  • Save scripts/db_userdata.sh.

Now you need the Terraform configuration that provisions the EC2 instance and wires up this script.

  • Create a new file called db.tf in your project root and paste the following code:
data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-2023.*-x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

resource "aws_instance" "db" {
  ami                    = data.aws_ami.al2023.id
  instance_type          = "t2.micro"
  subnet_id              = aws_subnet.db.id
  vpc_security_group_ids = [aws_security_group.db.id]

  user_data = base64encode(templatefile("${path.module}/scripts/db_userdata.sh", {
    db_password    = var.db_password
    encryption_key = var.encryption_key
  }))

  tags = { Name = "mariadb-server" }
}

What does this Terraform configuration do?

  • data "aws_ami" "al2023" looks up the latest Amazon Linux 2023 AMI automatically. You never need to hardcode an AMI ID.
  • subnet_id = aws_subnet.db.id places the instance in the dedicated DB subnet you created in Step 2.
  • vpc_security_group_ids = [aws_security_group.db.id] attaches the DB security group, restricting inbound traffic to MySQL from web instances only.
  • templatefile() reads scripts/db_userdata.sh and substitutes ${db_password} and ${encryption_key} with the actual variable values. base64encode() wraps the result for EC2 user data.
  • Save db.tf.

Seeing a template syntax error?

  • Make sure the scripts/db_userdata.sh file is saved inside the scripts directory at the same level as your .tf files.
  • Confirm the path matches exactly: ${path.module}/scripts/db_userdata.sh.

Help me debug a templatefile path error in Terraform.

Verify network isolation with terraform plan

The DB security group you created in Step 2 only allows inbound MySQL traffic (port 3306) from the Web security group. This means no other resource in your VPC can reach the database directly.

  • Confirm the DB instance configuration is correct by running:
terraform plan

Terraform will prompt you for encryption_key and db_password if you have not created a terraform.tfvars file yet. Enter any placeholder values for now.

What should I see?

In the plan output, look for aws_instance.db showing:

  • instance_type set to t2.micro.
  • subnet_id referencing aws_subnet.db.
  • vpc_security_group_ids referencing aws_security_group.db.
  • user_data shown as a base64 value (known after apply).

You should also see data.aws_ami.al2023 listed as a data source that will be read during apply.

Plan showing errors?

  • If you see a file-not-found error, verify that scripts/db_userdata.sh exists relative to your .tf files.
  • If you see a variable error, make sure db_password and encryption_key are defined in variables.tf (they should be from Step 1).

Help me fix terraform plan errors for my DB instance.

✔️ Awesome, I've got everything!

Great. Double check you have saved both db.tf and scripts/db_userdata.sh.

ⓧ I'd like to double check the full code

Here is the complete db.tf:

data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-2023.*-x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

resource "aws_instance" "db" {
  ami                    = data.aws_ami.al2023.id
  instance_type          = "t2.micro"
  subnet_id              = aws_subnet.db.id
  vpc_security_group_ids = [aws_security_group.db.id]

  user_data = base64encode(templatefile("${path.module}/scripts/db_userdata.sh", {
    db_password    = var.db_password
    encryption_key = var.encryption_key
  }))

  tags = { Name = "mariadb-server" }
}

Here is the complete scripts/db_userdata.sh:

#!/bin/bash
dnf update -y
dnf install -y mariadb105-server python3 python3-pip

systemctl start mariadb
systemctl enable mariadb

mysql -u root <<EOF
ALTER USER 'root'@'localhost' IDENTIFIED BY '${db_password}';
CREATE DATABASE secureapp;
CREATE USER 'appuser'@'%' IDENTIFIED BY '${db_password}';
GRANT ALL PRIVILEGES ON secureapp.* TO 'appuser'@'%';
FLUSH PRIVILEGES;

USE secureapp;
CREATE TABLE sensitive_data (
  id INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(100),
  encrypted_value TEXT
);
EOF

pip3 install cryptography

python3 -c "
from cryptography.fernet import Fernet
import subprocess

key = b'${encryption_key}'
f = Fernet(key)

records = [
    ('Alice Johnson', 'SSN: 123-45-6789'),
    ('Bob Smith', 'Credit Card: 4111-1111-1111-1111'),
    ('Carol White', 'Account: 9876543210'),
]

for name, secret in records:
    encrypted = f.encrypt(secret.encode()).decode()
    sql = f\"INSERT INTO secureapp.sensitive_data (name, encrypted_value) VALUES ('{name}', '{encrypted}');\"    subprocess.run(['mysql', '-u', 'root', '-p${db_password}', '-e', sql])
"

Your database tier is defined and ready. Next up, you will deploy the web tier with Auto Scaling, an Application Load Balancer, and shared EFS storage that connects everything together.

Deploy the Web Tier with Auto Scaling and EFS

Your database tier is provisioned and ready to accept connections from the web tier. Now you need the front door that users actually interact with.

In this step, you will create an Application Load Balancer that distributes traffic, a user data script that bootstraps your Flask API on each instance, and an Auto Scaling Group that keeps the right number of web servers running behind the load balancer.

In this step, get ready to:
  • Create an Application Load Balancer with a target group and HTTP listener.
  • Write a user data script that installs Flask, mounts EFS, and starts the API.
  • Define a Launch Template and Auto Scaling Group to manage web instances.
Define the Application Load Balancer

The ALB sits between the internet and your web servers. It accepts HTTP requests on port 80 and forwards them to healthy instances registered in a target group.

  • Create a new file called alb.tf in your project directory and add the following code:
resource "aws_lb" "web" {
  name               = "secure-app-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = aws_subnet.web[*].id

  tags = { Name = "secure-app-alb" }
}

resource "aws_lb_target_group" "web" {
  name     = "secure-app-tg"
  port     = 80
  protocol = "HTTP"
  vpc_id   = aws_vpc.main.id

  health_check {
    path                = "/health"
    healthy_threshold   = 2
    unhealthy_threshold = 3
    interval            = 30
  }

  tags = { Name = "secure-app-tg" }
}

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.web.arn
  port              = 80
  protocol          = "HTTP"

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

What does this code do?

  • aws_lb "web" creates an internet-facing Application Load Balancer in both web subnets, protected by the ALB security group you defined earlier.
  • aws_lb_target_group "web" defines where traffic goes. The health check pings /health every 30 seconds. An instance needs 2 consecutive successes to be healthy, 3 failures to be unhealthy.
  • aws_lb_listener "http" listens on port 80 and forwards all requests to the target group.
  • Save alb.tf.

You won't see a change yet. The ALB will be verified alongside the other resources when you run terraform plan at the end of this step.

Seeing a reference error on save?

If your editor shows an error about aws_security_group.alb or aws_subnet.web not existing, confirm that security_groups.tf and vpc.tf are saved in the same directory. Terraform resolves references across all .tf files in the working directory.

Help me debug a reference error in my alb.tf file

Write the web user data script

Each web instance needs to bootstrap itself when it launches. The user data script installs dependencies, mounts the shared EFS volume, writes the Flask application, and starts serving on port 80.

  • Create the file scripts/web_userdata.sh in your project directory (the scripts folder already exists from the previous step) and add the following code:
#!/bin/bash
dnf update -y
dnf install -y python3 python3-pip amazon-efs-utils

mkdir -p /app
mount -t efs -o tls ${efs_id}:/ /app
echo "${efs_id}:/ /app efs _netdev,tls 0 0" >> /etc/fstab

pip3 install flask cryptography pymysql boto3

What does this setup section do?

  • amazon-efs-utils provides the EFS mount helper that handles TLS encryption in transit.
  • mount -t efs -o tls mounts the shared EFS file system at /app with encryption. The fstab entry ensures it remounts after a reboot.
  • The ${efs_id} placeholder is a Terraform template variable that gets replaced with the actual EFS file system ID at plan time.
  • Continue adding the Flask application and startup commands below the pip3 install line:
cat > /app/app.py << 'APPEOF'
import os
import json
import boto3
import pymysql
from flask import Flask, request, jsonify
from cryptography.fernet import Fernet

app = Flask(__name__)

def get_encryption_key():
    ssm = boto3.client('ssm', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
    response = ssm.get_parameter(
        Name='/secure-app/encryption-key',
        WithDecryption=True
    )
    return response['Parameter']['Value'].encode()

def get_db_connection():
    return pymysql.connect(
        host=os.environ['DB_HOST'],
        user='appuser',
        password=os.environ['DB_PASSWORD'],
        database='secureapp',
        cursorclass=pymysql.cursors.DictCursor
    )

@app.route('/health')
def health():
    return jsonify({"status": "healthy"}), 200

@app.route('/data')
def get_data():
    auth_header = request.headers.get('Authorization', '')
    is_authorized = auth_header == 'Bearer secrettoken123'

    conn = get_db_connection()
    try:
        with conn.cursor() as cursor:
            cursor.execute("SELECT id, name, encrypted_value FROM sensitive_data")
            rows = cursor.fetchall()
    finally:
        conn.close()

    if is_authorized:
        key = get_encryption_key()
        f = Fernet(key)
        for row in rows:
            row['decrypted_value'] = f.decrypt(row['encrypted_value'].encode()).decode()
            del row['encrypted_value']
        return jsonify({"authorized": True, "data": rows}), 200
    else:
        return jsonify({"authorized": False, "data": rows}), 200

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80)
APPEOF

export DB_HOST="${db_host}"
export DB_PASSWORD="${db_password}"
export AWS_REGION="${aws_region}"

cd /app
nohup python3 app.py &

What does the Flask application do?

The app exposes two endpoints. /health returns a simple JSON response so the ALB can check instance health.

/data is the core endpoint. It queries the MariaDB database for encrypted records. If the request includes Authorization: Bearer secrettoken123, the app fetches the Fernet key from SSM Parameter Store and decrypts the values before returning them. Without the token, the raw encrypted blobs are returned instead.

The get_encryption_key() function uses the IAM instance profile you created earlier to authenticate with SSM. No credentials are hardcoded on the instance.

  • Save scripts/web_userdata.sh.

You won't see a change yet. This script runs inside EC2 instances at launch time, not on your local machine.

Worried about the heredoc syntax?

The << 'APPEOF' syntax writes everything until the closing APPEOF into the file. The single quotes around APPEOF prevent the shell from expanding variables inside the Python code. Terraform template variables like ${db_host} outside the heredoc still get substituted at plan time.

Help me understand the heredoc and Terraform template syntax in my user data script

✔️ Awesome, I've got everything!

Great. Double check you have saved scripts/web_userdata.sh with both parts combined into one file.

ⓧ I'd like to double check the full code

#!/bin/bash
dnf update -y
dnf install -y python3 python3-pip amazon-efs-utils

mkdir -p /app
mount -t efs -o tls ${efs_id}:/ /app
echo "${efs_id}:/ /app efs _netdev,tls 0 0" >> /etc/fstab

pip3 install flask cryptography pymysql boto3

cat > /app/app.py << 'APPEOF'
import os
import json
import boto3
import pymysql
from flask import Flask, request, jsonify
from cryptography.fernet import Fernet

app = Flask(__name__)

def get_encryption_key():
    ssm = boto3.client('ssm', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
    response = ssm.get_parameter(
        Name='/secure-app/encryption-key',
        WithDecryption=True
    )
    return response['Parameter']['Value'].encode()

def get_db_connection():
    return pymysql.connect(
        host=os.environ['DB_HOST'],
        user='appuser',
        password=os.environ['DB_PASSWORD'],
        database='secureapp',
        cursorclass=pymysql.cursors.DictCursor
    )

@app.route('/health')
def health():
    return jsonify({"status": "healthy"}), 200

@app.route('/data')
def get_data():
    auth_header = request.headers.get('Authorization', '')
    is_authorized = auth_header == 'Bearer secrettoken123'

    conn = get_db_connection()
    try:
        with conn.cursor() as cursor:
            cursor.execute("SELECT id, name, encrypted_value FROM sensitive_data")
            rows = cursor.fetchall()
    finally:
        conn.close()

    if is_authorized:
        key = get_encryption_key()
        f = Fernet(key)
        for row in rows:
            row['decrypted_value'] = f.decrypt(row['encrypted_value'].encode()).decode()
            del row['encrypted_value']
        return jsonify({"authorized": True, "data": rows}), 200
    else:
        return jsonify({"authorized": False, "data": rows}), 200

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=80)
APPEOF

export DB_HOST="${db_host}"
export DB_PASSWORD="${db_password}"
export AWS_REGION="${aws_region}"

cd /app
nohup python3 app.py &
Create the Launch Template and Auto Scaling Group

The Launch Template tells AWS how to configure each web instance. The Auto Scaling Group uses that template to maintain the desired number of instances and registers them with the ALB target group automatically.

  • Create a new file called asg.tf and add the Launch Template resource:
resource "aws_launch_template" "web" {
  name_prefix   = "secure-web-"
  image_id      = data.aws_ami.al2023.id
  instance_type = "t2.micro"

  iam_instance_profile {
    name = aws_iam_instance_profile.web.name
  }

  vpc_security_group_ids = [aws_security_group.web.id]

  user_data = base64encode(templatefile("${path.module}/scripts/web_userdata.sh", {
    efs_id     = aws_efs_file_system.app.id
    aws_region = var.aws_region
    db_host    = aws_instance.db.private_ip
    db_password = var.db_password
  }))

  tag_specifications {
    resource_type = "instance"
    tags          = { Name = "web-server" }
  }
}

What does the Launch Template do?

  • iam_instance_profile attaches the IAM role you created in the previous step. This gives the instance permission to call SSM GetParameter for the encryption key.
  • templatefile() reads scripts/web_userdata.sh and replaces placeholders like ${efs_id} and ${db_host} with actual resource values at plan time.
  • base64encode() wraps the result because EC2 user data must be base64-encoded.
  • Below the Launch Template resource in the same file, add the Auto Scaling Group:
resource "aws_autoscaling_group" "web" {
  name_prefix         = "secure-web-asg-"
  desired_capacity    = 1
  min_size            = 1
  max_size            = 3
  vpc_zone_identifier = aws_subnet.web[*].id
  target_group_arns   = [aws_lb_target_group.web.arn]

  launch_template {
    id      = aws_launch_template.web.id
    version = "$Latest"
  }

  tag {
    key                 = "Name"
    value               = "web-server"
    propagate_at_launch = true
  }
}

What does the Auto Scaling Group do?

  • desired_capacity = 1 starts with one instance. max_size = 3 allows scaling up to three during high load.
  • vpc_zone_identifier spreads instances across both web subnets in different availability zones for high availability.
  • target_group_arns automatically registers each new instance with the ALB target group. The ALB starts routing traffic to it once the health check passes.
  • version = "$Latest" ensures the ASG always uses the newest version of the launch template when scaling out.
  • Save asg.tf.
  • Verify everything is valid by running:
terraform plan

You should see a plan summary ending with a line like Plan: X to add, 0 to change, 0 to destroy. where X includes your ALB, target group, listener, launch template, and ASG resources.

Seeing a template error?

If Terraform reports an error about scripts/web_userdata.sh, check that the file path matches exactly. The templatefile() function uses ${path.module}/scripts/web_userdata.sh, so the file must be inside a scripts folder in your project root.

If you see a cycle error, confirm that alb.tf and asg.tf are separate files and that aws_lb_target_group.web is referenced correctly in the ASG's target_group_arns argument.

Help me fix a terraform plan error with my Launch Template or ASG configuration

✔️ Awesome, I've got everything!

Great. Double check you have saved both alb.tf and asg.tf with both parts in each file.

ⓧ I'd like to double check the full code

Here is the complete alb.tf:

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

  tags = { Name = "secure-app-alb" }
}

resource "aws_lb_target_group" "web" {
  name     = "secure-app-tg"
  port     = 80
  protocol = "HTTP"
  vpc_id   = aws_vpc.main.id

  health_check {
    path                = "/health"
    healthy_threshold   = 2
    unhealthy_threshold = 3
    interval            = 30
  }

  tags = { Name = "secure-app-tg" }
}

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.web.arn
  port              = 80
  protocol          = "HTTP"

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

Here is the complete asg.tf:

resource "aws_launch_template" "web" {
  name_prefix   = "secure-web-"
  image_id      = data.aws_ami.al2023.id
  instance_type = "t2.micro"

  iam_instance_profile {
    name = aws_iam_instance_profile.web.name
  }

  vpc_security_group_ids = [aws_security_group.web.id]

  user_data = base64encode(templatefile("${path.module}/scripts/web_userdata.sh", {
    efs_id     = aws_efs_file_system.app.id
    aws_region = var.aws_region
    db_host    = aws_instance.db.private_ip
    db_password = var.db_password
  }))

  tag_specifications {
    resource_type = "instance"
    tags          = { Name = "web-server" }
  }
}

resource "aws_autoscaling_group" "web" {
  name_prefix         = "secure-web-asg-"
  desired_capacity    = 1
  min_size            = 1
  max_size            = 3
  vpc_zone_identifier = aws_subnet.web[*].id
  target_group_arns   = [aws_lb_target_group.web.arn]

  launch_template {
    id      = aws_launch_template.web.id
    version = "$Latest"
  }

  tag {
    key                 = "Name"
    value               = "web-server"
    propagate_at_launch = true
  }
}

Your entire web tier is now defined in code. Next up, you will deploy the full infrastructure and test the encryption flow end-to-end.

Deploy and Test the Encryption Flow

Your entire Terraform codebase is complete. Every resource is defined, security groups are chained, and the Flask application knows how to fetch its encryption key from SSM Parameter Store.

Now it is time to bring the infrastructure to life with a single command and prove the encryption flow works. You will verify that unauthorized requests only see encrypted blobs, while authorized requests receive decrypted plaintext.

In this step, get ready to:
  • Run terraform apply to provision the entire multi-tier architecture.
  • Test the API with and without an authorization token to observe the encryption behavior.
  • Verify that EFS sharing and SSM key retrieval are working correctly.
Run terraform apply to provision the infrastructure

This single command creates every AWS resource you defined across all your configuration files. Terraform calculates the dependency graph and provisions resources in the correct order.

  • Deploy all resources by running this command:
terraform apply

What happens when you run this?

Terraform displays a plan showing every resource it will create. It then asks for confirmation with the prompt: Do you want to perform these actions?. Only typing yes and pressing Enter will start the provisioning.

The apply creates resources in dependency order: VPC first, then subnets and security groups, then EFS and IAM, then the database instance, and finally the ALB and Auto Scaling Group.

  • Type yes when Terraform asks for confirmation.
  • Wait for the apply to complete. This takes approximately 3-5 minutes as AWS provisions the VPC, EC2 instances, ALB, and all supporting resources.

When the apply finishes, you should see a message like Apply complete! Resources: X added, 0 changed, 0 destroyed. followed by the outputs you defined.

  • Copy the alb_dns_name value from the Terraform output. You will use this to test your API in the next substep.

Apply failed with an error?

If you see an error about invalid variable values, double check that your terraform.tfvars file has valid values for encryption_key and db_password. The encryption key must be a valid Fernet key (32 url-safe base64-encoded bytes).

If you see a quota or limit error, verify your AWS account is in good standing and you have not exceeded free tier EC2 instance limits in us-east-1.

Help me debug my terraform apply error

Test the API with authorized and unauthorized requests

The ALB needs a few minutes after the apply completes for the web instance to finish its user data bootstrap and pass the health check. This is normal.

  • Wait 3-4 minutes after the apply completes for the target group to report a healthy instance.
  • Test an unauthorized request (no token) by running this command:
curl http://[[ALB_DNS="your ALB DNS name from terraform output"]]/data

What should I see?

The response should show "authorized": false with a data array containing three records. Each record has a name field in plaintext and an encrypted_value field showing a long base64-encoded Fernet token. The sensitive data (SSNs, credit card numbers) is unreadable.

  • Now test an authorized request by running this command with the Bearer token:
curl -H "Authorization: Bearer secrettoken123" http://[[ALB_DNS="your ALB DNS name from terraform output"]]/data

What should I see now?

The response should show "authorized": true with the same three records. This time, instead of encrypted_value, each record has a decrypted_value field showing the original plaintext data: Alice Johnson's SSN, Bob Smith's credit card number, and Carol White's account number.

This proves the full encryption flow works. The Flask app retrieved the Fernet key from SSM Parameter Store, used it to decrypt the data stored in MariaDB, and returned the plaintext only because the correct Bearer token was provided.

Getting a connection error or empty response?

If curl returns a connection refused or timeout error, the web instance may still be bootstrapping. Wait another 2-3 minutes and try again.

If you get a 502 Bad Gateway response, the ALB can reach the instance but the Flask app is not running yet. The user data script installs several packages before starting the app.

My curl request to the ALB is failing

Verify EFS sharing and SSM key retrieval

The successful authorized response already proves two critical things about your architecture. Let's confirm exactly what is happening behind the scenes.

How do I know EFS and SSM are working?

The Flask app at /app/app.py lives on the EFS mount. If EFS was not mounted correctly, the web instance would have no application code and the health check would fail. The fact that /health returns 200 confirms the EFS mount is active.

The decryption working proves the web instance successfully called ssm:GetParameter on /secure-app/encryption-key with WithDecryption=True. The IAM instance profile granted exactly this permission and no more. If the key were hardcoded instead of fetched from SSM, the IAM policy would be unnecessary.

  • Verify the health endpoint is responding by running:
curl http://[[ALB_DNS="your ALB DNS name from terraform output"]]/health

You should see {"status": "healthy"} confirming the Flask app is running from the EFS-mounted /app directory.

Why this architecture matters

Your security chain works end-to-end. The ALB accepts public traffic and forwards it only to the web security group. The web instances can reach the database on port 3306 only. The encryption key never touches disk on any instance. It is stored as a SecureString in SSM, encrypted by the default aws/ssm KMS managed key, and retrieved at runtime only when an authorized request arrives.

If the Auto Scaling Group launches a new instance, that instance automatically mounts the same EFS share and has the same IAM permissions. It can immediately serve decrypted data without any manual configuration.

Secret mission

Add Auto Scaling and Stress Test

Your architecture handles authorization and encryption, but can it handle load? In this secret mission, you'll add a target-tracking scaling policy to your Auto Scaling Group and stress-test a web instance to watch the ASG spin up new instances that automatically mount EFS and serve decrypted data.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project provisions AWS resources that incur charges if left running. The Application Load Balancer alone costs approximately $0.02 per hour.

Cost warning

The ALB, EC2 instances, and EFS mount targets in this project will generate hourly charges if left running.

If you are not actively using the infrastructure, run terraform destroy as soon as you finish testing. Leaving resources running overnight could cost several dollars.

Resources you used:

  • AWS VPC with 3 subnets, internet gateway, and route table.
  • 4 security groups (ALB, Web, DB, EFS).
  • Application Load Balancer with target group and listener.
  • Auto Scaling Group with Launch Template and scaling policy.
  • 2+ EC2 instances (web servers and MariaDB).
  • EFS file system with 2 mount targets.
  • IAM role, instance profile, and policy.
  • SSM Parameter Store SecureString.
  • Local Terraform project directory on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively testing the encryption flow or experimenting with the Auto Scaling configuration.

  • Your ALB, EC2 instances, and EFS will continue running and incurring charges.
  • Monitor your AWS billing dashboard to track costs.
  • When you are done, return here and follow the Delete tab instructions.

Pause - I'll come back to this later

The ALB cannot be paused or stopped. There is no way to suspend charges without deleting the infrastructure.

  • Your only option to stop charges is to destroy all resources using terraform destroy.
  • Your Terraform configuration files remain on your Desktop. You can re-deploy at any time by running terraform apply again.
  • Follow the Delete tab to destroy the AWS resources while keeping your local files intact.

Delete - I don't want to use this again

Remove all AWS resources and local project files to start completely fresh.

  • Navigate to your Terraform project directory and run the destroy command:
cd ~/Desktop/secure-multi-tier
terraform destroy
  • When Terraform prompts you to confirm, type yes and press Enter.
  • Wait for Terraform to finish destroying all resources. This may take a few minutes as the ALB and ASG drain connections.
  • Verify in the AWS Console that no resources remain. Check the EC2 dashboard for running instances, the VPC dashboard for your VPC, and the EFS console for file systems.
  • Delete the local project folder by running:
cd ~/Desktop
rm -rf secure-multi-tier

Terraform destroy failed?

If terraform destroy fails with a dependency error, run it again. Some resources take time to fully deregister before dependent resources can be deleted.

If it still fails, manually delete resources in the AWS Console starting with EC2 instances, then the ALB, then the VPC.

Help me troubleshoot a failed terraform destroy

Nice Work!

Nice Work!

Nice work! You just built and deployed a fully automated, security-layered multi-tier architecture on AWS using Terraform.

You've learned how to:

  • Provision a complete multi-tier AWS architecture as Infrastructure as Code with Terraform, deploying and destroying everything in a single command.
  • Implement application-level encryption with keys stored securely in SSM Parameter Store as a SecureString, where authorization determines whether users see decrypted data or encrypted blobs.
  • Configure 4 layered security groups enforcing strict traffic flow from Internet to ALB to Web to DB/EFS, combined with IAM least-privilege roles scoped to a single SSM parameter.
  • Secret Mission: Added a target-tracking Auto Scaling policy, stress-tested the infrastructure, and watched the architecture scale dynamically while new instances automatically mounted EFS and served decrypted data.

Ready to quiz yourself?