Choosing how the runtime gets to Lambda
Zip + ERTS layer (layer: true) | Zip + bundled ERTS | Container image (--image) | |
|---|---|---|---|
| Artifact | 1–5 MB zip | 25–50 MB zip | 60–70 MB compressed image in ECR (≈270 MB unpacked, 10 GB limit) |
| Build host | any OS, no Docker | Amazon Linux 2023 or --docker | docker or finch (release built inside the container) |
| Build time | seconds | seconds + one-off 20–40 min image build | same as --docker, plus a few seconds for the image |
| NIF dependencies | need the layer and Linux-built NIFs (--docker) | works | works; add system libraries in lambda.image.Dockerfile |
| OTP version | must equal the layer's exactly (checked at start) | whatever you build with | whatever you build with |
| Cold start (arm64, 512 MB, measured) | ~540 ms median | ~505 ms median | ~430 ms median after the first pull; the very first start of a new image ≈1.2 s |
| Deploy | update-function-code --zip-file (2–4 s) | update-function-code --zip-file (10–14 s) | update-function-code --image-uri after push |
| Function URL / API GW / streaming / Managed Instances | yes | yes | yes |
| SnapStart | no | no | no (managed runtimes only) |
Recommendation: layer for everything without NIFs; bundled ERTS via Docker when you want a zip anyway; container image when the function needs native libraries (ImageMagick, Rust, C NIFs), when your pipeline already builds images, or when you want to run the exact artefact locally. Public layer ARNs and the naming scheme are in layers.md.
Release configuration
releases: [
lambda: [
steps: [&Mayfly.Release.prepare/1, :assemble, &Mayfly.Release.bootstrap/1, &Mayfly.Release.zip/1],
mayfly: [handler: MyApp.Handler, layer: true],
# any Mix.Release option:
applications: [my_app: :permanent],
include_erts: false # implied by layer: true
]
]Mayfly.Release.prepare/1 sets strip_beams: true, include_executables_for: [:unix]
and a vm.args with +S 1:1 +sbwt none unless you provide these yourself.
Umbrella apps: define the release in the umbrella root mix.exs as usual.
Runtime configuration
config/runtime.exs works normally. The generated bootstrap sets
RELEASE_TMP=/tmp because Lambda's /var/task is read-only, so the release
can write its generated sys.config there.
The generated bootstrap
#!/bin/bash
set -eu
export LAMBDA_TASK_ROOT="${LAMBDA_TASK_ROOT:-...}"
export RELEASE_TMP="${RELEASE_TMP:-/tmp}"
export RELEASE_DISTRIBUTION="${RELEASE_DISTRIBUTION:-none}"
export _HANDLER="${_HANDLER:-MyApp.Handler}"
export PATH="${MAYFLY_ERTS:-/opt/erlang}/bin:$PATH" # layer builds only, plus an
# ERTS presence/version check that fails fast with a readable message
exec "$LAMBDA_TASK_ROOT/bin/lambda" eval "Mayfly.Boot.main()"
Lambda's Handler setting populates _HANDLER and therefore overrides the
default baked in at build time.
Building with Docker (bundled ERTS)
mix lambda.build --docker --arch x86_64 # or arm64
mix lambda.build --docker --release other --env staging --outdir ./deploy
The task uses docker, or finch if docker is not installed
(CONTAINER_CLI=... overrides). It builds lambda.Dockerfile (yours if
present, else Mayfly's) for the requested platform, runs mix deps.get && mix release inside the container
with a separate MIX_BUILD_PATH, and copies lambda.zip out. path:
dependencies outside the project are mounted read-only into the container.
Add system packages for NIFs by copying Mayfly's Dockerfile into your project
and extending the dnf install line.
Container image
mix lambda.build --image --arch arm64 # -> my_app-lambda:latest
mix lambda.build --image --arch arm64 --push 123456789012.dkr.ecr.eu-central-1.amazonaws.com/my-app
--image builds the release inside the Amazon Linux 2023 build container
exactly like --docker (so NIFs compile there, no local toolchain needed) and
copies it into an image based on public.ecr.aws/lambda/provided:al2023 at
/var/task, with your handler as the image CMD. The generated Dockerfile:
FROM public.ecr.aws/lambda/provided:al2023
COPY . ${LAMBDA_TASK_ROOT}/
RUN ln -sf ${LAMBDA_TASK_ROOT}/bootstrap /var/runtime/bootstrap
CMD ["MyApp.Handler"]Both release flavours work: a bundled-ERTS release is self-contained; a
layer: true release gets /opt/erlang copied from the build image instead
(smaller image, ~90 MB unpacked). Drop a lambda.image.Dockerfile into your
project to replace the generated one, for instance to dnf install ImageMagick
or copy configuration files; it receives the build args RELEASE_DIR,
BUILD_IMAGE and HANDLER.
The base image's entrypoint exports its argument as _HANDLER and runs
/var/runtime/bootstrap, through the Runtime Interface Emulator when
AWS_LAMBDA_RUNTIME_API is unset. So the image runs locally as is:
docker run --rm --platform linux/arm64 -p 9000:8080 my_app-lambda:latest
curl -d '{"name":"x"}' http://localhost:9000/2015-03-31/functions/function/invocations
--push logs in to ECR (aws ecr get-login-password), tags, pushes and
prints the create-function command with the image digest. The build passes
--provenance=false --sbom=false: BuildKit otherwise wraps the image in an
OCI index with attestation manifests, which Lambda rejects with "image
manifest, config or layer media type … is not supported".
aws ecr create-repository --repository-name my-app --image-scanning-configuration scanOnPush=true
aws lambda create-function --function-name my-app --package-type Image \
--code ImageUri=123456789012.dkr.ecr.eu-central-1.amazonaws.com/my-app@sha256:… \
--architectures arm64 --role arn:aws:iam::123456789012:role/lambda-role \
--logging-config LogFormat=JSON
# later:
aws lambda update-function-code --function-name my-app --image-uri …@sha256:…
No --runtime, no --layers, no --handler: the handler is the image
CMD (override per function with --image-config Command=Other.Handler).
SAM: PackageType: Image, ImageUri, Metadata: {Dockerfile, DockerContext}
or push yourself; Terraform: package_type = "Image", image_uri; CDK:
lambda.DockerImageFunction / Code.fromEcrImage. Everything else (Function
URLs, streaming, JSON logs, Managed Instances) is unchanged.
Measured on the playground (bcrypt C NIF, arm64, 512 MB, 63 MB compressed /
268 MB unpacked, 10 forced cold starts): initDurationMs median 433 ms, p90
547 ms, min 386 ms; the first start after the push took 1188 ms while Lambda
pulled and cached the image. Warm invocations 2–10 ms plus the handler's own
work. That is on par with the zip variants once the image is cached; budget
the first-pull second for brand-new image digests.
HTTP events (Function URLs, API Gateway)
Function URLs and API Gateway do not pass your JSON body as the event; they
wrap it in an HTTP event ("version": "2.0", "rawPath", "headers",
"body" as a string). Match on it explicitly:
def handle(%{"requestContext" => %{"http" => %{"method" => m, "path" => p}}, "body" => body}, ctx, state) do
with {:ok, json} <- JSON.decode(body || "{}") do
route(m, p, json, ctx, state)
else
_ -> {:ok, %{statusCode: 400, body: "invalid JSON"}}
end
endTest locally with mix lambda.invoke MyApp.Handler '{"x":1}' --http --method POST --path /items.
Infrastructure as code
SAM, Terraform and CDK definitions that resolve the public layer per region,
and the mix lambda.new generator that fills them in, have their own guide:
Infrastructure as code. The short version:
mix lambda.new hello --iac sam|terraform|cdk [--http-api] [--arch x86_64] [--otp 28]
CI
- uses: erlef/setup-beam@v1
with: { elixir-version: "1.18", otp-version: "27" }
- run: mix deps.get
- run: MIX_ENV=prod mix release lambda # layer: true → no Docker needed
- run: aws lambda update-function-code --function-name hello \
--zip-file fileb://_build/prod/rel/lambda/lambda.zipPin the OTP major in CI to the layer's (27 here). If you bundle ERTS, replace
the release step with mix lambda.build --docker --arch arm64.
Lambda Managed Instances
Nothing changes in the package. Create a capacity provider, then create the
function with --capacity-provider-config (memory ≥ 2048 MB, invoked by
published version) and Mayfly reads AWS_LAMBDA_MAX_CONCURRENCY and starts
that many pollers:
aws lambda create-capacity-provider --capacity-provider-name mayfly \
--vpc-config SubnetIds=subnet-…,SecurityGroupIds=sg-… \
--permissions-config CapacityProviderOperatorRoleArn=arn:aws:iam::ACCOUNT:role/lmi-operator \
--instance-requirements '{"Architectures":["arm64"],"AllowedInstanceTypes":["m7g.large"]}' \
--capacity-provider-scaling-config '{"MaxVCpuCount":16}'
aws lambda create-function … --memory-size 2048 \
--capacity-provider-config '{"LambdaManagedInstancesCapacityProviderConfig":{"CapacityProviderArn":"arn:…:capacity-provider:mayfly","PerExecutionEnvironmentMaxConcurrency":8,"ExecutionEnvironmentMemoryGiBPerVCpu":2}}'
aws lambda publish-version --function-name my-fn # invoke my-fn:1
The operator role needs the AWSLambdaManagedEC2ResourceOperator policy.
Mayfly's bootstrap keeps all vCPUs as schedulers in this mode. Your handler must
be safe to run concurrently (it runs in separate processes; init/1 state is
shared read-only), and should watch Mayfly.Context.remaining_time_ms/1
because Managed Instances do not terminate a handler at its deadline.
Memory and timeouts
Start with 512 MB (more memory = more CPU). Cold start is dominated by BEAM
boot (~150–300 ms on arm64 with a stripped release); keep init/1 lean.
Streaming functions are billed for the full duration even if the client
disconnects.