by skunxicat

A Terraform Module for Shell Functions on Lambda

Deploy once. Ship shell scripts forever.

diagram

Why Shell on Lambda

Lambda is often associated with “applications” — Node.js handlers, Python processors, Java services. But at its core, Lambda is just a compute environment that runs code in response to events. You’re billed for memory allocated × execution duration. Nothing in that model requires a programming language.

There are tasks that fit Lambda perfectly — fetching an API, transforming JSON, calling an AWS service, piping data between systems — where a shell script is the most efficient and straightforward solution. No compilation, no dependency tree, no package manager. Just the command you’d type in a terminal, triggered by an event.

The question was never “can shell scripts run on Lambda?” — it was “how do they perform, and how do you make them easy to provision and extend?”

A concrete example: you need a function that collects GitHub traffic data daily and publishes it to an SNS topic. The entire logic is curl the GitHub API, pipe through jq to shape the JSON, curl again to POST to SNS. In Python you’d import requests, json, boto3, write a handler class, manage dependencies. In shell it’s three lines of piping.

There are several ways to run shell scripts in Lambda:

  • Lambda container image — package your scripts in a Docker image with a custom runtime. Full control, but heavier deployments and slower iteration.
  • Embedded runtime — bundle the bootstrap binary inside each function’s ZIP package. Works, but duplicates the runtime across every function.
  • Pure Bash bootstrap — use a shell script as the bootstrap itself, calling the Runtime API with curl. Functional, but slow (~90ms cold starts from process spawning).
  • Runtime as a layer — deploy a compiled bootstrap once as a shared layer, keep function packages as pure shell scripts. Fast, composable, and the runtime is maintained independently.

We explored all of these. The layer approach won on every axis — performance, maintainability, and developer experience. The benchmarks confirm it.

One might argue that compiled languages like Go, Rust, or C++ can achieve better cold starts and raw performance. That’s true for compute-heavy workloads. But not everyone writes Go, and not every task justifies a compiled binary. Shell is the lingua franca of operations — anyone who’s configured a server, deployed an application, or piped commands together already knows it. This module makes that existing skill directly deployable to Lambda.

We packaged the result into a single Terraform module: terraform-aws-lambda-shell-runtime-layer. It embeds a Go-based custom runtime that handles the Lambda Runtime API loop, sources your handler script, and calls your function. Around that, it provides a clean provisioning model — deploy the runtime once as a shared layer, add tool layers as needed (jq, htmlq, uuid), and ship pure shell scripts as function packages.

One terraform apply gives you a Lambda Layer with a custom Go bootstrap. Every function that uses it is just a shell script.

run () {
    curl -Ss https://wttr.in/?format=3
}

That’s a complete Lambda function. No SDK, no framework, no build step. With the module, deploying it is:

module "shell_runtime" {
  source       = "git::https://github.com/ql4b/terraform-aws-lambda-shell-runtime-layer.git?ref=v1.0.0"
  name         = "shell-runtime"
  architecture = "arm64"
}

module "weather" {
  source       = "git::https://github.com/ql4b/terraform-aws-lambda-function.git?ref=v1.1.0"
  source_dir   = "./app"
  name         = "weather"
  runtime      = "provided.al2023"
  handler      = "handler.run"
  architecture = "arm64"
  layers       = [module.shell_runtime.layer_arn]
}

A running example

The runtime and Terraform module used in this article also power a small public endpoint that exposes the currently supported AWS Lambda runtimes as JSON:

https://signals.cloudless.sh/runtimes

The handler is a tiny bash function:

runtimes () {
    curl -sS https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html \
        | htmlq 'div.table-container' \
        | htmlq --text 'div.table-container:first-of-type tbody code' \
        | jq -R | jq -sc
}

There is no api for currently supported runtimes. This function parse that out of the lambda runtimes documentation page.

At the time of writing, it returns:

[
  "nodejs24.x",
  "nodejs22.x",
  "python3.14",
  "python3.13",
  "python3.12",
  "python3.11",
  "python3.10",
  "java25",
  "java21",
  "java17",
  "java11",
  "java8.al2",
  "dotnet10",
  "dotnet9",
  "dotnet8",
  "ruby4.0",
  "ruby3.4",
  "ruby3.3",
  "provided.al2023",
  "provided.al2"
]

The Architecture

AWS Lambda’s custom runtime model is a clean contract: Lambda manages the execution environment and exposes a local HTTP API. You provide a bootstrap executable that runs in a loop — fetch the next event, hand it to your code, post the result back. There’s no framework, no lifecycle hooks, no magic. Just an HTTP conversation between your process and Lambda’s control plane.

The runtime layer pattern takes this contract and separates the execution engine from business logic:

runtime-layer (2.3MB, deployed once)
└── bootstrap    ← Go binary, raw TCP, Runtime API loop

function.zip (~1KB, deployed per function)
└── handler.sh   ← your shell script

The Go bootstrap starts in /var/runtime/bootstrap, polls the Lambda Runtime API for events, sources your handler script, calls the specified function, and returns stdout as the response. That’s the entire contract.

What You Get for Free

When you deploy a shell Lambda, you choose provided.al2023 as the runtime — AWS’s custom runtime base image built on Amazon Linux 2023. This tells Lambda: “I’m bringing my own bootstrap, just give me the OS.”

With provided.al2023, Lambda gives you Amazon Linux 2023 as the execution environment. No layers needed for:

  • curl (with --aws-sigv4 for native AWS API signing)
  • bash, sh, grep, sed, awk, cut, sort
  • date, env, mktemp, base64

