Technical

Lambda Deploy

Pattern for deploying Lambda code from CI/CD using direct zip upload with Terraform infrastructure separation.

Production

Pattern for deploying Lambda code from CI/CD using direct zip upload, with clear separation between code deployment (GitHub Actions) and infrastructure management (Terraform).

Problem

Lambda services need automated code deployment from CI/CD. Without a standard:

  • Some services use S3 artifacts, others use direct upload, others rely on manual terraform apply
  • Terraform and CI/CD fight over Lambda code state
  • No clear boundary between code changes (safe, frequent) and infrastructure changes (risky, infrequent)
  • New services reinvent the deployment pipeline each time

Context

Use this pattern when:

  • Deploying AWS Lambda functions from GitHub Actions
  • Lambda zip bundles are under 40 MB
  • The service uses Terraform for infrastructure management
  • You need clear separation between code and infrastructure deployments

Don't use when:

  • Bundle exceeds 40 MB zipped — escalate to S3 artifact mode (see Variations)
  • Compliance requires persistent artifact retention with audit trail
  • Sub-30-second rollback SLA is required

Solution

Lambda code deploys use direct zip upload. GitHub Actions builds, Terraform configures.

Two workflows handle CI/CD. Terraform manages infrastructure but ignores code changes. The Lambda-DeployRole (OIDC Deploy tier) provides the necessary permissions.

Workflow Structure

PR to master:
  ci.yaml -> lint, test, build, terraform plan

Push to master:
  deploy.yaml -> build, deploy each Lambda via direct upload

Terraform Separation

Terraform (developer):          GitHub Actions (automated):
  - Create/delete Lambda          - Build zip
  - IAM roles, policies           - aws lambda update-function-code
  - Environment variables         - Smoke tests (optional)
  - Memory, timeout, runtime
  - API Gateway, DynamoDB, etc.

Structure

my-service/
|-- .github/
|   +-- workflows/
|       |-- ci.yaml              # PRs: lint, test, build, tf plan
|       +-- deploy.yaml  # master: build + deploy code
|-- .infra/
|   |-- lambda.tf                # Lambda definitions with ignore_changes
|   +-- ...
|-- src/
|   |-- my-function/
|   |   +-- index.ts
|   +-- other-function/
|       +-- index.ts
|-- dist/                        # Build output (gitignored)
|   |-- my-function.zip
|   +-- other-function.zip
+-- Taskfile.yaml                # build, test, deploy:lambdas tasks

Implementation

1. Terraform Lambda Configuration

Add lifecycle { ignore_changes } to prevent Terraform from reverting CI/CD code deployments:

resource "aws_lambda_function" "my_function" {
  function_name    = "${var.project}-${var.environment}-my-function"
  role             = aws_iam_role.my_function.arn
  handler          = "index.handler"
  runtime          = "nodejs20.x"
  filename         = "${path.module}/bootstrap/placeholder.zip"
  source_code_hash = filebase64sha256("${path.module}/bootstrap/placeholder.zip")

  lifecycle {
    ignore_changes = [filename, source_code_hash]
  }
}

Why filename points to a placeholder: AWS requires code when creating a Lambda — you cannot create an empty function. The filename is only used during the initial terraform apply to bootstrap the resource. After that, lifecycle { ignore_changes } ensures Terraform never touches the code again; CI/CD takes over via update-function-code.

The placeholder zip is a minimal handler committed to the repo at .infra/bootstrap/placeholder.zip:

// .infra/bootstrap/handler.js
exports.handler = async () => ({ statusCode: 503, body: "Not deployed yet" });
# Generate once, commit the zip
cd .infra/bootstrap && zip placeholder.zip handler.js

This avoids requiring a full build before terraform apply and makes terraform plan work in CI without build artifacts.

2. Taskfile Tasks

vars:
  LAMBDAS: my-function other-function

tasks:
  build:
    desc: Build all artifacts
    cmds:
      - task: build:lambdas
      # - task: build:frontend  (if applicable)

  build:lambdas:
    desc: Compile and package Lambda zips
    cmds:
      - npx tsx esbuild.config.ts  # or: uv run build
      - for lambda in {{.LAMBDAS}}; do
          (cd dist/$lambda && zip -qr ../$lambda.zip .) ;
        done

  deploy:lambdas:
    desc: Deploy all Lambda functions (requires AWS credentials)
    deps: [build]
    cmds:
      - for lambda in {{.LAMBDAS}}; do
          aws lambda update-function-code
            --function-name "{{.PROJECT}}-{{.ENVIRONMENT}}-$lambda"
            --zip-file "fileb://dist/$lambda.zip"
            --publish ;
        done

