Upgrading Bref 2 to Bref 3 with zero downtime

August 14, 2026

Bref 3 shipped a unified runtime, which is a nice simplification internally, but on Amazon Linux 2023 the layer has to ship libicu and php-fpm. The php-85 layer is 92MB uncompressed against 46MB for php-84 on Bref 2.

That extra 46MB comes straight out of your budget for application code. Lambda's limit is 250MB for code plus layers unzipped, and a normal Laravel app with a normal number of composer packages gets there faster than you'd think. I've now hit this on two separate client projects, neither of which was doing anything unusual. If your vendor directory is over about 100MB, which is an ordinary Laravel app with a queue driver, a payments SDK, an AWS SDK and a PDF library, this is going to land on you when you upgrade:

Function code combined with layers exceeds the maximum allowed size of 262144000 bytes.
The actual size is 282365753 bytes.

I opened an issue on the Bref repo about it. The answer from Matthieu was pretty clear: container images are the path forward, and he'd considered making them the default in v3. AL2023 forces the libicu bundling, and there's nothing to trim on the runtime side.

Trimming your own vendor directory doesn't save you either. On the first project I tried it three separate times, increasingly aggressively, and got a byte-identical figure in the error message each time, which is the tell that the number isn't coming from your app package at all. The runtime layer plus the gd extension unzip to around 199MB, leaving under 60MB for the application against a vendor directory twice that. No amount of --no-dev closes a gap that size.

So: container images. The Dockerfile is the easy half. Getting there on a production app without dropping traffic is the part worth writing down.

Why you can't blue/green this

The obvious plan is to deploy an image-based function alongside the existing zip one, validate it, then move the httpApi event across. I planned it that way, rehearsed it on staging, and it doesn't work.

Lambda treats PackageType (Zip vs Image) as immutable. CloudFormation rejects an in-place change with Updating PackageType is not supported and rolls the stack back. Stack updates are transactional, so a single rejected function fails the entire deploy.

That would be survivable if you could keep the old zip function serving while the image one validates, but you can't, because of the size limit that started all this: a Bref 3 zip function doesn't fit in the first place, so there's no safety net to fall back to.

You also can't hand-edit your way around it. HttpApi, the integration and the default route are all CloudFormation-managed resources in your stack, so touching them manually leaves the stack drifted and the next deploy fights you.

What actually works

Stand up an entirely new stack, validate it on its own endpoint, then switch traffic by repointing the API Gateway API mapping. The mapping is the one piece CloudFormation doesn't own, so it can be changed out of band and reversed instantly.

                    ┌─────────────────────────────┐
   example.com ───► │  API GW custom domain       │
                    │                             │
                    │   api mapping  ◄─── the one │
                    │        │            movable │
                    └────────┼────────────  part  ┘
                             │
              ┌──────────────┴──────────────┐
              ▼                             ▼
    ┌───────────────────┐        ┌───────────────────┐
    │  OLD STACK        │        │  NEW STACK        │
    │  Bref 2 / zip     │        │  Bref 3 / image   │
    │  own API id       │        │  own API id       │
    │  scheduler ON     │        │  scheduler OFF    │
    │  own queue        │        │  own queue        │
    └─────────┬─────────┘        └─────────┬─────────┘
              │                            │
              └──────────┬─────────────────┘
                         ▼
              shared database + session store
              (both stacks must see the same
               session store, or users get
               logged out on the swap)

Both stacks are fully deployed and fully functional at the same time, and a single API mapping decides which one serves your users. Flipping it is one CLI call that takes effect immediately.

Step Downtime Reversible
Deploy new stack, all PHP functions as images none, nothing live is touched n/a
Validate on the new stack's raw API GW URL none n/a
Repoint the API mapping none (cold starts only) yes, instantly
Drain old queue, tear old stack down none no, do this last

If your DNS CNAMEs through to the API Gateway regional endpoint, as it will with Cloudflare or similar in front, the mapping swap moves real user traffic with no DNS change and no propagation wait.

Set your retain policies first

This is the step to get wrong at 2am, so do it first and do it as its own deploy.

DeletionPolicy: Retain only protects a stack that already has it deployed. If you tear the old stack down without it, CloudFormation takes your DynamoDB cache table, your SQS queues and your log groups with it. On one of these projects the cache table held around 90,000 live items, and the web function's log group alone had 2.5GB in it.

