"Can I run Phoenix on Lambda?" – the HTTP/API part, yes: one adapter, no HTTP
server. The companion package mayfly_plug
turns API Gateway (v1/v2), Function URL and ALB events into %Plug.Conn{}s,
runs your router or endpoint, and returns the proxy response. LiveView and
channels need a WebSocket and stay out of scope.
# mix.exs
deps: [{:mayfly, "~> 1.0.0-rc"}, {:mayfly_plug, "~> 0.1"}, {:phoenix, "~> 1.8"}, ...]
releases: [
lambda: [
steps: [&Mayfly.Release.prepare/1, :assemble, &Mayfly.Release.bootstrap/1, &Mayfly.Release.zip/1],
mayfly: [handler: MyApp.Lambda, layer: true]
]
]defmodule MyApp.Lambda do
use Mayfly.Plug.Handler, plug: {MyAppWeb.Endpoint, []}
endThat is the whole integration. A plain Plug.Router works the same way with
plug: MyApp.Router.
Phoenix configuration
Generate the app without the parts that assume a server:
mix phx.new my_app --no-html --no-assets --no-ecto --no-live --no-mailer --no-dashboard
Then:
- No server. In
config/runtime.exssetserver: falsefor the endpoint and drop thehttp:block (Lambda never opens a port). Keepurl: [host: System.get_env("PHX_HOST"), port: 443, scheme: "https"]so generated URLs are right. Move{:bandit, ...}toonly: [:dev, :test]; it is not needed in the release. - Remove
DNSClusterfrom the supervision tree and thedns_clusterdep; there is no cluster. SECRET_KEY_BASEandPHX_HOSTbecome Lambda environment variables. The application (Endpoint, PubSub, Telemetry) starts normally whenMayfly.Bootboots the release; yourinit/1runs after that.- Logging: Phoenix's
Plug.Telemetrylog lines go throughMayfly.LogFormatterand show up in CloudWatch as JSON with the LambdarequestIdwhen the function's log format is JSON.
What works: controllers, JSON views, pipelines and plugs, Plug.Parsers,
sessions in signed/encrypted cookies (Plug.Session with the :cookie
store), Plug.Static for files in priv/static (prefer S3/CloudFront for
assets), Phoenix.PubSub within one execution environment.
What does not: LiveView, channels and anything else that needs a WebSocket or
a long-lived connection; Phoenix.PubSub across instances; the live dashboard.
Ecto: start the Repo as usual, pool_size: 1 or 2 (one invocation at a time
per environment; more on Managed Instances), and use RDS Proxy or Aurora DSQL
so thousands of environments do not open thousands of connections.
Streaming
A second handler module with streaming: true serves send_chunked/chunk
responses through Lambda response streaming:
defmodule MyApp.StreamingLambda do
use Mayfly.Plug.Handler, plug: {MyAppWeb.Endpoint, []}, streaming: true
endDeploy it as its own function (same zip, different handler) behind a Function
URL in RESPONSE_STREAM mode. Every chunk is sent as it is produced; plain
routes still work through that function. Buffered functions concatenate chunks
into one response.
Errors
Phoenix renders a 500 through Phoenix.Endpoint.RenderErrors and re-raises.
mayfly_plug returns the rendered 500 to the client and logs the exception
(what Bandit does), so API Gateway shows your error JSON instead of a generic
- Pass
on_error: :propagateif you prefer Lambda to record a function error. Exceptions raised before any response was sent always propagate.
Measured
Phoenix 1.8.15 JSON API, Erlang layer, arm64, 1024 MB, eu-central-1:
| zip (BEAM files only) | 2.8 MB |
Init Duration (cold start) | 586 ms / 739 ms |
warm GET /api/hello | 3–10 ms function time, ~100–180 ms end to end via Function URL |
| memory | 94 MB |
| streamed route, 4 chunks 300 ms apart | chunks arrived at +1.61 s (cold), +1.91 s, +2.21 s, +2.52 s |
Local testing
mix lambda.invoke MyApp.Lambda '{}' --http --method GET --path /api/hello
mix lambda.invoke MyApp.Lambda '{"name":"Ada"}' --http --path /api/users # JSON body
mix lambda.invoke MyApp.StreamingLambda '{}' --http --method GET --path /api/stream --raw
mix test in your Phoenix app is untouched: Phoenix.ConnTest keeps using
Plug.Test; the Lambda adapter is only used inside the function.