The Lambda model worked until it did not.
Not because it broke. Because the shape stopped matching the work. Every API route was its own function, its own deploy artifact, its own cold start behavior, its own set of IAM permissions, and its own slice of API Gateway configuration. Multiply that across several products in a portfolio and you end up with a deployment choreography that takes more time to maintain than the features it supports.
That was the state of things before the migration. Working, but heavy.
What the Lambda stack actually looked like
Each product had a similar shape: a handful of Lambda functions behind API Gateway, each responsible for one or two routes. Auth was handled separately at the edge. The packaging story was SAM or CDK depending on the product’s vintage. Deploys went through CodeBuild.
The problem was not any one piece. The problem was the number of pieces.
A simple product with five API routes meant five functions, five packaging configs, one API Gateway definition, one CodeBuild pipeline, and whatever IAM glue held it together. When you are running several products that all follow this pattern, the operational surface area adds up fast.
Most of the time I was not thinking about product logic. I was thinking about deployment topology.
Why a single container was the better shape
Magic Containers on Bunny let me collapse that entire stack into one deployable unit.
One container. One image. One routing layer inside the app. No API Gateway. No per-function packaging. No SAM templates. No CodeBuild orchestration across multiple function artifacts.
The app itself owns its routes. An incoming request hits the container, the container routes it, the handler runs, the response goes back. That is the whole story.
This is not a novel architecture. It is how most web applications worked before serverless made function-per-route the default assumption. The difference is that Magic Containers give you this shape at the edge, on infrastructure that is already close to your users, without dragging a full Kubernetes cluster or a traditional VM fleet into the picture.
What actually changed in the code
The application code barely changed. The handlers were already written as discrete functions. The main difference was adding a lightweight router at the entry point and removing the Lambda-specific event wrappers.
On Lambda, every handler expected an API Gateway event object and returned a response shaped for API Gateway. On Magic Containers, the handlers just process HTTP requests directly. The translation layer disappeared because there was nothing left to translate between.
The Dockerfile ended up simple. Install dependencies, copy the app, expose a port, start the server. No multi-stage build gymnastics. No Lambda runtime interface client. No custom bootstrap scripts.
Cold starts went away
This mattered more than I expected.
Lambda cold starts were manageable individually but they added up across the portfolio. Each function had its own cold start profile, and provisioned concurrency was an extra cost I did not want to pay for workloads that were not latency-critical at the function level but still needed to feel responsive to real users.
A running container does not have cold starts. The process is already alive. The first request is the same speed as the hundredth request. For a portfolio of apps where traffic is real but not massive, this was a better fit.
The deployment story got dramatically simpler
This was the biggest practical improvement.
The deploy script for a product went from sixty or seventy lines of AWS orchestration to a handful of commands. Build the image, push it to the registry, tell Bunny to pick it up. The feedback loop dropped from minutes to seconds.
When your deploy path is that short, you ship more confidently and more often. That is not a minor quality-of-life improvement. It changes how you think about what is worth shipping.
Routing inside the container
The router is intentionally minimal. A small mapping from path patterns to handler functions. No framework. No middleware chain. No dependency injection. Just a map and a dispatch.
For products that need middleware-like behavior, I compose it at the handler level. Auth checks, input validation, and response shaping all happen inside the handler or in small composable functions the handler calls. There is no global middleware stack to reason about.
This keeps the routing layer thin enough that you can read the entire thing in a few seconds and understand exactly what every request path does. That matters when you are the operator, the developer, and the person who gets paged.
What I gave up
Per-function scaling granularity. On Lambda, if one route gets ten times more traffic than the others, only that function scales. In a container, the whole process handles all the routes.
For this portfolio, that tradeoff was trivial. No single route in any product had dramatically different scaling needs from the others. If that changes, the container can still scale horizontally. It just scales as a unit instead of per-route.
I also gave up the Lambda ecosystem integrations: direct event source mappings from SQS, SNS, DynamoDB Streams, and similar triggers. For the workloads that used those, the replacement was either a polling pattern inside the container or a webhook-based flow. Both were straightforward.
Why this was worth it
The Lambda model encourages you to think in fragments. Each function is small and isolated, which sounds good until you realize the complexity just moved from application code to deployment topology.
A container that owns its own routing puts the complexity back where it belongs: in the application, where you can see it, test it, and reason about it as a single unit.
For a portfolio of real products maintained by one operator, that is the right shape. Less choreography, less deployment surface area, and more time spent on the parts that actually matter to users.