Quick take — A reusable Terraform module for aws_lambda_function on hashicorp/aws ~> 5.0: ZIP or container packaging, scoped IAM execution role, CloudWatch log retention, env vars, VPC and DLQ support. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "aws" {
region = "us-east-1"
}
module "lambda" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-lambda?ref=v1.0.0"
function_name = "..." # Function name; derives role (`-exec`) and log group nam…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
AWS Lambda runs your code without you managing servers: you hand AWS a deployment package (a ZIP of your handler or a container image) plus a runtime, and it scales invocations from zero to thousands of concurrent executions, billing per millisecond. The core resource is aws_lambda_function, but a function on its own is useless — it cannot even write its own logs without an IAM execution role, and AWS will silently create an unmanaged, never-expiring CloudWatch log group the first time it runs.
This module wraps aws_lambda_function together with the three things every production function needs: an IAM execution role (with the AWS-managed AWSLambdaBasicExecutionRole for logging, plus optional VPC and extra policy attachments), an explicitly-managed CloudWatch log group with a retention period so logs do not accumulate forever, and conditional VPC / dead-letter-queue wiring. Inputs are variable-driven so the same module produces a 128 MB Python utility function or a 3 GB container-image data processor inside a VPC, with consistent naming, tagging, and tracing across every function in your estate.
When to use it
- You are deploying more than one or two Lambda functions and want consistent log retention, tracing, and IAM scoping instead of hand-rolling a role and log group each time.
- You want CloudWatch log groups to be managed by Terraform (with a retention policy) rather than auto-created by AWS with infinite retention — a common silent cost leak.
- You need functions that can run either as a ZIP package or as a container image from ECR, selectable per environment without rewriting the module.
- You want optional VPC attachment (for reaching RDS, ElastiCache, or private endpoints) and a dead-letter queue for async failure capture, both toggled by variables.
- You are standing up event-driven workloads (S3 triggers, EventBridge schedules, SQS consumers, API Gateway backends) and want the function defined once and referenced by its ARN/invoke ARN downstream.
Reach for raw resources instead if you have a single throwaway function, or for a heavier framework (SAM, Serverless Framework) if your team prefers an application-centric packaging workflow over Terraform-managed infrastructure.
Module structure
terraform-module-aws-lambda/
├── versions.tf # provider + Terraform version pins
├── main.tf # aws_lambda_function, IAM role, log group, attachments
├── variables.tf # all inputs with validations
└── outputs.tf # arn, invoke_arn, name, role ARN, log group
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# main.tf
locals {
# Lambda always needs basic execution (CloudWatch Logs); add the VPC-managed
# policy only when the function is attached to a VPC.
managed_policy_arns = compact([
"arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
var.vpc_config != null ? "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" : "",
var.tracing_mode != null ? "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess" : "",
])
log_group_name = "/aws/lambda/${var.function_name}"
}
# --- IAM execution role -----------------------------------------------------
data "aws_iam_policy_document" "assume_role" {
statement {
sid = "LambdaAssumeRole"
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role" "this" {
name = "${var.function_name}-exec"
assume_role_policy = data.aws_iam_policy_document.assume_role.json
permissions_boundary = var.permissions_boundary_arn
tags = var.tags
}
resource "aws_iam_role_policy_attachment" "managed" {
for_each = toset(local.managed_policy_arns)
role = aws_iam_role.this.name
policy_arn = each.value
}
# Caller-supplied policy ARNs (e.g. access to a specific S3 bucket / DynamoDB table).
resource "aws_iam_role_policy_attachment" "extra" {
for_each = toset(var.additional_policy_arns)
role = aws_iam_role.this.name
policy_arn = each.value
}
# Inline least-privilege policy generated from caller statements.
resource "aws_iam_role_policy" "inline" {
count = length(var.inline_policy_statements) > 0 ? 1 : 0
name = "${var.function_name}-inline"
role = aws_iam_role.this.id
policy = jsonencode({
Version = "2012-10-17"
Statement = var.inline_policy_statements
})
}
# --- Log group (managed, with retention) ------------------------------------
resource "aws_cloudwatch_log_group" "this" {
name = local.log_group_name
retention_in_days = var.log_retention_days
kms_key_id = var.log_kms_key_arn
tags = var.tags
}
# --- Lambda function --------------------------------------------------------
resource "aws_lambda_function" "this" {
function_name = var.function_name
description = var.description
role = aws_iam_role.this.arn
# Packaging: either a ZIP (local file or S3 object) OR a container image.
package_type = var.package_type
filename = var.package_type == "Zip" ? var.filename : null
source_code_hash = var.package_type == "Zip" ? var.source_code_hash : null
s3_bucket = var.package_type == "Zip" ? var.s3_bucket : null
s3_key = var.package_type == "Zip" ? var.s3_key : null
s3_object_version = var.package_type == "Zip" ? var.s3_object_version : null
image_uri = var.package_type == "Image" ? var.image_uri : null
# Handler/runtime apply to ZIP packages only; null for container images.
handler = var.package_type == "Zip" ? var.handler : null
runtime = var.package_type == "Zip" ? var.runtime : null
architectures = [var.architecture]
memory_size = var.memory_size
timeout = var.timeout
reserved_concurrent_executions = var.reserved_concurrent_executions
dynamic "environment" {
for_each = length(var.environment_variables) > 0 ? [1] : []
content {
variables = var.environment_variables
}
}
dynamic "vpc_config" {
for_each = var.vpc_config != null ? [var.vpc_config] : []
content {
subnet_ids = vpc_config.value.subnet_ids
security_group_ids = vpc_config.value.security_group_ids
}
}
dynamic "dead_letter_config" {
for_each = var.dead_letter_target_arn != null ? [1] : []
content {
target_arn = var.dead_letter_target_arn
}
}
dynamic "tracing_config" {
for_each = var.tracing_mode != null ? [1] : []
content {
mode = var.tracing_mode
}
}
dynamic "ephemeral_storage" {
for_each = var.ephemeral_storage_mb != 512 ? [1] : []
content {
size = var.ephemeral_storage_mb
}
}
tags = var.tags
# Ensure the role can write logs and the log group exists before first invoke.
depends_on = [
aws_iam_role_policy_attachment.managed,
aws_cloudwatch_log_group.this,
]
}
# variables.tf
variable "function_name" {
description = "Name of the Lambda function (also used to derive the role and log group names)."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9-_]{1,64}$", var.function_name))
error_message = "function_name must be 1-64 chars: letters, numbers, hyphens, underscores."
}
}
variable "description" {
description = "Human-readable description of the function."
type = string
default = ""
}
variable "package_type" {
description = "Deployment package type: 'Zip' (handler + runtime) or 'Image' (container from ECR)."
type = string
default = "Zip"
validation {
condition = contains(["Zip", "Image"], var.package_type)
error_message = "package_type must be either 'Zip' or 'Image'."
}
}
# --- ZIP packaging (used when package_type = "Zip") ---
variable "filename" {
description = "Path to a local deployment ZIP. Mutually exclusive with s3_bucket/s3_key."
type = string
default = null
}
variable "source_code_hash" {
description = "Base64 SHA256 of the package, used to trigger redeploys (e.g. filebase64sha256(...))."
type = string
default = null
}
variable "s3_bucket" {
description = "S3 bucket holding the deployment ZIP (alternative to filename)."
type = string
default = null
}
variable "s3_key" {
description = "S3 key of the deployment ZIP."
type = string
default = null
}
variable "s3_object_version" {
description = "Specific S3 object version of the deployment ZIP."
type = string
default = null
}
variable "handler" {
description = "Function entrypoint for ZIP packages, e.g. 'app.handler' or 'main.lambda_handler'."
type = string
default = null
}
variable "runtime" {
description = "Managed runtime for ZIP packages, e.g. python3.12, nodejs20.x, java21, provided.al2023."
type = string
default = null
validation {
condition = var.runtime == null || can(regex(
"^(python3\\.(9|1[0-3])|nodejs(18|20|22)\\.x|java(11|17|21)|dotnet[68]|ruby3\\.[23]|provided\\.al2(023)?|go1\\.x)$",
var.runtime
))
error_message = "runtime must be a currently supported Lambda runtime identifier."
}
}
# --- Container packaging (used when package_type = "Image") ---
variable "image_uri" {
description = "ECR image URI (with tag or digest) when package_type = 'Image'."
type = string
default = null
}
# --- Sizing & concurrency ---
variable "architecture" {
description = "Instruction set: 'x86_64' or 'arm64' (Graviton is cheaper per ms)."
type = string
default = "arm64"
validation {
condition = contains(["x86_64", "arm64"], var.architecture)
error_message = "architecture must be 'x86_64' or 'arm64'."
}
}
variable "memory_size" {
description = "Memory in MB (CPU scales with memory). 128-10240."
type = number
default = 256
validation {
condition = var.memory_size >= 128 && var.memory_size <= 10240
error_message = "memory_size must be between 128 and 10240 MB."
}
}
variable "timeout" {
description = "Maximum execution time in seconds (1-900)."
type = number
default = 30
validation {
condition = var.timeout >= 1 && var.timeout <= 900
error_message = "timeout must be between 1 and 900 seconds."
}
}
variable "ephemeral_storage_mb" {
description = "Size of /tmp in MB (512-10240)."
type = number
default = 512
validation {
condition = var.ephemeral_storage_mb >= 512 && var.ephemeral_storage_mb <= 10240
error_message = "ephemeral_storage_mb must be between 512 and 10240 MB."
}
}
variable "reserved_concurrent_executions" {
description = "Reserved concurrency. -1 leaves it unreserved (shares the account pool)."
type = number
default = -1
}
# --- Runtime configuration ---
variable "environment_variables" {
description = "Map of environment variables passed to the function."
type = map(string)
default = {}
}
variable "tracing_mode" {
description = "X-Ray tracing mode: 'Active', 'PassThrough', or null to disable."
type = string
default = null
validation {
condition = var.tracing_mode == null || contains(["Active", "PassThrough"], var.tracing_mode)
error_message = "tracing_mode must be 'Active', 'PassThrough', or null."
}
}
variable "vpc_config" {
description = "Optional VPC attachment. Set subnet_ids and security_group_ids to run inside a VPC."
type = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
default = null
}
variable "dead_letter_target_arn" {
description = "ARN of an SQS queue or SNS topic for async invocation failures (DLQ)."
type = string
default = null
}
# --- IAM ---
variable "additional_policy_arns" {
description = "Extra managed/customer IAM policy ARNs to attach to the execution role."
type = list(string)
default = []
}
variable "inline_policy_statements" {
description = "List of IAM policy statement objects merged into an inline least-privilege policy."
type = list(any)
default = []
}
variable "permissions_boundary_arn" {
description = "Optional IAM permissions boundary ARN applied to the execution role."
type = string
default = null
}
# --- Logging ---
variable "log_retention_days" {
description = "CloudWatch log retention in days (0 = never expire)."
type = number
default = 14
validation {
condition = contains(
[0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653],
var.log_retention_days
)
error_message = "log_retention_days must be a value accepted by CloudWatch Logs."
}
}
variable "log_kms_key_arn" {
description = "Optional KMS key ARN to encrypt the CloudWatch log group."
type = string
default = null
}
variable "tags" {
description = "Tags applied to the function, role, and log group."
type = map(string)
default = {}
}
# outputs.tf
output "function_arn" {
description = "ARN of the Lambda function."
value = aws_lambda_function.this.arn
}
output "function_name" {
description = "Name of the Lambda function."
value = aws_lambda_function.this.function_name
}
output "invoke_arn" {
description = "Invoke ARN, for API Gateway / Step Functions integrations."
value = aws_lambda_function.this.invoke_arn
}
output "qualified_arn" {
description = "ARN of the published $LATEST version (function ARN + version)."
value = aws_lambda_function.this.qualified_arn
}
output "version" {
description = "Latest published version of the function."
value = aws_lambda_function.this.version
}
output "role_arn" {
description = "ARN of the execution IAM role (attach more policies downstream if needed)."
value = aws_iam_role.this.arn
}
output "role_name" {
description = "Name of the execution IAM role."
value = aws_iam_role.this.name
}
output "log_group_name" {
description = "Name of the managed CloudWatch log group."
value = aws_cloudwatch_log_group.this.name
}
How to use it
A ZIP-packaged Python function that processes objects dropped into an S3 bucket, runs on Graviton, has scoped read access to that bucket, and is wired to an SQS dead-letter queue:
data "archive_file" "processor" {
type = "zip"
source_dir = "${path.module}/src/processor"
output_path = "${path.module}/build/processor.zip"
}
resource "aws_sqs_queue" "dlq" {
name = "image-processor-dlq"
message_retention_seconds = 1209600 # 14 days
}
module "lambda_function" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-lambda?ref=v1.0.0"
function_name = "image-processor"
description = "Resizes images uploaded to the ingest bucket."
package_type = "Zip"
runtime = "python3.12"
handler = "app.handler"
architecture = "arm64"
filename = data.archive_file.processor.output_path
source_code_hash = data.archive_file.processor.output_base64sha256
memory_size = 1024
timeout = 60
ephemeral_storage_mb = 2048
environment_variables = {
OUTPUT_BUCKET = aws_s3_bucket.thumbnails.id
LOG_LEVEL = "INFO"
}
tracing_mode = "Active"
log_retention_days = 30
dead_letter_target_arn = aws_sqs_queue.dlq.arn
# Least-privilege: read source images, write thumbnails.
inline_policy_statements = [
{
Effect = "Allow"
Action = ["s3:GetObject"]
Resource = "${aws_s3_bucket.ingest.arn}/*"
},
{
Effect = "Allow"
Action = ["s3:PutObject"]
Resource = "${aws_s3_bucket.thumbnails.arn}/*"
},
]
tags = {
Environment = "prod"
Team = "media-platform"
}
}
# Downstream: let S3 invoke the function, using the module's function_arn output.
resource "aws_lambda_permission" "allow_s3" {
statement_id = "AllowS3Invoke"
action = "lambda:InvokeFunction"
function_name = module.lambda_function.function_name
principal = "s3.amazonaws.com"
source_arn = aws_s3_bucket.ingest.arn
}
resource "aws_s3_bucket_notification" "ingest" {
bucket = aws_s3_bucket.ingest.id
lambda_function {
lambda_function_arn = module.lambda_function.function_arn
events = ["s3:ObjectCreated:*"]
}
depends_on = [aws_lambda_permission.allow_s3]
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/prod/lambda/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-lambda?ref=v1.0.0"
}
inputs = {
function_name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/lambda && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
| function_name | string | — | yes | Function name; derives role (-exec) and log group names. |
| description | string | "" |
no | Human-readable description. |
| package_type | string | "Zip" |
no | Zip or Image. |
| filename | string | null |
no | Local deployment ZIP path (ZIP packaging). |
| source_code_hash | string | null |
no | Base64 SHA256 of the package to trigger redeploys. |
| s3_bucket | string | null |
no | S3 bucket holding the ZIP (alternative to filename). |
| s3_key | string | null |
no | S3 key of the deployment ZIP. |
| s3_object_version | string | null |
no | Specific S3 object version of the ZIP. |
| handler | string | null |
no | Entrypoint for ZIP packages, e.g. app.handler. |
| runtime | string | null |
no | Managed runtime, e.g. python3.12, nodejs20.x. Validated. |
| image_uri | string | null |
no | ECR image URI when package_type = "Image". |
| architecture | string | "arm64" |
no | x86_64 or arm64. |
| memory_size | number | 256 |
no | Memory in MB (128–10240); CPU scales with it. |
| timeout | number | 30 |
no | Max execution time in seconds (1–900). |
| ephemeral_storage_mb | number | 512 |
no | Size of /tmp in MB (512–10240). |
| reserved_concurrent_executions | number | -1 |
no | Reserved concurrency; -1 = unreserved. |
| environment_variables | map(string) | {} |
no | Environment variables for the function. |
| tracing_mode | string | null |
no | Active, PassThrough, or null (also attaches X-Ray policy). |
| vpc_config | object | null |
no | { subnet_ids, security_group_ids } to run inside a VPC. |
| dead_letter_target_arn | string | null |
no | SQS/SNS ARN for async failure capture (DLQ). |
| additional_policy_arns | list(string) | [] |
no | Extra IAM policy ARNs to attach to the role. |
| inline_policy_statements | list(any) | [] |
no | Statement objects merged into an inline least-privilege policy. |
| permissions_boundary_arn | string | null |
no | IAM permissions boundary for the execution role. |
| log_retention_days | number | 14 |
no | CloudWatch retention (0 = never expire). Validated. |
| log_kms_key_arn | string | null |
no | KMS key ARN to encrypt the log group. |
| tags | map(string) | {} |
no | Tags for function, role, and log group. |
Outputs
| Name | Description |
|---|---|
| function_arn | ARN of the Lambda function. |
| function_name | Name of the Lambda function. |
| invoke_arn | Invoke ARN for API Gateway / Step Functions integrations. |
| qualified_arn | ARN of the published $LATEST version. |
| version | Latest published version of the function. |
| role_arn | ARN of the execution IAM role. |
| role_name | Name of the execution IAM role. |
| log_group_name | Name of the managed CloudWatch log group. |
Enterprise scenario
A logistics company runs an event-driven order pipeline where roughly 40 Lambda functions consume from SQS and EventBridge across dev, staging, and prod accounts. By standardising on this module, the platform team guarantees every function ships with 30-day encrypted log retention, X-Ray tracing, an arm64 (Graviton) runtime to cut compute cost by ~20%, and a per-function inline IAM policy instead of a shared over-privileged role — so the warehouse-sync function can read its DynamoDB table but cannot touch the billing queue. When auditors ask “which functions can write to the payments topic,” the answer is grep-able in Terraform rather than reconstructed from the console.
Best practices
- Manage the log group, set retention. If you let AWS auto-create
/aws/lambda/<name>, it defaults to never expire and bills indefinitely. This module creates the group explicitly withlog_retention_days(and optional KMS) — keep it at 14–30 days for most workloads. - Scope IAM per function. Prefer
inline_policy_statements(oradditional_policy_arns) targeting specific resource ARNs over broad managed policies likeAmazonS3FullAccess. Add apermissions_boundary_arnin regulated accounts so a function can never escalate beyond the boundary. - Default to Graviton (
arm64). It is cheaper per millisecond and usually faster for interpreted runtimes; only fall back tox86_64for binaries or layers without ARM builds. - Right-size memory, then measure. Memory also dictates CPU — under-provisioning a CPU-bound function makes it slower and more expensive. Tune
memory_sizeagainst real billed-duration metrics rather than guessing. - Use reserved concurrency for downstream protection. Set
reserved_concurrent_executionson functions that hit a fragile database or third-party API so a traffic spike cannot exhaust connections or the account-wide concurrency pool. - Always pin the package, and pin the module. Drive redeploys with
source_code_hash(for ZIPs) or an immutable image digest (for containers), and consume the module at a fixed?ref=v1.0.0tag so function infrastructure changes are reviewed, not surprise-applied.