3. CI Workflow (ci.yaml)

name: CI
on:
  pull_request:
    branches: [master]

permissions:
  id-token: write
  contents: read

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: arduino/setup-task@v2

      - name: Configure AWS (CodeArtifact read)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActions-CodeArtifact-ReadRole
          aws-region: eu-central-1

      - name: Install, lint, test, build
        run: task ci  # wraps: install, lint:check, typecheck, test, build

  plan:
    runs-on: ubuntu-latest
    needs: validate
    steps:
      - uses: actions/checkout@v4
      - uses: arduino/setup-task@v2

      - name: Configure AWS (Terraform plan)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActions-Terraform-PlanRole
          aws-region: eu-central-1

      - name: Terraform Plan
        run: task infra:plan

4. Deploy Workflow (deploy.yaml)

name: Deploy Lambdas
on:
  push:
    branches: [master]
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: arduino/setup-task@v2

      - name: Configure AWS (CodeArtifact read)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActions-CodeArtifact-ReadRole
          aws-region: eu-central-1

      - name: Build
        run: task build

      - name: Configure AWS (Lambda deploy)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/GitHubActions-Lambda-DeployRole
          aws-region: eu-central-1

      - name: Deploy
        run: task deploy:lambdas

Note the two configure-aws-credentials steps: first assumes the read role for npm install (CodeArtifact), then switches to the deploy role for update-function-code.

Variations

S3 Artifact Mode

Instead of uploading zips directly to Lambda, this variation stores them in S3 first:

  1. Create an S3 bucket following {project}-{env}-lambda-artifacts naming convention
  2. Upload zips to lambdas/{component}/{version}-{sha}.zip
  3. Use --s3-bucket and --s3-key instead of --zip-file
  4. Enable bucket versioning for rollback support
  5. Add lifecycle policy (90 days expiry, 30 noncurrent versions)

The Lambda-DeployRole already has S3 permissions for buckets matching *-*-lambda-artifacts*.

Bundle Size CI Check

Add to your Taskfile to catch bundle growth proactively:

  build:check-size:
    desc: Warn if any Lambda zip exceeds 40 MB
    cmds:
      - |
        for zip in dist/*.zip; do
          size=$(stat -f%z "$zip" 2>/dev/null || stat -c%s "$zip")
          mb=$((size / 1048576))
          if [ "$mb" -ge 40 ]; then
            echo "WARNING: $zip is ${mb}MB (threshold: 40MB)"
            exit 1
          fi
        done

Choosing Between Models

Use this comparison to decide which model fits your service:

DimensionDirect Upload (default)S3 Artifacts
Max bundle size50 MB (hard AWS limit)250 MB
InfrastructureNoneS3 bucket + lifecycle + versioning
Deploy command--zip-file fileb://aws s3 cp + --s3-bucket/--s3-key
RollbackGit revert + CI rebuild (~2 min)Repoint to previous S3 key (~5 sec)
Artifact retentionNone (rebuild from git SHA)Persistent in S3 with versioning
Deployment traceabilityGit commit SHAS3 key with {version}-{sha} + timestamp
CI dependency for rollbackYes — broken CI blocks rollbackNo — S3 artifacts are independent
Multi-Lambda consistencySequential update, brief version skewSame, unless combined with CodeDeploy
ComplexityMinimalModerate (bucket, lifecycle, IAM already covered)

Decision Rule

Use Direct Upload (this pattern) when all of these are true:

  • Bundles are under 40 MB
  • Rebuild-based rollback (~2 min) meets your SLA
  • No compliance requirement for persistent binary artifacts
  • CI pipeline is reliable

Escalate to S3 Artifacts when any of these apply:

  • Bundle approaching or exceeding 40 MB
  • Need instant rollback without rebuild (sub-30 sec SLA)
  • Regulatory or contractual requirement for artifact retention
  • CI pipeline reliability is a concern during incidents

References