Home

Terraform + AWS Lambda — modular IaC deployment

Why bother

AWS Lambda is a staple of serverless architectures, but provisioning each function by hand — uploading zips, wiring up IAM, syncing CloudWatch — gets old fast. Terraform flips that around: you describe the function (and its IAM, environment, packaging) in code, and a single apply does the rest.

This post walks through a small reusable Terraform module for Lambda, with the packaging, IAM role, and policies wrapped up so each new function is just a few lines of HCL.

Working example: github.com/linuxpedi/terraform-aws-lambda

Why Terraform here

Project layout

bash
.├── lambda_functions/   └── linuxpedi/       └── lambda_function.py     # the actual Lambda code├── modules/   └── lambda/                    # reusable Lambda module       ├── main.tf                # function + zip + cleanup       ├── iam.tf                 # IAM role + policy       ├── variables.tf           # module inputs       └── outputs.tf             # module outputs├── main.tf                        # one block per function└── provider.tf                    # AWS provider config

The module

modules/lambda/main.tf

hcl
data "archive_file" "lambda_zip" {  type        = "zip"  source_dir  = var.source_dir  output_path = "${path.root}/lambda_function_payload.zip"}resource "aws_lambda_function" "this" {  filename         = data.archive_file.lambda_zip.output_path  function_name    = var.function_name  role             = aws_iam_role.lambda_exec_role.arn  handler          = var.handler  runtime          = var.runtime  source_code_hash = filebase64sha256(data.archive_file.lambda_zip.output_path)  environment {    variables = var.environment_variables  }}resource "null_resource" "cleanup" {  provisioner "local-exec" {    command = "rm -f ${path.root}/lambda_function_payload.zip"  }  depends_on = [aws_lambda_function.this]}

What's happening:

modules/lambda/iam.tf

Lambda needs an IAM role to execute, plus a policy granting at least the right to write to CloudWatch Logs.

hcl
resource "aws_iam_role" "lambda_exec_role" {  name = "${var.function_name}_exec_role"  assume_role_policy = jsonencode({    Version = "2012-10-17"    Statement = [      {        Action = "sts:AssumeRole"        Principal = {          Service = "lambda.amazonaws.com"        }        Effect = "Allow"        Sid    = ""      },    ]  })}resource "aws_iam_role_policy" "lambda_exec_policy" {  name = "${var.function_name}_exec_policy"  role = aws_iam_role.lambda_exec_role.id  policy = jsonencode({    Version = "2012-10-17"    Statement = [      {        Effect = "Allow"        Action = [          "logs:CreateLogGroup",          "logs:CreateLogStream",          "logs:PutLogEvents"        ]        Resource = "*"      },    ]  })}

The assume_role_policy is a trust policy — it says "Lambda is allowed to assume this role." The inline policy then grants the actual permissions the role can use. For real workloads you'd attach more policies here (S3 access, SQS, DynamoDB, etc.) — keep them per-function rather than one mega-role.

modules/lambda/variables.tf

hcl
variable "function_name" {  description = "The name of the Lambda function"  type        = string}variable "handler" {  description = "The handler for the Lambda function"  type        = string}variable "runtime" {  description = "The runtime for the Lambda function"  type        = string}variable "source_dir" {  description = "The source directory of the Lambda function code"  type        = string}variable "environment_variables" {  description = "Environment variables for the Lambda function"  type        = map(string)  default     = {}}

modules/lambda/outputs.tf

hcl
output "lambda_function_name" {  description = "The name of the Lambda function"  value       = aws_lambda_function.this.function_name}output "lambda_function_arn" {  description = "The ARN of the Lambda function"  value       = aws_lambda_function.this.arn}output "lambda_function_invoke_arn" {  description = "The invoke ARN of the Lambda function"  value       = aws_lambda_function.this.invoke_arn}

Outputs make the module composable — you can chain the function ARN into an API Gateway, an EventBridge rule, or whatever else lives downstream.

Using the module

main.tf

hcl
module "linuxpedi" {  source                = "./modules/lambda"  function_name         = "linuxpedi"  handler               = "lambda_function.lambda_handler"  runtime               = "python3.9"  source_dir            = "${path.module}/lambda_functions/linuxpedi"  environment_variables = {    foo = "bar"  }}# Adding another function is just another module block:# module "hello_world" {#   source        = "./modules/lambda"#   function_name = "hello_world"#   handler       = "lambda_function.lambda_handler"#   runtime       = "python3.9"#   source_dir    = "${path.module}/lambda_functions/hello_world"# }

That's the whole point of putting Lambda in a module — adding a new function is six lines.

Run it

Clone the example:

bash
git clone https://github.com/linuxpedi/terraform-aws-lambdacd terraform-aws-lambda

terraform init

Pulls down the AWS, archive, and null providers.

bash
 terraform initInitializing modules...Initializing the backend...Initializing provider plugins......Terraform has been successfully initialized!

terraform plan

Shows what's about to change. Look for + create (new), ~ update in-place, - destroy — anything you didn't expect should make you stop and double-check.

bash
 terraform plan...Plan: 4 to add, 0 to change, 0 to destroy.Changes to Outputs:  + lambda_function_arn        = (known after apply)  + lambda_function_invoke_arn = (known after apply)  + lambda_function_name       = "linuxpedi"

terraform apply

Applies the plan after a confirmation prompt.

bash
 terraform apply...Plan: 4 to add, 0 to change, 0 to destroy.Do you want to perform these actions?  Enter a value: yesmodule.linuxpedi.aws_iam_role.lambda_exec_role: Creating...module.linuxpedi.aws_iam_role.lambda_exec_role: Creation complete after 1smodule.linuxpedi.aws_iam_role_policy.lambda_exec_policy: Creating...module.linuxpedi.aws_lambda_function.this: Creating...module.linuxpedi.aws_lambda_function.this: Creation complete after 16smodule.linuxpedi.null_resource.cleanup: Creating...Apply complete! Resources: 4 added, 0 changed, 0 destroyed.Outputs:lambda_function_arn        = "arn:aws:lambda:us-west-1:xxxxxxxxxxxx:function:linuxpedi"lambda_function_invoke_arn = "arn:aws:apigateway:us-west-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-west-1:xxxxxxxxxxxx:function:linuxpedi/invocations"lambda_function_name       = "linuxpedi"

The function shows up in the AWS console under Lambda:

Environment variables are populated:

You can fire a test event right from the console:

Wrapping up

Modular Terraform makes Lambda — and the IAM that goes with it — far less painful:

Where to take it from here