IaC AWS

Terraform Module: AWS Lambda Function — production-ready functions with packaging, logging, and least-privilege IAM

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

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 configlive/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 configlive/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

TerraformAWSLambda FunctionModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading