All posts

Engineering

Running Laravel on AWS Lambda with Bref, Neon and Upstash, and what it costs at idle

· 5 min read

  • laravel
  • aws lambda
  • bref
  • serverless
  • postgres

A small API that serves real traffic for a few hours a day is a bad fit for a server, because a server charges for the other twenty hours. Lambda charges per request, which fits the shape of the traffic exactly.

Laravel runs there through Bref, which supplies the PHP runtime as a Lambda layer and an adapter that turns an API Gateway event into a request Laravel understands. The framework is unmodified. What changes is everything around it.

Compute at zero is easy to lose

The saving only exists if every part of the stack idles at zero. Attach one always-on managed database and the bill stops depending on traffic, which was the entire point.

So the pieces have to match:

  • Postgres that scales to zero, so an idle database costs storage and nothing else. Neon does this, with a compute that suspends when nothing is connected.
  • Redis billed per command rather than per hour. Upstash does this over HTTP, which also suits a runtime where a persistent connection is meaningless.
  • Object storage with no egress charge. Cloudflare R2 matters here more than it looks: on a media-heavy API, egress from the usual object store can exceed every other line on the bill combined.

The cold start is the cost you pay instead. For PHP through Bref it is real but not dramatic, and for an app whose users are already tolerating a mobile connection it is usually below the threshold anyone notices. Measure it against your own latency budget before assuming either way.

The filesystem is read only, apart from /tmp

This is the first thing that breaks, and it breaks at boot rather than at runtime, which at least makes it obvious.

Point every writable path at /tmp: the view cache, the framework cache, logs, and anything a package writes without asking. /tmp is per execution environment, so it survives between invocations on a warm container and disappears when that container goes away. That makes it correct for caches and wrong for anything you need later.

Logs go to stdout instead, where CloudWatch picks them up. A permission error on storage/ in production is almost always a path that was never redirected, not a permissions problem to be fixed with chmod.

Connections, and why the pooled endpoint matters

Every concurrent invocation is its own PHP process opening its own database connection. Ten concurrent requests is ten connections, and Postgres's connection limit arrives much sooner than any interesting load does.

The answer is a pooler in front of the database, and a serverless Postgres provides one. Point the application at the pooled endpoint for request traffic.

Migrations are the exception: run them against the direct endpoint, because a pooler in transaction mode does not support everything DDL needs. Two connection strings in the environment, used for different things, is the shape to expect.

Persistent connections are worth a moment's thought too. They help on a warm container and do nothing on a cold one, and whether they help overall depends on your concurrency pattern. It is one environment variable, so measure rather than assume.

There is no cron, and there is no queue worker

Two Laravel habits stop working, and both fail silently, which is the dangerous part.

schedule:run has nothing to run it. There is no machine with a crontab. Scheduled work becomes an EventBridge rule invoking a separate artisan function on a schedule. The trap is declaring the task in routes/console.php and forgetting the infrastructure side, or the reverse. A task declared in one place only never fires and never complains.

A queue worker is a long-running process, which Lambda is not. There are two honest options. Run the queue as sync, which means a dispatched job runs inline and a slow job is a slow request, and accept that. Or put SQS in front of a second function and keep the worker properly separate. Start with sync if the jobs are small, and know that the cost of being wrong is response time rather than correctness.

What to keep an eye on in the bill

Per request, at small scale, most of it is genuinely near zero. The lines that grow are not the ones people watch:

  • CloudWatch Logs. Verbose logging on every invocation costs more than the invocations do, faster than seems reasonable.
  • NAT, if the function is in a VPC. A NAT gateway is billed by the hour, which puts an always-on charge back into a stack built to avoid exactly that. Keep the function out of a VPC unless something genuinely requires it.
  • Egress, everywhere except the object store chosen for not charging it.

Cold starts show up as latency rather than cost, and the usual mitigation, provisioned concurrency, is an hourly charge. That is the same trade as the NAT gateway: it buys predictability with the property that made the stack cheap.

When this is the wrong choice

Steady traffic all day. If the function is warm anyway, a small always-on instance is simpler and often cheaper, and it comes with a filesystem, a crontab and a queue worker you did not have to redesign around.

Long requests. A report that takes two minutes is fighting the execution limit, and the answer is a second function and a job rather than a bigger timeout.

Anything needing a persistent connection, such as websockets. That is a different service entirely and no amount of configuration makes Lambda into one.

YathraBook runs on this stack. The application-level design that sits on top of it is in idempotent offline sync with client-generated operation UUIDs and allocating sequential invoice numbers safely under concurrent Lambda invocations, the second of which is specifically about what parallel invocations do to code written for one server.

Questions people ask

Does Laravel need changes to run on Lambda with Bref?
The framework is unmodified. What changes is the environment around it: writable paths must point at /tmp, logs go to stdout, scheduled tasks need EventBridge, and the queue has no long-running worker to consume it.
Why does the database have to be serverless too?
Because an always-on database puts a fixed hourly charge back into a stack whose whole point is costing nothing at idle. Compute scaling to zero saves nothing if the database next to it bills by the hour regardless.
Why use a pooled connection endpoint?
Each concurrent invocation is a separate process with its own connection, so Postgres's connection limit is reached at modest concurrency. A pooler sits in front and multiplexes them. Run migrations against the direct endpoint instead.
How do scheduled tasks work without cron?
An EventBridge rule invokes an artisan function on a schedule. The task has to be declared both in the application and in the infrastructure configuration. Declared in only one of the two, it never fires and never reports an error.
Can I run Laravel queue workers on Lambda?
Not as long-running workers. Either run the queue synchronously, so a dispatched job runs inline and a slow job is a slow request, or put SQS in front of a second function and keep the worker genuinely separate.
What actually costs money on this stack?
CloudWatch Logs if logging is verbose, a NAT gateway if the function sits in a VPC, since that is billed hourly, and egress anywhere the object store charges for it. The invocations themselves are usually the smallest line.
When should I not use Lambda for an API?
Steady all-day traffic, where a small always-on instance is simpler and often cheaper. Also long requests that fight the execution limit, and anything needing persistent connections such as websockets, which Lambda cannot provide.

Read next

I build offline first apps and serverless backends for a living. If you have something like this that needs building or fixing, the contact form reaches me.

Get in touch