resources:
  extensions:
    WebLogGroup: { DeletionPolicy: Retain, UpdateReplacePolicy: Retain }
    ArtisanLogGroup: { DeletionPolicy: Retain, UpdateReplacePolicy: Retain }
    JobsWorkerLogGroup: { DeletionPolicy: Retain, UpdateReplacePolicy: Retain }
    jobsQueueCEDBAE3E: { DeletionPolicy: Retain, UpdateReplacePolicy: Retain }
    jobsDlqD18CF374: { DeletionPolicy: Retain, UpdateReplacePolicy: Retain }
  Resources:
    CacheTable:
      DeletionPolicy: Retain
      UpdateReplacePolicy: Retain

The SQS logical IDs are hash suffixed but derive from the construct id, so the same block works across every stage. Deletion policies only apply on delete or replace, so this change is completely inert on a running stack. Ship it on its own, verify the live template actually reads Retain for every resource you care about, then move on.

Verify it by actually deleting a staging stack rather than trusting the template. Mine came through with the cache table still ACTIVE, both queues and all four log groups surviving, and only the Lambdas and the API removed.

Building the image

Bake the config cache into the image. Bref's BrefSubscriber shells out to artisan config:cache on every cold start unless bootstrap/cache/config.php already exists. Without it the console function blows Lambda's 10 second init cap and you get INIT_REPORT ... Phase: init, Status: timeout, with init then retried inside the invoke.

RUN cd /var/task \
    && APP_ENV=production APP_DEBUG=false php artisan config:cache \
    && APP_ENV=production APP_DEBUG=false php artisan event:cache

Pin APP_ENV at build time like that. If a build picks up a developer .env you bake app.env=local, app.debug=true into the config cache, and a baked cache is read before runtime env vars apply, so provider.environment will not correct it. Don't route:cache if you have closure routes.

Your .dockerignore has to mirror the zip package.patterns you were using before. It's easy to forget this exists, since the zip packager was quietly doing the same job for you, and Docker will copy your entire working directory into the image if you let it. Left thin, the first image I built came out at 3.32GB, against 792MB once the excludes matched.

Stay on amd64. Bref publishes an actively maintained ARM runtime, but the gd extra-extension image is x86 only, and the whole extra-* family appears to be. Copying amd64 .so files into an arm64 image builds fine and dies at runtime. There's no multi-arch escape either, since Bref ships ARM as a separate repo rather than a multi-platform manifest, and Lambda doesn't support multi-architecture container images anyway.

The stage is a deploy argument

You have one serverless.yml. It defines images for every PHP function, and whichever stage you deploy it to gets images. The "new stack" and the eventual production stack are the same code at different stages, which has one consequence worth spelling out:

Don't merge the migration branch and let your normal deploy pipeline do the cutover. If merging to your main branch auto-deploys to the existing production stack, that's exactly the in-place Zip to Image conversion that fails and rolls back. The new stack has to go up as a manual deploy to a new stage, off the migration branch, so your main branch stays untouched and the live stack stays fully managed the whole way through.

serverless deploy --stage=prod2

Point it at production configuration. A manual deploy from a migration branch will happily bake your staging dotenv and staging URLs into a stack that's about to serve production traffic.

Deploy it with the scheduler disabled. The new stack shares the live database, so its schedule:run fires every task alongside the old stack's. On one of these apps, ten of eighteen scheduled entries had no withoutOverlapping or onOneServer guard, and several of those send customer email. Leaving the scheduler on means real customers get duplicate emails. The guarded ones do coordinate correctly across two stacks, since a database cache store is shared between them, but don't rely on that for the unguarded ones. Go and read your Kernel.php before you deploy, not after.

Nothing live is touched by this deploy. The new stack gets its own API, functions, queues and cache table, and the old stack keeps serving with its scheduler intact.

Validating

Hit the new stack's own execute-api URL, not the public domain, which is still pointing at the old stack. Health check, a real page, a login, an authenticated action. Confirm schedule:run ticks when you invoke it manually and that a queued job drains.

If you're running Inertia SSR or similar, leave that function as zip on nodejs20.x. It has no reason to be an image.

