Runtime API, bootstrap file and handler loop for a minimal Bash runtime
AWS Lambda does not force a specific programming language. Through the Runtime API, any executable file, including a plain Bash script, can act as a full Lambda runtime. Understanding how the bootstrap file fetches events, calls the actual handler and reports the result back makes it possible to run small glue scripts in Lambda without any interpreter overhead.
Table of Contents
- 1. What a custom runtime is and why Bash qualifies
- 2. Runtime API basics: /next, /response and /error
- 3. Writing a minimal runtime loop in Bash
- 4. Separating the handler script from the bootstrap file
- 5. Building the package and deploying it as a Lambda function
- 6. Error handling and timeouts in the runtime loop
- 7. Performance: cold start and interpreter overhead compared
- 8. When a Bash Lambda makes sense: glue scripts and simple automation
- 9. When a Bash Lambda does not make sense
- 10. Summary
- 11. FAQ
1. What a custom runtime is and why Bash qualifies
AWS Lambda ships officially supported runtimes for Node.js, Python, Java, Go, Ruby and .NET, but also opens up the execution environment for custom runtimes. A custom runtime is ultimately just an executable file named bootstrap that Lambda invokes when a container starts, and which is responsible for the entire lifecycle of the execution context, including fetching events and handling errors.
Because bootstrap only needs to be executable, a Bash script with the right shebang line qualifies as a full runtime too, as long as the execution image ships basic tools like curl and jq. For simple glue tasks that mostly orchestrate shell commands anyway, that eliminates the detour through a Python or Node interpreter entirely, and cold starts stay minimal since no language runtime overhead needs to load.
2. Runtime API basics: /next, /response and /error
The Lambda Runtime API is a simple HTTP server reachable locally inside the execution context under the environment variable AWS_LAMBDA_RUNTIME_API. A runtime calls GET /2018-06-01/runtime/invocation/next to wait for the next event, with the call blocking until an event actually arrives, which maps directly to a blocking curl call in Bash.
After processing, the runtime reports the result via POST /2018-06-01/runtime/invocation/{requestId}/response, or through the corresponding /error endpoint on failure. The request ID lives in a response header of the /next call and must be extracted by the runtime itself from the headers, which in Bash typically happens via curl -D followed by parsing with grep or sed.
3. Writing a minimal runtime loop in Bash
The core of the custom runtime is an infinite loop that fetches exactly one event per iteration, processes it and reports the result back. This loop runs for the entire lifetime of the Lambda execution context, so several consecutive invocations reuse the same container and therefore the same Bash process, which avoids cold starts after the first execution.
It is important to catch every error inside the loop instead of letting the entire runtime crash on a single failed event. A set -e across the whole loop would be counterproductive here, because a single failure would then terminate the entire container instead of just reporting that one invocation as failed and continuing with the next.
#!/usr/bin/env bash
set -uo pipefail
readonly RUNTIME_API="$AWS_LAMBDA_RUNTIME_API"
readonly BASE_URL="http://${RUNTIME_API}/2018-06-01/runtime"
while true; do
# Fetch the next event, extract the request ID from the header
headers_file=$(mktemp)
event=$(curl -sS -D "$headers_file" "${BASE_URL}/invocation/next")
request_id=$(grep -Fi "Lambda-Runtime-Aws-Request-Id" "$headers_file" | tr -d '\r' | cut -d' ' -f2)
rm -f "$headers_file"
# Run the handler, catch failures instead of aborting the runtime
if response=$(./handler.sh "$event" 2>&1); then
curl -sS -X POST "${BASE_URL}/invocation/${request_id}/response" -d "$response" > /dev/null
else
curl -sS -X POST "${BASE_URL}/invocation/${request_id}/error" \
-d "{\"errorMessage\": \"handler failed\", \"errorType\": \"HandlerError\"}" > /dev/null
fi
done
4. Separating the handler script from the bootstrap file
It pays off to move the actual business logic into a separate handler script while bootstrap stays responsible only for Runtime API communication. That keeps both files manageable and allows testing the handler independently of the runtime loop logic, simply by calling it with a sample JSON argument.
The handler receives the raw event JSON as an argument or via standard input and has to extract the needed fields itself with jq, since Bash has no native JSON parsing. The return value goes through standard output as valid JSON, which the bootstrap loop forwards unchanged to the /response endpoint.
#!/usr/bin/env bash
# handler.sh -- actual business logic, independently testable
set -euo pipefail
event="$1"
name=$(echo "$event" | jq -r '.name // "World"')
echo "{\"message\": \"Hello, ${name}!\"}"
5. Building the package and deploying it as a Lambda function
A custom runtime gets uploaded as a completely normal deployment package that contains at least the executable bootstrap file and the handler script. Both files need execute permissions before being packed into the ZIP archive, because Lambda carries the permissions over from the archive, and a missing execute bit only shows up as a fairly unhelpful error on the very first invocation.
When creating the function through the AWS CLI, the runtime is specified as provided.al2 or provided.al2023, the minimal Amazon Linux base image without any preinstalled language runtime. If that base image lacks needed tools like jq, they can be added through an extra Lambda layer instead of copying them into every single function package.
#!/usr/bin/env bash
set -euo pipefail
chmod +x bootstrap handler.sh
zip -j function.zip bootstrap handler.sh
aws lambda create-function \
--function-name mironsoft-bash-glue \
--runtime provided.al2023 \
--handler bootstrap \
--zip-file fileb://function.zip \
--role arn:aws:iam::123456789012:role/lambda-bash-runtime-role \
--timeout 10 \
--memory-size 128
6. Error handling and timeouts in the runtime loop
Because the bootstrap loop is responsible for the entire execution lifetime of a container, it has to distinguish between a failure in the actual business logic and a failure in the runtime communication itself. A failed handler call gets reported cleanly through the /error endpoint and the loop keeps running, while a failed curl call against the Runtime API itself usually indicates a deeper problem and should terminate the container.
Lambda enforces its own timeout independently of the runtime, killing the container once the configured timeout value is exceeded, regardless of what the bootstrap loop is doing at that moment. Inside the runtime it still pays off to enforce a somewhat shorter timeout for the handler call, so a properly formatted error message reaches /error instead of Lambda cutting off execution with a generic timeout message.
7. Performance: cold start and interpreter overhead compared
A Bash Lambda starts without the overhead of initializing a Python or Node runtime, because the shell itself is already part of the minimal base image as a process. For very simple tasks that mostly consist of calling external programs, such as aws s3 cp or curl, that shows up as measurably shorter cold start times, since no additional interpreter needs to load.
Once more complex logic enters the picture though, such as nested JSON parsing, error objects with stack traces, or state management across multiple invocations, the advantage reverses: Bash scripts relying on many external process calls like jq, awk and curl spawn a new subprocess per call, which is slower for compute-heavy logic than the same logic running in a native Python runtime with in-process libraries.
8. When a Bash Lambda makes sense: glue scripts and simple automation
A Bash Lambda works great for small glue scripts that react to an S3 event or an EventBridge trigger by running a handful of CLI commands, for instance copying a file between two S3 buckets, kicking off a backup command, or running a simple health-check script periodically. Such tasks are almost entirely shell commands anyway, so a native language runtime brings little real value while adding cold-start overhead.
It is also attractive as a throwaway tool for one-time migrations, or for teams that already maintain extensive Bash tooling libraries for their deployment pipelines, since existing scripts can be reused in Lambda almost unchanged instead of being ported to another language just to run them serverless.
9. When a Bash Lambda does not make sense
As soon as a function processes complex data structures, needs extensive error handling with typed exceptions, or relies on a rich SDK for other AWS services, Bash quickly becomes a burden. Missing native JSON handling, no real data structures beyond arrays, and the need to delegate practically every piece of logic to external processes like jq make the code fragile and hard to test once it grows past a few dozen lines.
For production-critical business logic, teams without deep Bash experience, or functions maintained jointly by many developers, an official runtime like Python or Node is almost always the better choice, because tooling, test frameworks and the error messages of the AWS SDKs are considerably more mature there than what can be recreated in Bash with curl and jq.
| Criterion | Bash custom runtime | Native runtime (Python/Node) | Recommendation |
|---|---|---|---|
| Cold start | Minimal, no interpreter startup | Interpreter and SDK initialization | Bash for simple glue scripts |
| JSON handling | Only via external jq | Natively built in | Native runtime for heavy JSON logic |
| Error handling | Manual through /error endpoint | Try/except with stack traces | Native runtime for critical logic |
| AWS SDK access | Only through AWS CLI calls | Fully featured SDK (boto3 etc.) | Native runtime for many AWS services |
| Team maintainability | Low without Bash experience | High, widely known language | Native runtime in a team context |
Mironsoft
Shell automation, DevOps tooling and deployment infrastructure
Shell scripts that hold up in production?
We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.
Code Review
ShellCheck analysis and manual review for critical Bash pattern violations.
Refactoring
Retrofitting error handling, logging and safe file operations.
CI Integration
Wiring ShellCheck and BATS into pipelines and building regression tests.
10. Summary
AWS Lambda Custom Runtime in Bash: The Essentials at a Glance
Core idea
A custom runtime is just an executable bootstrap file that fetches events and reports results through the Runtime API.
Core loop
while true with curl against /invocation/next, handler call, result to /response or failure to /error.
Deployment
Make bootstrap and the handler executable, upload as a ZIP, choose runtime provided.al2023.
Where it fits
Good for glue scripts without complex logic, unsuitable for data-heavy, team-owned business logic.