This means you can call any AWS service directly with curl:

buckets () {
    curl -sSf \
        --aws-sigv4 "aws:amz:${AWS_REGION}:s3" \
        --user "${AWS_ACCESS_KEY_ID}:${AWS_SECRET_ACCESS_KEY}" \
        -H "x-amz-security-token: ${AWS_SESSION_TOKEN}" \
        "https://s3.${AWS_REGION}.amazonaws.com/"
}

No AWS CLI. No SDK. No dependencies. Just curl and the credentials Lambda already provides.

Adding Tools

When your function needs a tool the OS doesn’t include, you add a Lambda layer. The layer’s contents are mounted at /opt in the execution environment — binaries in /opt/bin are automatically on PATH.

We maintain lambda-shell-layers — a collection of frequently used CLI tools (jq, htmlq, uuid, yq, pcre2grep, http-cli) pre-built as zip files for both arm64 and x86_64. Each release publishes architecture-specific zips ready to be deployed as a layer using our terraform-aws-lambda-layer module:

module "shell_runtime" {
  source       = "git::https://github.com/ql4b/terraform-aws-lambda-shell-runtime-layer.git?ref=v1.0.0"
  name         = "shell-runtime"
  architecture = "arm64"
}

module "jq" {
  source     = "git::https://github.com/ql4b/terraform-aws-lambda-layer.git?ref=v1.2.0"
  name       = "jq"
  source_url = "https://github.com/ql4b/lambda-shell-layers/releases/download/v0.0.3/jq-arm64-layer.zip"
}

module "my_function" {
  source       = "git::https://github.com/ql4b/terraform-aws-lambda-function.git?ref=v1.1.0"
  source_dir   = "./app"
  name         = "my-function"
  runtime      = "provided.al2023"
  handler      = "handler.run"
  architecture = "arm64"
  layers       = [module.shell_runtime.layer_arn, module.jq.layer_arn]
}

Each function gets only the layers it needs. The runtime is shared across all functions in the region.

Multiple Functions, One Runtime

The layer is deployed once per region. Every shell function reuses it:

module "function_a" {
  source       = "git::https://github.com/ql4b/terraform-aws-lambda-function.git?ref=v1.1.0"
  source_dir   = "./app"
  name         = "function-a"
  runtime      = "provided.al2023"
  handler      = "handler.a"
  architecture = "arm64"
  layers       = [module.shell_runtime.layer_arn]
}

module "function_b" {
  source       = "git::https://github.com/ql4b/terraform-aws-lambda-function.git?ref=v1.1.0"
  source_dir   = "./app"
  name         = "function-b"
  runtime      = "provided.al2023"
  handler      = "handler.b"
  architecture = "arm64"
  layers       = [module.shell_runtime.layer_arn]
}

Function packages are ~1KB. Deployments are instant. Runtime updates don’t touch your functions.

The Handler Contract

The handler format is <file>.<function>:

  • handler.run → source handler.sh, call run()
  • handler.events → source handler.sh, call events()
  • lib.process → source lib.sh, call process()

The event payload is available via stdin. Your function’s stdout becomes the Lambda response.

Get Started

git clone https://github.com/ql4b/terraform-aws-lambda-shell-runtime-layer
cd terraform-aws-lambda-shell-runtime-layer/examples/basic
terraform init
terraform apply

Four example deployments are included:


Performance

All values in milliseconds. Measured on provided.al2023, 128MB memory, using lambda-benchmarks.

arm64 (Graviton)

FunctionLayersmedianp90p99
weather2 (runtime + jq)19.0622.3227.56
events2 (runtime + jq)19.1522.1822.50
id3 (runtime + jq + uuid)19.2922.1222.77
runtimes3 (runtime + jq + htmlq)19.2022.3023.29
status3 (runtime + jq + http-cli)19.9822.2622.79

x86_64

FunctionLayersmedianp90p99
weather1 (runtime)25.7126.6728.10
events2 (runtime + jq)25.5626.1426.52
id3 (runtime + jq + uuid)25.6526.2032.12
runtimes3 (runtime + jq + htmlq)25.7026.4728.14
status3 (runtime + jq + http-cli)25.5426.5028.79

arm64 is ~25% faster and 20% cheaper. Layer count has no meaningful impact — 1 layer vs 3 layers, same cold start. The IQR stays under 4ms across all configurations.

How Cold Starts Were Measured

We force fresh execution environments by updating a function’s environment variable, then burst 120 concurrent invocations (parallel -j 60). Lambda provisions a new environment for each concurrent request. After logs flush, we extract Init Duration from REPORT lines and compute stats with datamash.

No artificial warmup. No sequential invocations. Real cold starts from a single burst.

Where This Fits

This module is the production-ready distillation of our shell Lambda journey. The progression:

  1. Pure Bash bootstrap — functional, ~90ms cold starts
  2. Go + Bash hybrid — better, ~42ms
  3. Raw TCP sockets — breakthrough, ~21ms
  4. Runtime-as-a-layer — optimal architecture, ~19ms on arm64, zero overhead from separation

The module ships pre-built binaries for both arm64 and x86_64. The Go source is in the repo if you want to inspect or rebuild it.

Shell scripts shouldn’t need frameworks to become APIs. This module makes that real — sub-20ms cold starts, composable layers, and function packages measured in bytes.