Handy trick for telling the two stacks apart once both are live: fingerprint by the asset hash in the HTML. If your build stamps a commit SHA into the asset URL, you can tell at a glance which stack answered.

The swap

# capture rollback state first
aws apigatewayv2 get-api-mappings --domain-name example.com --region us-east-1

aws apigatewayv2 update-api-mapping --domain-name example.com \
  --api-mapping-id <id> --api-id <new-api-id> --stage '$default' --region us-east-1

Rollback is the same command with the old api-id, so write the original one down before you start. It's effective immediately, no deploy involved. Test it in both directions on staging before doing it for real.

At this point traffic is on the new stack, and the old stack is still the only one running a scheduler. Leave it that way.

Draining and tearing down

Order matters here, and there must never be two schedulers running against the live database at once.

  swap mapping ──► new stack serving, old scheduler still running
       │
       ▼
  drain old queue ──► both message counts at 0
       │
       ▼
  delete old stack ──► old scheduler dies with it
       │                (retained resources survive)
       ▼            ◄── no scheduler anywhere, keep this window short
  redeploy new stack with scheduler enabled
       │
       ▼
  point your deploy pipeline at the new stage

Draining means both ApproximateNumberOfMessages and ApproximateNumberOfMessagesNotVisible at 0. The second one catches in-flight messages a worker has picked up but not finished, and it's the one people forget.

Between deleting the old stack and redeploying the new one with its scheduler on, no scheduled tasks run at all. A gap there is much safer than an overlap, but keep it short.

One thing to watch in the window between the mapping swap and that last step: your deploy pipeline is still pointing at the retired stack. Either freeze the main branch for that period or move the pipeline over promptly.

Sessions need a store both stacks can see, which on Lambda means DynamoDB or Redis (DynamoDB in our case). As long as both stacks point at the same one, sessions survive the swap and nobody gets logged out. Worth checking before you swap rather than after, because the failure mode is every logged-in user landing on the login screen at once.

That does mean the new stack has to point at the existing session table rather than standing up its own. If you're using DynamoDB for cache as well, you can decide the two separately: config/cache.php reads DYNAMODB_CACHE_TABLE, so pointing the new stack's cache at the existing table is a one-line change plus widening the IAM statement to match. I kept sessions shared and let the cache be per-stack, since a cold cache only costs you some database load for a few minutes, while a shared cache table lets a bad deploy on the new stack poison the old stack's cache and take your rollback with it.

Cold starts

The honest number, measured on staging: warm latency is identical at around 0.85s, cold starts went from 2.28s to 8.7s.

That's the real regression, and worth knowing before you commit to this. Matthieu's point in the issue thread is that the very first invocation is warming the Lambda container image cache, and subsequent cold starts are much faster, so don't judge it on the first one. Container images also lazy load in chunks, which means the libicu and fpm files that caused this whole problem never get downloaded to the Lambda instance if nothing reads them. The Bref benchmarks have container cold starts coming out ahead in most cases.

Gotchas

Things I either hit or nearly hit:

  • Function-level package.patterns are silently ignored unless package.individually: true. A service-wide exclude plus a function-level re-include looks right and isn't, and shipped me an SSR function that died with Cannot find module 'ssr'.
  • Don't rename the queue construct. Queue and DLQ names both derive from it, and it breaks any ${construct:jobs.queueUrl} reference.
  • Never run two SQS workers against one queue.
  • OCTANE_SERVER is a red herring. Bref's OctaneHandler never reads config('octane.server').
  • Bref 3 switches production logs to structured Monolog LEVEL msg {json}. Expected, not a break, but it'll look like one in CloudWatch the first time.
  • Check your frameworkVersion. Pinning it to an exact Serverless version while package.json installs a newer one blocks deploys entirely.
  • Prove your pipeline is green from your main branch before blaming your migration branch. Staging that's been untouched for a year will have rotted in ways that look exactly like your changes broke something.

Timeline

provided.al2 lost security patches on 31 July 2026. No new functions on it after 31 August 2026, and no updates to existing functions after 30 September 2026. That last one is the hard wall: past it you can't ship a code change to those Lambdas at all.

Matthieu mentioned hoping zip deployments eventually get the same limit as container deployments, which would make all of this unnecessary, but there's no date on that. If you're near the limit today, the mapping swap is what I'd do.