DEV Community

Cover image for Build and Deploy a 3 Tier Serverless App on AWS with Terraform Modules
Tanseer for AWS Community Builders

Posted on

Build and Deploy a 3 Tier Serverless App on AWS with Terraform Modules

Introduction

Most tutorials show you how to create a Lambda function by clicking around the AWS console. That works once. Then you need a staging environment, and you click everything again. Then production. Then someone asks "wait, did staging have the 30 second timeout or was that only dev?" and nobody knows, because the infrastructure lives in memory and screenshots.

This post walks through a project where the entire 3 tier stack comes from a single terraform apply. Tier one is the frontend on Amplify, tier two is the API on Lambda and API Gateway, tier three is a MySQL database on RDS. Run the apply in the dev folder, you get a dev environment. Run it in prod, you get prod. Same code, zero clicking.

The app itself is a small todo application. That is on purpose. The app is just a passenger. The real work is the Terraform: reusable modules, three isolated environments, remote state stored in S3, and module outputs wired into module inputs so everything builds in the correct order.

The full source is here: github.com/TanseerS/serverless-web-application

Quick note on the word serverless before we start. It does not mean there are no servers. It means AWS manages the servers and you only bring code. You pay when the code runs, not while it sits idle.

What we are building

Architecture diagram of the serverless web application

The flow is simple to trace. A user opens a React app hosted on AWS Amplify (a service that builds and hosts frontends straight from a GitHub repo). The app calls a REST API on API Gateway (the AWS service that gives your code an HTTPS URL). Every route on that API points to a single Lambda function (code that runs on demand without a server you manage). Inside the Lambda, an Express app handles the routing and talks to a MySQL database on RDS (managed relational databases on AWS).

Deployments are split in two. Push to GitHub and Amplify rebuilds the frontend on its own, since CI/CD comes free with Amplify. Change the backend code or any infrastructure, and terraform apply ships it. Terraform even re-zips the backend folder automatically whenever the source changes.

The repository layout

serverless-web-application/
├── frontend/            # React 18 + Vite app
├── backend/             # Express + Sequelize API, packaged for Lambda
├── assets/              # diagrams and screenshots
└── infrastructure/
    ├── bootstrap/       # run once, creates the S3 state bucket
    ├── modules/
    │   ├── database/    # RDS MySQL, security group, subnet group
    │   ├── backend/     # Lambda, API Gateway, IAM, code packaging
    │   └── frontend/    # Amplify app, branch, IAM
    └── environments/
        ├── dev/
        ├── staging/
        └── prod/
Enter fullscreen mode Exit fullscreen mode

Two ideas make this layout work.

First, modules are reusable building blocks. The database module does not know or care whether it is building dev or prod. It takes a project and an environment variable and names everything ${var.project}-${var.environment}-..., so the same code stamps out todo-app-dev-rds, todo-app-staging-rds and todo-app-prod-rds.

Second, each environment folder is tiny. It looks up the default VPC with data sources, calls the three modules, and passes in values from its own terraform.tfvars file. Promoting a change from dev to prod means changing directory and applying again. You never edit module code to deploy an environment.

Step 1: solve the chicken and egg problem

Terraform keeps a state file, which is its record of everything it has created. By default that file sits on your laptop, which is fine until your laptop is not around or a teammate runs an apply and now there are two versions of the truth. The fix is remote state: store the file in an S3 bucket instead.

But here is the catch. The S3 bucket has to exist before Terraform can store state in it, and we want Terraform to create the bucket. Who creates the creator?

The answer is a small run once setup called the bootstrap pattern. The infrastructure/bootstrap/ folder is a tiny Terraform project with plain local state. Its only job is to create the state bucket. You run it once per AWS account and forget about it.

data "aws_caller_identity" "current" {}

resource "aws_s3_bucket" "this" {
  bucket = "${var.project}-statefile-${data.aws_caller_identity.current.account_id}"
}
Enter fullscreen mode Exit fullscreen mode

Notice the bucket name. S3 bucket names are globally unique across every AWS account on the planet, so todo-app-statefile alone would probably be taken. The aws_caller_identity data source reads your AWS account ID at plan time and appends it, so the name is unique without hardcoding anything.

