IaC AWS

Terraform Module: AWS Lambda Layer — share code across functions without copy-paste

Quick take — A reusable Terraform module for aws_lambda_layer_version that packages shared dependencies once, versions them immutably, and exposes the layer ARN to every consuming Lambda function. 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_layer" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-lambda-layer?ref=v1.0.0"

  layer_name = "..."  # Unique name for the layer (1-140 chars: letters, number…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

An AWS Lambda Layer is a .zip archive that holds libraries, a custom runtime, or other shared content. You attach a layer to one or more functions, and its contents are extracted into /opt at runtime. A single function can reference up to five layers, and the unzipped layer content counts toward the 250 MB deployment package limit. The point is to stop shipping the same requests, boto3, pandas, or internal SDK with every function — you build it once, publish a versioned layer, and let every function point at the same ARN.

The catch is that aws_lambda_layer_version is immutable: every change publishes a brand new version number, and old versions are never overwritten. That makes hand-managing layers error-prone — you have to track the S3 object, the SHA256 hash that triggers a new version, the compatible runtimes, the compatible architectures, and the layer permissions if you share it across accounts. This module wraps all of that into one var-driven interface so a layer is reproducible, the hash-based versioning is wired correctly, and the consuming functions always get a clean arn output to reference.

When to use it

Module structure

terraform-module-aws-lambda-layer/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # A new layer version publishes only when the artifact hash changes,
  # so we resolve exactly one source: a local file OR an existing S3 object.
  use_s3 = var.s3_bucket != null && var.s3_key != null
}

resource "aws_lambda_layer_version" "this" {
  layer_name  = var.layer_name
  description = var.description
  license_info = var.license_info

  compatible_runtimes      = var.compatible_runtimes
  compatible_architectures = var.compatible_architectures

  # Local artifact path (mutually exclusive with the S3 inputs).
  filename         = local.use_s3 ? null : var.filename
  source_code_hash = local.use_s3 ? null : var.source_code_hash

  # S3-hosted artifact (preferred for packages > 50 MB).
  s3_bucket         = local.use_s3 ? var.s3_bucket : null
  s3_key            = local.use_s3 ? var.s3_key : null
  s3_object_version = local.use_s3 ? var.s3_object_version : null

  skip_destroy = var.skip_destroy
}

# Optional cross-account / public sharing. One statement per principal.
resource "aws_lambda_layer_version_permission" "this" {
  for_each = var.layer_permissions

  layer_name      = aws_lambda_layer_version.this.layer_name
  version_number  = aws_lambda_layer_version.this.version
  statement_id    = each.key
  action          = "lambda:GetLayerVersion"
  principal       = each.value.principal
  organization_id = each.value.organization_id
}

variables.tf

variable "layer_name" {
  description = "Unique name for the Lambda layer (1-140 chars)."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9-_]{1,140}$", var.layer_name))
    error_message = "layer_name must be 1-140 chars: letters, numbers, hyphens, underscores."
  }
}

variable "description" {
  description = "Human-readable description shown in the console for this layer version."
  type        = string
  default     = null
}

variable "license_info" {
  description = "SPDX license identifier or URL describing the layer's license."
  type        = string
  default     = null
}

variable "filename" {
  description = "Path to the local .zip artifact. Mutually exclusive with s3_bucket/s3_key."
  type        = string
  default     = null
}

variable "source_code_hash" {
  description = "Base64 SHA256 of the local .zip (use filebase64sha256). Forces a new layer version on change."
  type        = string
  default     = null
}

variable "s3_bucket" {
  description = "S3 bucket holding the layer artifact. Required for packages over the 50 MB direct-upload limit."
  type        = string
  default     = null
}

variable "s3_key" {
  description = "S3 object key for the layer artifact .zip."
  type        = string
  default     = null
}

variable "s3_object_version" {
  description = "Specific S3 object version of the artifact. Recommended on versioned buckets for immutability."
  type        = string
  default     = null
}

variable "compatible_runtimes" {
  description = "Runtimes this layer supports, e.g. [\"python3.12\", \"python3.13\"]. Max 15 entries."
  type        = list(string)
  default     = []

  validation {
    condition     = length(var.compatible_runtimes) <= 15
    error_message = "A layer may declare at most 15 compatible runtimes."
  }
}

variable "compatible_architectures" {
  description = "CPU architectures the layer supports. Valid values: x86_64, arm64."
  type        = list(string)
  default     = ["x86_64"]

  validation {
    condition = alltrue([
      for a in var.compatible_architectures : contains(["x86_64", "arm64"], a)
    ])
    error_message = "compatible_architectures entries must be x86_64 or arm64."
  }
}

