Building a Scalable, Load-Balanced Laravel App with Docker, Nginx, and AWS ECS
If you’ve ever tried taking a "normal" Laravel app and making it ready for cloud-native production, you know the dream: sleek deployments, effortless scaling, no server babysitting. This week, I set out to finally make that dream real — containerising a Laravel app, setting up a proper Nginx reverse proxy, and launching the whole thing on AWS ECS Fargate with a load balancer in front.
What followed was one of the most rewarding, frustrating, and eye-opening experiences I’ve had in a while.
The Original Plan
I didn't start this week thinking about Nginx.
The original plan was Laravel Octane running on RoadRunner inside a container. It sounded perfect: high performance, super low latency, and "production ready" according to the docs. But the deeper I went, the messier it became. The documentation around Octane deployment at scale was thin. Setting up RoadRunner correctly inside an ECS container? Even thinner.
And when you're fighting the framework more than you're building the product, it’s time to take a step back.
The Pivot: Choosing Stability
Midway through the week, after yet another dead-end with Octane config examples, I made the call: ditch Octane and RoadRunner for now, and go back to a rock-solid Nginx + PHP-FPM setup.
It felt a little like giving up — but honestly, it turned out to be a huge win.
Nginx is boring. PHP-FPM is boring. But boring is reliable. And right now, reliability mattered more than novelty.
Making that pivot was a huge mental relief. I finally felt like I could move forward without second-guessing every choice.
Dockerizing Laravel (For Real This Time)
Once I committed to Nginx + PHP-FPM, the Dockerfile almost wrote itself.
Instead of trying to juggle process managers and worker threads, I focused on the basics:
A lean Alpine-based PHP-FPM image.
An optimized Laravel build (caching configs, routes, etc).
Proper permissions on
storage/andbootstrap/cache.A clean Nginx config that served static assets directly and proxied the rest to PHP-FPM.
Watching the first container actually start without any weird Octane errors felt amazing. Small reward, big morale boost.
Setting Up AWS ECS and Load Balancing
This is where things got both exciting and painful.
ECS is powerful, but it expects you to know what you're doing. The amount of options just to create a simple service is insane:
Do you want EC2 or Fargate?
What networking mode?
Which security groups?
Public IP or internal only?
What about autoscaling rules?
Some evenings, it felt like I was just staring at AWS console tabs and Googling acronyms.
When I finally got the Application Load Balancer (ALB) forwarding traffic properly to my containers — and I could see a real Laravel page being served — I literally fist-pumped at my desk.
There’s nothing like that first successful HTTP 200 after hours of configuring listeners, target groups, and security groups.
Frustrations (and How I Overcame Them)
It wasn't smooth sailing the whole way. There were moments when it felt like I was one bad decision away from throwing my laptop out the window.
Here were the biggest frustrations:
Health checks: Getting the ALB health checks working meant adding a dedicated
/healthroute to Laravel. Without it, my containers kept getting killed.Port mismatches: I accidentally mapped the wrong container port to the load balancer target group once. Debugging that took hours.
Logging black holes: Without CloudWatch logs, it’s almost impossible to troubleshoot container failures inside ECS. Turning on logging early saved me from a lot of silent failures later.
Resource limits: Forgetting to define CPU/memory limits caused the tasks to scale poorly under load.
Each of these mistakes cost me time and energy — but they also forced me to learn exactly how ECS, load balancers, and containers behave in real-world scenarios.
The Rewards
The hard work paid off.
By the end of the week, I had:
A Dockerized Laravel app that runs consistently.
A load-balanced service that auto-scales when needed.
A clean CI/CD pipeline that can trigger deployments.
A deeper understanding of AWS networking than I ever had before.
More importantly, I had built something that wasn’t just a proof of concept — it was ready for production.
That feeling — seeing all the moving parts finally click into place — made every frustrating moment worth it.
What's Next
Even though the base architecture is live, there's always another level up:
Move environment variables into AWS Secrets Manager (no more hardcoded
.envfiles).Set up blue/green deployments for zero downtime releases.
Fine-tune autoscaling policies based on memory and request rates, not just CPU.
Improve observability with better CloudWatch dashboards and alarms.
The foundation is solid now. Next comes the polishing.
Final Thoughts
If I could give one piece of advice to anyone going through this journey: Be willing to pivot.
The biggest progress I made this week didn’t come from pushing harder — it came from letting go of a setup that wasn’t working, and choosing something stable and reliable instead.
Octane and RoadRunner might be perfect someday, and I’ll revisit them when the time’s right. But for now, plain old Nginx and PHP-FPM are powering a fast, scalable, cloud-native Laravel deployment — and that’s a win worth celebrating.