The bootstrap also turns on three protections for the bucket: versioning (every old copy of your state is kept, which is what saves you when an apply goes wrong), full public access blocking, and AES 256 encryption at rest.

Step 2: remote state with S3 native locking

With the bucket in place, every environment points at it through a backend.tf file. Same bucket, different key per environment:

terraform {
  backend "s3" {
    bucket       = "todo-app-statefile-<account-id>"
    key          = "dev/terraform.tfstate"
    region       = "ap-south-1"
    encrypt      = true
    use_lockfile = true
  }
}
Enter fullscreen mode Exit fullscreen mode

Because dev, staging and prod each write to their own key, an apply in dev can never touch prod. They are fully isolated.

The use_lockfile = true line deserves attention. State locking stops two people from running apply at the same time and corrupting the state. For years the standard answer was a DynamoDB table just for the lock. Terraform 1.10 added native S3 locking: Terraform drops a lock object into the bucket itself while an apply runs. One less resource to create, pay for and explain.

Here is the bucket after all three environments have been initialized, one prefix per state file:

S3 state bucket with dev, staging and prod prefixes, versioning and encryption enabled

Step 3: the database module

The database module creates three things. A DB subnet group that tells RDS which subnets it can live in. A security group (a virtual firewall) allowing inbound MySQL on port 3306. And the RDS instance itself:

resource "aws_db_instance" "this" {
  identifier        = "${var.project}-${var.environment}-rds"
  db_name           = "TodoAppDb"
  engine            = "mysql"
  engine_version    = "8.0"
  allocated_storage = 20
  storage_type      = "gp3"
  instance_class    = "db.t3.micro"

  username = var.username
  password = var.password

  db_subnet_group_name   = aws_db_subnet_group.this.name
  vpc_security_group_ids = [aws_security_group.this.id]

  publicly_accessible = true
  skip_final_snapshot = true
}
Enter fullscreen mode Exit fullscreen mode

MySQL 8.0 on a db.t3.micro with 20 GB of gp3 storage, which keeps it inside the free tier.

One honest admission. The security group currently allows 3306 from 0.0.0.0/0, meaning anywhere on the internet, and the instance is publicly accessible. That is a demo shortcut so the Lambda can reach the database without living inside the VPC. Production would put RDS in private subnets, place the Lambda inside the VPC, and scope the security group to the Lambda alone. The repo says this out loud in its trade offs section rather than pretending it is fine.

The module ends by outputting db_endpoint, db_name and db_port. Remember those. They are about to become someone else's inputs.

Step 4: the backend module, where most of the Terraform lives

This module packages the code, creates the Lambda, and builds the entire REST API. A few pieces are worth slowing down for.

Packaging code without a build script

data "archive_file" "this" {
  type        = "zip"
  source_dir  = "${path.module}/../../../backend"
  output_path = "${path.module}/../../build/function.zip"
}
Enter fullscreen mode Exit fullscreen mode

Terraform zips the backend/ folder at plan time. The Lambda resource then sets source_code_hash = data.archive_file.this.output_base64sha256, which means the function only gets updated when the code actually changed. Edit a Terraform file and apply, the Lambda is untouched. Edit routes/tasks.js and apply, Terraform notices the hash changed and ships the new zip. No separate deploy pipeline for the backend at all.

The Lambda function