variable "skip_destroy" {
  description = "Retain old layer versions on destroy/replace instead of deleting them."
  type        = bool
  default     = false
}

variable "layer_permissions" {
  description = <<-EOT
    Map of cross-account grants, keyed by statement_id. Each value sets the
    principal (an account ID, or "*" for org-wide / public) and optionally an
    organization_id to scope a "*" principal to a single AWS Organization.
  EOT
  type = map(object({
    principal       = string
    organization_id = optional(string)
  }))
  default = {}
}

outputs.tf

output "arn" {
  description = "ARN of this specific layer version (attach this to a function's layers list)."
  value       = aws_lambda_layer_version.this.arn
}

output "layer_arn" {
  description = "Version-less ARN of the layer itself (without the trailing version number)."
  value       = aws_lambda_layer_version.this.layer_arn
}

output "layer_name" {
  description = "Name of the published layer."
  value       = aws_lambda_layer_version.this.layer_name
}

output "version" {
  description = "Immutable version number assigned to this publish."
  value       = aws_lambda_layer_version.this.version
}

output "source_code_size" {
  description = "Size of the layer .zip artifact in bytes."
  value       = aws_lambda_layer_version.this.source_code_size
}

output "created_date" {
  description = "Timestamp when this layer version was created."
  value       = aws_lambda_layer_version.this.created_date
}

How to use it

# Build the layer artifact: a python/ directory zipped so its contents land in /opt/python.
data "archive_file" "shared_deps" {
  type        = "zip"
  source_dir  = "${path.module}/build/shared-deps"
  output_path = "${path.module}/dist/shared-deps.zip"
}

module "lambda_layer" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-lambda-layer?ref=v1.0.0"

  layer_name       = "platform-shared-deps"
  description      = "boto3 + internal httpx client + structlog, pinned by the platform team"
  license_info     = "MIT"
  filename         = data.archive_file.shared_deps.output_path
  source_code_hash = data.archive_file.shared_deps.output_base64sha256

  compatible_runtimes      = ["python3.12", "python3.13"]
  compatible_architectures = ["arm64"]

  # Share read access with a sibling workload account.
  layer_permissions = {
    workload-prod = {
      principal = "210987654321"
    }
  }
}

# Downstream: a function consuming the layer via the module's arn output.
resource "aws_lambda_function" "order_processor" {
  function_name = "order-processor"
  role          = aws_iam_role.order_processor.arn
  runtime       = "python3.13"
  architectures = ["arm64"]
  handler       = "app.handler"
  filename      = "${path.module}/dist/order-processor.zip"

  layers = [module.lambda_layer.arn]
}

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_layer/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-layer?ref=v1.0.0"
}

inputs = {
  layer_name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/lambda_layer && 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
layer_name string Yes Unique name for the layer (1-140 chars: letters, numbers, hyphens, underscores).
description string null No Description shown in the console for this layer version.
license_info string null No SPDX license identifier or URL for the layer content.
filename string null No Path to the local .zip artifact. Mutually exclusive with the S3 inputs.
source_code_hash string null No Base64 SHA256 of the local .zip; change forces a new layer version.
s3_bucket string null No S3 bucket holding the artifact (use for packages over 50 MB).
s3_key string null No S3 object key for the artifact .zip.
s3_object_version string null No Specific S3 object version; recommended on versioned buckets.
compatible_runtimes list(string) [] No Runtimes the layer supports (max 15), e.g. ["python3.13"].
compatible_architectures list(string) ["x86_64"] No CPU architectures: x86_64 and/or arm64.
skip_destroy bool false No Retain old versions on destroy/replace instead of deleting.
layer_permissions map(object) {} No Cross-account grants keyed by statement_id; each has principal and optional organization_id.

Outputs

Name Description
arn ARN of this specific layer version — attach this to a function’s layers list.
layer_arn Version-less ARN of the layer (no trailing version number).
layer_name Name of the published layer.
version Immutable version number assigned to this publish.
source_code_size Size of the layer .zip artifact in bytes.
created_date Timestamp when this layer version was created.

Enterprise scenario

A central platform team at a fintech maintains a “golden” observability layer — pinned aws-lambda-powertools, structlog, and an internal correlation-ID middleware — published from one tooling account. They call this module with s3_bucket/s3_object_version pointing at a versioned artifact bucket and grant lambda:GetLayerVersion to the whole AWS Organization via layer_permissions = { org = { principal = "*", organization_id = "o-ab12cd34ef" } }. Every one of the 40+ workload accounts attaches module.lambda_layer.arn to its functions, so a single layer publish rolls consistent structured logging out fleet-wide, and a regression is rolled back by repinning to the previous immutable version number.

Best practices

TerraformAWSLambda LayerModuleIaC
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