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
- Multi-environment — same config, different
tfvarsfordev/staging/prod. - Versioned infrastructure — diffs in PRs, history in
git log. - Tear-down is
terraform destroy— no orphaned resources cluttering the account.
Project layout
.├── 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 configThe module
modules/lambda/main.tf
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:
archive_filezips the function source fromvar.source_dirintolambda_function_payload.zipat apply time. No more hand-zipping before each deploy.aws_lambda_functionuploads that zip and creates the function with the IAM role we'll define next.source_code_hashmakes Terraform redeploy whenever the source changes — without it, code edits wouldn't trigger an update.null_resource.cleanupremoves the zip after the function is created, just to keep the workspace tidy.depends_onmakes sure cleanup runs after the function is up.
modules/lambda/iam.tf
Lambda needs an IAM role to execute, plus a policy granting at least the right to write to CloudWatch Logs.
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
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
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
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:
git clone https://github.com/linuxpedi/terraform-aws-lambdacd terraform-aws-lambdaterraform init
Pulls down the AWS, archive, and null providers.
❯ 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.
❯ 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.
❯ 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:
- Adding a function is a 6-line module block, not a copy-paste of 50 lines of HCL
- IAM is generated per-function instead of shared across everything
- Environment variables and secrets stay declarative
Where to take it from here
- Workspaces / per-env tfvars — flip between
dev,staging,prodcleanly. - API Gateway — wire the Lambda up to an HTTPS endpoint.
- EventBridge / SQS triggers — make the function react to events instead of HTTP calls.
- Per-function policies — extend
iam.tf(or accept a list ofaws_iam_policy_documentfrom the caller) so functions can have least-privilege access to S3 / DynamoDB / etc. - Layers and runtime versions — the module currently exposes only the bare minimum; add
layers,memory_size,timeout, andvpc_configas variables when you need them. - OpenTofu — same HCL, MPL-licensed fork. Worth keeping on your radar.