resource "aws_lambda_function" "this" {
  function_name    = "${var.project}-${var.environment}-lambda-function"
  handler          = "server.handler"
  runtime          = "nodejs22.x"
  timeout          = 30
  memory_size      = 512

  environment {
    variables = {
      DB_HOST     = var.db_endpoint
      DB_NAME     = var.db_name
      DB_PORT     = var.db_port
      DB_USER     = var.username
      DB_PASSWORD = var.password
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Node.js 22, 512 MB of memory, 30 second timeout. The database connection details arrive as environment variables, and look where they come from: var.db_endpoint is the output of the database module. The environment folder wires them together like this:

module "backend" {
  source      = "../../modules/backend"
  db_endpoint = module.database.db_endpoint
  db_name     = module.database.db_name
  db_port     = module.database.db_port
  ...
}
Enter fullscreen mode Exit fullscreen mode

This is the trick that makes the whole project build in the right order. Terraform sees that the backend needs an output from the database, so it builds the database first. Nobody wrote depends_on for that. The dependency graph falls out of the references themselves.

Eight API routes from one map

The API has four paths (/api, /api/health, /api/tasks, /api/tasks/{id}) and eight method and path combinations once you count the OPTIONS methods needed for CORS preflight. Writing eight method blocks and eight integration blocks by hand would be sixteen nearly identical resources. Instead, the routes live in one locals map:

locals {
  routes = {
    health_get    = { resource_id = aws_api_gateway_resource.health.id,     http_method = "GET" }
    tasks_get     = { resource_id = aws_api_gateway_resource.tasks.id,      http_method = "GET" }
    tasks_post    = { resource_id = aws_api_gateway_resource.tasks.id,      http_method = "POST" }
    task_put      = { resource_id = aws_api_gateway_resource.task_by_id.id, http_method = "PUT" }
    task_delete   = { resource_id = aws_api_gateway_resource.task_by_id.id, http_method = "DELETE" }
    # ...plus three OPTIONS entries
  }
}

resource "aws_api_gateway_method" "this" {
  for_each      = local.routes
  rest_api_id   = aws_api_gateway_rest_api.this.id
  resource_id   = each.value.resource_id
  http_method   = each.value.http_method
  authorization = "NONE"
}
Enter fullscreen mode Exit fullscreen mode

The for_each loops over the map and stamps out one resource per entry. The integrations do the same, every one an AWS_PROXY integration pointing at the Lambda, which means API Gateway forwards the raw request and lets Express figure out the routing inside the function. Adding a new route later means adding one line to the map.

The silent API Gateway gotcha

Here is a bug that costs beginners hours. API Gateway keeps a deployed snapshot of your API separate from its configuration. You can change methods and integrations in Terraform, apply successfully, and API Gateway will keep serving the old snapshot without a single complaint. Everything looks green. Your change is just not live.

The fix is to force a redeploy whenever the routing changes:

resource "aws_api_gateway_deployment" "this" {
  rest_api_id = aws_api_gateway_rest_api.this.id

  triggers = {
    redeploy = sha1(jsonencode([
      aws_api_gateway_method.this,
      aws_api_gateway_integration.this,
    ]))
  }

  lifecycle {
    create_before_destroy = true
  }
}
Enter fullscreen mode Exit fullscreen mode

The trigger is a hash of the routing config. Config changes, hash changes, Terraform creates a fresh deployment. The create_before_destroy lifecycle rule makes sure the new deployment exists before the old one goes away, so the stage never has a gap where the API is down.

Step 5: the frontend module

The Amplify app connects directly to the GitHub repo using a personal access token, with the build instructions written inline:

resource "aws_amplify_app" "this" {
  name         = "${var.project}-${var.environment}-amplify-app"
  repository   = var.repository
  access_token = var.github_access_token

  environment_variables = {
    VITE_API_URL = var.api_uri
  }
}
Enter fullscreen mode Exit fullscreen mode

That VITE_API_URL line is my favorite detail in the project. The value comes from module.backend.api_invoke_url, the output of the backend module. So Terraform creates the API, learns its URL, and hands it to Amplify as a build time environment variable. The React app bakes it in during npm run build. The frontend learns its backend address from Terraform and nothing is ever hardcoded.

The module also adds a rewrite rule so deep links like /tasks/5 fall back to index.html instead of returning 404, which every single page app on Amplify needs, and a branch resource with auto build turned on. The branch stage is set with a conditional: var.environment == "prod" ? "PRODUCTION" : "DEVELOPMENT".

Step 6: the backend code that makes Express work in Lambda

A normal Express app ends with app.listen(3000). There is no port to listen on inside Lambda, so the app is wrapped instead:

const serverlessExpress = require('@vendia/serverless-express');
const app = express();
// routes...
exports.handler = serverlessExpress({ app });
Enter fullscreen mode Exit fullscreen mode

The @vendia/serverless-express package translates the event API Gateway sends into the request object Express expects, and translates the response back. Your Express code does not change at all.

One more pattern worth stealing. The database table has to exist before the first query, but you do not want to create it on every request. The code caches the promise:

let dbReady = null;
app.use('/api/tasks', async (req, res, next) => {
  try {
    dbReady = dbReady || sequelize.sync();
    await dbReady;
    next();
  } catch (err) {
    dbReady = null; // don't cache a failed attempt
    res.status(500).json({ error: err.message });
  }
}, tasksRouter);
Enter fullscreen mode Exit fullscreen mode

The first request in each Lambda container runs sequelize.sync() which creates the tasks table if needed. Every later request awaits the already resolved promise, which costs nothing. And if the sync fails, the cache resets so the next request tries again instead of being stuck with a failed promise forever. There is no manual schema step anywhere in the deployment.

Deploying it yourself

You need an AWS account with credentials configured, Terraform 1.15 or newer, and a GitHub personal access token that can read your fork and create webhooks.

First, bootstrap the state bucket, once per account:

cd infrastructure/bootstrap
terraform init
terraform apply
Enter fullscreen mode Exit fullscreen mode

Second, edit backend.tf in each environment folder and set the bucket name to what the bootstrap printed. Backend blocks cannot take variables, so this one value is a literal.

Third, deploy an environment:

cd infrastructure/environments/dev
cp terraform.tfvars.example terraform.tfvars   # fill in DB password, repo URL, token, branch
terraform init
terraform plan
terraform apply
Enter fullscreen mode Exit fullscreen mode

RDS takes a few minutes to come up. The result is 28 resources from one command:

terraform apply output ending with Apply complete, 28 resources added

Fourth, trigger the first frontend build. Amplify only builds on new commits, so push something to the configured branch after the apply. An empty commit does the job.

Finally, check it is alive:

curl https://<api-id>.execute-api.ap-south-1.amazonaws.com/dev/api/health
# {"status":"ok","database":"connected"}
Enter fullscreen mode Exit fullscreen mode

What actually gets created

Everything below came from the same modules, applied once per environment folder. Three Lambda functions on Node.js 22:

Lambda console showing the dev, staging and prod functions

Three regional REST APIs, each with a stage named after its environment:

API Gateway console showing the three REST APIs

A MySQL instance per environment:

RDS console showing the three MySQL instances

And the Amplify apps, connected to GitHub and building on every push:

Amplify console showing the connected apps

Notice what did not happen. Nobody opened the RDS console to click Create database three times. The staging environment is not "dev, but with some settings someone changed by hand last month." All three are provably identical because they came from identical code.

Honest trade offs

A few shortcuts were taken deliberately, and it is worth knowing what production would change. The open security group and public RDS would become private subnets with the Lambda inside the VPC. Database credentials would move from Lambda environment variables into Secrets Manager. The single AZ db.t3.micro with skip_final_snapshot would become Multi AZ with deletion protection. And the hardcoded bucket name in backend.tf would be handled with partial backend config through terraform init -backend-config.

The roadmap includes a GitHub Actions pipeline that runs terraform fmt, validate and plan on pull requests and applies on merge, plus tflint and checkov for static checks.

Conclusion

The todo app is forgettable. The patterns are not. The bootstrap trick for remote state, S3 native locking without a DynamoDB table, modules whose outputs feed the next module's inputs so the build order takes care of itself, for_each over a map instead of sixteen copy pasted resources, and a hash trigger that stops API Gateway from silently serving stale config. Every one of these shows up in real production Terraform, and this repo is a small enough place to learn them without drowning.

Clone it, deploy dev, then run terraform destroy when you are done exploring. RDS is the only always on cost here, and tearing it down is one command, the same way bringing it up was.

Repo: github.com/TanseerS/serverless-web-application

Contact

Questions, feedback, or stuck on a step? Reach me at khantanseer43@gmail.com. Happy to help.

Top comments (0)