Setting up repro-lambda for your project
June 21, 2026 · View on GitHub
repro-lambda builds and uploads Lambda artifacts to S3 outside of Terraform.
For Terraform to read those artifacts via s3_existing_package, you need to
provision the supporting AWS infrastructure once per AWS account and region.
This guide is a copy-paste-able reference. Adapt the names and account IDs to your environment.
Architecture
For each environment (e.g. dev, prod) you typically need:
${env}-my-lambda-artifacts S3 bucket, region = eu-west-1 (or your primary region)
${env}-my-lambda-artifacts-us-east-1 S3 bucket, region = us-east-1 (for Lambda@Edge — optional)
gha-lambda-builder-${env} IAM role, assumed via GitHub OIDC by source-repo CI to upload
Both buckets enforce key-level immutability via bucket policy:
s3:PutObjectdenied unlessIf-None-Match=*is set (writes are first-write-wins)s3:DeleteObjectands3:DeleteObjectVersiondenied- The Lambda service is allowed
s3:GetObjectso functions can load their zip
Once an artifact with a content-hash key (lambdas/<name>/<sha256>.zip) is
uploaded, the key is permanently bound to those bytes. repro-lambda treats
HTTP 412 PreconditionFailed on a duplicate upload as success.
Terraform — per-account bootstrap
The Terraform below assumes you already have a configured AWS provider in the target account. Drop it into your bootstrap directory (or anywhere you provision account-wide infrastructure that has no dependencies on other Terraform state).
# us-east-1 is needed only for Lambda@Edge artifacts. Skip if you don't use L@E.
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
locals {
env = "dev" # change per environment
lambda_artifacts_bucket_primary = "${local.env}-my-lambda-artifacts"
lambda_artifacts_bucket_us_east_1 = "${local.env}-my-lambda-artifacts-us-east-1"
# GitHub OIDC subjects allowed to assume the builder role.
# Pattern: repo:<owner>/<repo>:* — narrow to specific refs in production.
lambda_builder_oidc_subjects = [
"repo:my-org/my-source-repo:*",
]
}
# Immutability policy applied to every artifact bucket.
data "aws_iam_policy_document" "lambda_artifacts_immutability" {
for_each = toset([
local.lambda_artifacts_bucket_primary,
local.lambda_artifacts_bucket_us_east_1,
])
statement {
sid = "DenyOverwrites"
effect = "Deny"
principals {
type = "*"
identifiers = ["*"]
}
actions = ["s3:PutObject"]
resources = ["arn:aws:s3:::${each.value}/*"]
condition {
test = "StringNotEquals"
variable = "s3:If-None-Match"
values = ["*"]
}
}
statement {
sid = "DenyDelete"
effect = "Deny"
principals {
type = "*"
identifiers = ["*"]
}
actions = [
"s3:DeleteObject",
"s3:DeleteObjectVersion",
]
resources = ["arn:aws:s3:::${each.value}/*"]
}
statement {
sid = "AllowLambdaServiceRead"
effect = "Allow"
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
actions = ["s3:GetObject"]
resources = ["arn:aws:s3:::${each.value}/*"]
condition {
test = "StringEquals"
variable = "aws:SourceAccount"
values = [data.aws_caller_identity.current.account_id]
}
}
}
data "aws_caller_identity" "current" {}
# Primary-region bucket
module "lambda_artifacts_bucket_primary" {
source = "terraform-aws-modules/s3-bucket/aws"
version = "~> 4.0"
bucket = local.lambda_artifacts_bucket_primary
versioning = {
enabled = false # sha-as-key IS the versioning
}
server_side_encryption_configuration = {
rule = {
apply_server_side_encryption_by_default = {
sse_algorithm = "AES256"
}
}
}
attach_policy = true
policy = data.aws_iam_policy_document.lambda_artifacts_immutability[local.lambda_artifacts_bucket_primary].json
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
force_destroy = false
}
# us-east-1 bucket — only needed for Lambda@Edge. Remove if not using L@E.
module "lambda_artifacts_bucket_us_east_1" {
source = "terraform-aws-modules/s3-bucket/aws"
version = "~> 4.0"
providers = {
aws = aws.us_east_1
}
bucket = local.lambda_artifacts_bucket_us_east_1
versioning = {
enabled = false
}
server_side_encryption_configuration = {
rule = {
apply_server_side_encryption_by_default = {
sse_algorithm = "AES256"
}
}
}
attach_policy = true
policy = data.aws_iam_policy_document.lambda_artifacts_immutability[local.lambda_artifacts_bucket_us_east_1].json
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
force_destroy = false
}
# OIDC role assumed by GitHub Actions in source repos to upload artifacts.
module "gha_lambda_builder_role" {
source = "terraform-aws-modules/iam/aws//modules/iam-role"
version = "~> 6.0"
name = "gha-lambda-builder-${local.env}"
enable_github_oidc = true
oidc_wildcard_subjects = local.lambda_builder_oidc_subjects
create_inline_policy = true
inline_policy_permissions = {
WriteLambdaArtifacts = {
actions = [
"s3:PutObject",
"s3:GetObject",
"s3:HeadObject",
"s3:ListBucket",
]
resources = [
module.lambda_artifacts_bucket_primary.s3_bucket_arn,
"${module.lambda_artifacts_bucket_primary.s3_bucket_arn}/*",
module.lambda_artifacts_bucket_us_east_1.s3_bucket_arn,
"${module.lambda_artifacts_bucket_us_east_1.s3_bucket_arn}/*",
]
}
}
}
output "lambda_artifacts_bucket_primary_arn" {
value = module.lambda_artifacts_bucket_primary.s3_bucket_arn
}
output "lambda_artifacts_bucket_us_east_1_arn" {
value = module.lambda_artifacts_bucket_us_east_1.s3_bucket_arn
}
output "gha_lambda_builder_role_arn" {
value = module.gha_lambda_builder_role.arn
}
Note: if you only use a single region (no Lambda@Edge), delete the
lambda_artifacts_bucket_us_east_1module and theus_east_1provider alias. The OIDC role policy only needs the primary bucket then.
Apply the Terraform once per environment. The bucket names and role ARN are referenced by your source repos' CI workflows below.
GitHub OIDC provider
If your account doesn't already have the GitHub Actions OIDC provider, the
terraform-aws-modules/iam collection includes a module for it:
module "iam_github_oidc_provider" {
source = "terraform-aws-modules/iam/aws//modules/iam-oidc-provider"
version = "~> 6.0"
}
The provider is shared per AWS account — declare it once, not per environment.
Source-repo CI workflow
In each repo that builds a Lambda, add a thin caller workflow that delegates to
the reusable workflow in antonbabenko/repro-lambda:
# .github/workflows/build-lambdas.yml
name: build-lambdas
on:
pull_request:
push:
branches: [master]
jobs:
build:
uses: antonbabenko/repro-lambda/.github/workflows/build.yml@v0
with:
manifest_path: lambdas.toml
aws_dev_role_arn: arn:aws:iam::<dev-account-id>:role/gha-lambda-builder-dev
dev_bucket: <env>-lambda-artifacts
# Optional - master-push upload to a prod bucket:
# aws_prod_role_arn: arn:aws:iam::<prod-account-id>:role/gha-lambda-builder-prod
# prod_bucket: <env>-lambda-artifacts
Pin the reusable workflow with the sliding major tag @v0 - it auto-moves to the
latest backward-compatible 0.x release on every tag (switch to @v1 once repro-lambda
ships 1.0). The role ARNs are not secrets: the boundary is the OIDC trust policy plus
the key-level bucket immutability, so they are plain inputs (only the account IDs,
which are public), not stored secrets.
Per-Lambda manifest
Each consumer repo defines a lambdas.toml at its root:
[[lambda]]
logical_name = "app"
source_dir = "src/app"
requirements_lock = "src/app/requirements.${arch}.lock"
runtime = "python3.13"
arch = "arm64"
handler = "app.lambda_handler"
region = "eu-west-1"
package_manager = "pip"
lambda_at_edge = false
hash_extra = ""
[builder]
base_image_python = "public.ecr.aws/lambda/python:3.13@sha256:<pinned-digest>"
include_patterns = ["**/*.py", "**/*.json"]
exclude_patterns = [".venv/**", "__pycache__/**", "*.pyc", ".git/**", ".env*"]
Pin the base_image_python to a specific digest with docker pull <image> && docker inspect --format='{{index .RepoDigests 0}}' <image> — never use a
floating tag in production.
Per-lambda builder overrides
[builder] sets the defaults for every lambda. Any [[lambda]] may override
base_image_python, include_patterns, or exclude_patterns for itself. An
override REPLACES the default for that field (lists are not merged); an unset
field inherits [builder]. Use this when one lambda needs a different base image
or a tighter file filter (so it re-hashes only on changes that affect it):
[[lambda]]
logical_name = "worker"
source_dir = "src/worker"
requirements_lock = "src/worker/requirements.${arch}.lock"
runtime = "python3.13"
arch = "arm64"
handler = "worker.handler"
# Override: only this lambda's runtime modules trigger a rebuild, and it builds
# on its own pinned base image. base_image_python must still be digest-pinned.
base_image_python = "public.ecr.aws/lambda/python:3.13@sha256:<other-digest>"
include_patterns = ["worker/**/*.py", "worker/**/*.json"]
exclude_patterns = ["**/tests/**"]
The resolved per-lambda builder (base-image digest + include/exclude lists + builder version) folds into the content hash, so changing an override re-keys that lambda's artifact while leaving the others untouched.
Declarative sources — [[lambda.source]]
A lambda can bundle pinned external artifacts (a release tarball, a vendored CLI
binary) fetched at build time, instead of a hand-rolled download script. Each
[[lambda.source]] is fully pinned and fetched + verified + extracted into the
package before the container build:
[[lambda]]
logical_name = "app"
source_dir = "src/app"
requirements_lock = "src/app/requirements.${arch}.lock"
runtime = "python3.13"
arch = "arm64"
handler = "app.lambda_handler"
# A private GitHub release tarball -> staged at package path "vendor".
[[lambda.source]]
name = "vendor"
type = "github_release"
repo = "owner/vendor-tool"
tag = "vendor-v{version}"
asset = "vendor-{version}.tar.gz"
sha256 = "<64-hex; written by `repro-lambda lock`>"
extract = "tar.gz"
member = "vendor-{version}" # map the versioned top dir to dest
dest = "vendor"
version = "1.4.0" # bump this one line; lock re-pins everything
# A public binary whose version is derived from the vendor release's .tool-versions.
[[lambda.source]]
name = "terraform"
type = "https"
url = "https://releases.hashicorp.com/terraform/{version}/terraform_{version}_linux_arm64.zip"
sha256 = "<64-hex; written by lock>"
extract = "zip"
member = "terraform"
dest = "bin/terraform"
executable = true
version = "1.9.0"
[lambda.source.version_from]
source = "vendor" # read from the vendor source's extracted tree
file = ".tool-versions" # relative to its member-stripped root
key = "terraform"
- Pinning.
sha256is verified before the archive is opened.extractiszip/tar.gz/none.memberextracts one file or a directory subtree todest; omit it to extract the whole archive underdest. Source names are unique per lambda and dests may not overlap each other or the staged source. version_from(single-level) derives a source'sversionfrom an asdf-stylekey valueline in another source's file, so bumping the rootversioncascades to dependents. It is a lock input - it never affects the artifact hash.repro-lambda lockre-resolvesversion_from, re-downloads, recomputes eachsha256, and rewrites this file (comment-preserving, atomic, idempotent). Run it after bumping aversion. PassREPRO_LAMBDA_SOURCES_TOKENfor privategithub_releasesources.- Security. Fetches are HTTPS-only with an SSRF guard (no private/loopback/
link-local/metadata IPs), strip
Authorizationon cross-host redirects, verify sha256 before extraction, and reject path-traversal / link / device entries with decompression-bomb bounds. Seesrc/repro_lambda/sources.py.
In CI, pass the token to the reusable build.yml as the sources_token secret:
jobs:
build:
uses: antonbabenko/repro-lambda/.github/workflows/build.yml@v0
with:
manifest_path: lambdas.toml
aws_dev_role_arn: arn:aws:iam::<account>:role/<dev-builder-role>
dev_bucket: <env>-my-lambda-artifacts
secrets:
sources_token: ${{ secrets.MY_RELEASE_TOKEN }}
Terraform consumer — s3_existing_package
In the Terraform that creates your Lambda function, point at the artifact in S3 instead of building inline:
locals {
lambda_manifest = jsondecode(file("${path.module}/builds/catalog.json"))
}
module "lambda_app" {
source = "terraform-aws-modules/lambda/aws"
version = "~> 8.0"
function_name = "my-app"
runtime = "python3.13"
architectures = ["arm64"]
handler = "app.lambda_handler"
publish = true
s3_existing_package = {
bucket = "${var.env}-my-lambda-artifacts"
key = "lambdas/app/${local.lambda_manifest.lambdas.app.current}.zip"
object_version = null
}
}
For Lambda@Edge, point at the -us-east-1 bucket and set lambda_at_edge = true
in the Terraform module.
Smoke test
After applying the bootstrap Terraform and configuring CI secrets:
# In your consumer repo
gh workflow run build-lambdas.yml
# Wait for completion, then check that the artifact landed
aws s3 ls s3://dev-my-lambda-artifacts/lambdas/app/
The first PR after migration should show a Terraform plan whose only diff is the
s3_key change. If you see last_modified, qualified_arn, version,
local_file.archive_plan, or null_resource.archive mentioned, the migration
is incomplete — review your Lambda module call and remove the legacy build
attributes (source_path, build_in_docker, trigger_on_package_timestamp,
ignore_source_code_hash, hash_extra, local_existing_package,
store_on_s3).
Troubleshooting
403 AccessDeniedon upload: the OIDC role doesn't haves3:PutObjecton the bucket. Check the inline policy resources include both the bucket ARN and${bucket_arn}/*.PreconditionFailedon every upload: the artifact already exists with that sha. This is the expected cache-hit behavior;repro-lambdatreats it as success.- AWS CLI multipart upload fails with 403: bucket policy denies multipart
parts. Pin the multipart threshold above your Lambda size cap:
aws configure set s3.multipart_threshold 300MB. - Plan still shows noisy diff after migration: verify the consumer Terraform
removed every legacy attribute and that the catalog sha read by
jsondecodematches the sha in the S3 key. The plan diff should be exactly~ s3_key = "<old>.zip" -> "<new>.zip"and nothing else.
Node.js (npm) Lambdas
repro-lambda v0.2 adds Node.js Lambda packaging. The build runs npm ci in
the digest-pinned Node base image, then packs the resulting pkg/ directory
inside the digest-pinned Python base image (Python is the only language with
deterministic-zip tooling pre-installed in the AWS Lambda runtime images, so
its zlib is the only deflate implementation invoked - macOS arm64 hosts and
Linux x86_64 CI produce byte-identical output).
Manifest fields for npm specs
[[lambda]]
logical_name = "api"
source_dir = "src/api"
requirements_lock = "src/api/package-lock.json" # npm lockfile
package_json = "src/api/package.json" # REQUIRED for npm specs
runtime = "nodejs22.x" # or "nodejs20.x"
arch = "x86_64" # or "arm64"
handler = "index.handler"
region = "eu-west-1"
package_manager = "npm"
lambda_at_edge = false
hash_extra = ""
[builder]
base_image_python = "public.ecr.aws/lambda/python:3.13@sha256:<pinned-digest>"
base_image_nodejs = "public.ecr.aws/lambda/nodejs:22@sha256:<pinned-digest>"
include_patterns = ["**/*.js", "**/*.json"]
exclude_patterns = [".git/**", "node_modules/**", "*.md", "LICENSE*", "CHANGELOG*"]
Pin both base images by digest:
docker pull public.ecr.aws/lambda/nodejs:22
docker inspect --format='{{index .RepoDigests 0}}' public.ecr.aws/lambda/nodejs:22
Lockfile regeneration
repro-lambda lock only regenerates per-arch Python lockfiles via uv pip compile. For npm specs it prints skip <name>: npm uses package-lock.json directly. Regenerate the npm lockfile upstream with:
cd src/api
npm install --package-lock-only
git add package-lock.json
The lockfile and package.json both contribute to the artifact content hash;
editing either bumps the S3 key.
Lambda@Edge example
Lambda@Edge functions must be deployed to us-east-1, regardless of where the
CloudFront distribution serves traffic. Set region = "us-east-1" and
lambda_at_edge = true in the manifest; repro-lambda will upload to the
*-us-east-1 artifact bucket automatically.
[[lambda]]
logical_name = "edge"
source_dir = "src/edge"
requirements_lock = "src/edge/package-lock.json"
package_json = "src/edge/package.json"
runtime = "nodejs22.x"
arch = "x86_64" # L@E currently requires x86_64
handler = "index.handler"
region = "us-east-1"
package_manager = "npm"
lambda_at_edge = true
Consumer Terraform points at the us-east-1 bucket:
module "lambda_edge" {
source = "terraform-aws-modules/lambda/aws"
version = "~> 8.0"
providers = { aws = aws.us_east_1 }
function_name = "my-edge"
runtime = "nodejs22.x"
architectures = ["x86_64"]
handler = "index.handler"
publish = true
lambda_at_edge = true
s3_existing_package = {
bucket = "${var.env}-my-lambda-artifacts-us-east-1"
key = "lambdas/edge/${local.lambda_manifest.lambdas.edge.current}.zip"
}
}
Caveats
- No npm workspaces. v0.2 supports a single
package.jsonper Lambda. If your repo uses workspaces, copy the published package into a single-package layout per Lambda before invokingrepro-lambda. - Native dependencies need
optionalDependenciesarms.npm ci --cpu=${arch} --os=linuxcannot cross-compile native modules. A dep with native code must ship alinux-${arch}binary via itspackage-lock.jsonoptionalDependenciesarm (the lockfile-v3 standard mechanism). If it doesn't, the build runs on the host arch and may produce a non-portable artifact. - Symlinks in source are skipped.
pack_directoryskips symlinks and prints a stderr warning; the resulting zip cannot preserve link semantics. If your build relies on symlinks (e.g. monorepo references), replace them with the file contents.