Prerequisites

  • Elixir 1.18+ and OTP 27+
  • An AWS account and the AWS CLI (for deployment)
  • Docker only if you bundle ERTS instead of using the layer

1. Add Mayfly and a release

Shortcut: mix lambda.new hello --iac sam (from any project that has mayfly in its deps, or a Mayfly checkout) generates everything in steps 1–3 plus a SAM, Terraform or CDK definition; see the IaC guide. The steps below show what it generates.

# mix.exs
def project do
  [
    app: :hello,
    version: "0.1.0",
    elixir: "~> 1.18",
    deps: [{:mayfly, "~> 1.0.0-rc"}],
    releases: [
      lambda: [
        steps: [&Mayfly.Release.prepare/1, :assemble, &Mayfly.Release.bootstrap/1, &Mayfly.Release.zip/1],
        mayfly: [handler: Hello.Handler, layer: true]
      ]
    ]
  ]
end

layer: true means the zip will not contain ERTS; the Mayfly layer provides it. Leave it out to bundle ERTS (then build with mix lambda.build --docker).

2. Write the handler

# lib/hello/handler.ex
defmodule Hello.Handler do
  use Mayfly.Handler

  @impl true
  def handle(event, %Mayfly.Context{} = ctx, _state) do
    name = Map.get(event, "name", "World")

    {:ok,
     %{
       message: "Hello, #{name}!",
       request_id: ctx.request_id,
       remaining_ms: Mayfly.Context.remaining_time_ms(ctx)
     }}
  end
end

3. Try it locally

mix lambda.invoke Hello.Handler '{"name":"Elixir"}'
# %{"message" => "Hello, Elixir!", "remaining_ms" => 29998, "request_id" => "local-1-..."}

This runs the real runtime against an emulated Runtime API, so errors, the context and JSON encoding behave exactly as in Lambda.

4. Build

MIX_ENV=prod mix release lambda
# * creating _build/prod/rel/lambda/bootstrap
# * creating _build/prod/rel/lambda/lambda.zip (2140 KiB)

5. Deploy

ARCH=arm64                                  # or x86_64 – must match the layer
LAYER=arn:aws:lambda:eu-central-1:ACCOUNT:layer:mayfly-erlang-27-3-4-$ARCH:1

aws lambda create-function \
  --function-name hello \
  --runtime provided.al2023 \
  --architectures $ARCH \
  --handler Hello.Handler \
  --layers $LAYER \
  --zip-file fileb://_build/prod/rel/lambda/lambda.zip \
  --role arn:aws:iam::ACCOUNT:role/lambda-execution-role \
  --timeout 30 --memory-size 512

aws lambda invoke --function-name hello \
  --cli-binary-format raw-in-base64-out \
  --payload '{"name":"Lambda"}' /dev/stdout

Your local OTP must be the layer's exact version (the bootstrap checks it); mise use erlang@27.3.4.18 elixir@1.18.4-otp-27 does it. Public layer ARNs are listed in layers.md; to publish your own run layer/build.sh && layer/publish.sh --region eu-central-1. Or skip the layer and bundle ERTS with mix lambda.build --docker --arch $ARCH (docker or finch).

6. Iterate

MIX_ENV=prod mix release lambda --overwrite
aws lambda update-function-code --function-name hello \
  --zip-file fileb://_build/prod/rel/lambda/lambda.zip

Handler patterns

# state from init/1
def init(_opts), do: {:ok, %{client: MyApp.Client.new()}}
def handle(event, _ctx, %{client: client}), do: {:ok, MyApp.Client.call(client, event)}

# pattern matching on the event
def handle(%{"action" => "create"} = e, _ctx, _s), do: {:ok, create(e)}
def handle(%{"action" => "delete"} = e, _ctx, _s), do: {:ok, delete(e)}
def handle(_e, _ctx, _s), do: {:error, %{errorType: "BadRequest", errorMessage: "unknown action"}}

# Function URL / API Gateway: the payload arrives as event["body"] (a string)
def handle(%{"rawPath" => path, "body" => body}, _ctx, _s) do
  {:ok, %{statusCode: 200, headers: %{"content-type" => "application/json"},
          body: JSON.encode!(%{path: path, received: JSON.decode!(body || "{}")})}}
end
# try it: mix lambda.invoke Hello.Handler '{"a":1}' --http --path /items

Next: Deployment, Streaming, Observability.