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
- You have three or more Lambda functions in the same language sharing a common dependency set (an internal client, ORM models, or a heavy library like
numpy/Pillow). - You want to keep individual function packages small and under the 50 MB direct-upload / 250 MB unzipped limits.
- You need to share a layer across AWS accounts (for example a platform team publishes a “golden” observability layer that every workload account consumes) and therefore need
aws_lambda_layer_version_permission. - You want deterministic, immutable versioning so a rollback is just pointing a function back at an older layer ARN.
- You are not a fit if the dependency is unique to a single function — in that case bundle it directly; a layer adds indirection for no reuse benefit.
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 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_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
- Drive versioning off the hash, never a manual counter. Wire
source_code_hash/source_code_base64sha256tofilebase64sha256orarchive_file.output_base64sha256so a new immutable version is published only when the artifact actually changes — this keepsterraform planclean and avoids accidental redeploys. - Match
compatible_architecturesto your functions and preferarm64. Graviton layers are cheaper per millisecond; but a layer built forx86_64will fail at runtime on anarm64function, so build the artifact on the same architecture you declare. - Use S3 with object versioning for anything over 50 MB, and set
s3_object_versionexplicitly — this makes the layer fully reproducible and survives bucket-side overwrites that would otherwise break a rebuild. - Scope cross-account sharing tightly. Prefer an explicit account-ID
principal; only use"*"together withorganization_idso the layer is never unintentionally public — a layer granted to"*"with no org bound is readable by every AWS account on earth. - Set
skip_destroy = truefor shared/published layers so in-flight functions referencing an older version don’t break when Terraform replaces the layer, then prune retired versions deliberately on a schedule rather than on every apply. - Name layers by purpose, not by contents (
platform-shared-deps, notboto3-1-34), so the ARN stays stable as you bump the underlying libraries, and consumers never have to change their reference.