Moving a PHP App onto a Shared EC2 Without Touching the Other Tenant
2026-07-23 · Micro Learner AI team
We build Micro Learner AI as a plain PHP app — nginx, php-fpm, Redis, MySQL — developed and tested locally on XAMPP. When it came time to put it on the internet, the server available to us was an EC2 instance that already hosts another production site behind a shared load balancer. The constraints were strict: zero downtime for the existing tenant, zero changes to anything it depends on, and no restarting shared services to make our lives easier.
It worked. This is the writeup, including the two bugs that cost us real hours — the kind that bite anyone deploying onto infrastructure they don't fully own.
Rule one: additive only
We set one rule before touching anything: every change must be additive. At the load balancer, that meant creating a new target group and adding new listener rules keyed on our hostname — never editing the existing rules, never touching the default action. On the box itself, it meant our own web root and our own nginx vhost, with the other site's vhost left byte-for-byte alone.
The companion habit: verify the other site after every single step. Every listener rule, every nginx reload ended with a request to the other site through its own domain confirming it still answered. It sounds paranoid; it's what made the project low-stress, because a mistake could never age past five minutes.
Our own lane on the box
The instance turned out to be well set up for a quiet co-tenant. It already ran PHP 8.4 with php-fpm and had the extensions we needed (mysqli, redis, curl), so we installed nothing — important, because PHP is shared with the other tenant and upgrades were off the table. We dropped our code into its own directory under the web root, added one new vhost listening on port 8080, and pointed the load balancer's new target group at that port. The other site's vhost never crosses paths with ours.
The shared-services constraint meant graceful nginx reloads only — a reload drops no connections, so the other tenant never blips — and anything that would normally require restarting a shared service meant finding another way. There was always another way.
The Redis hang that looked like success
Instead of running a local Redis, we reused the existing managed Redis cluster. It's TLS-only, with an auth token. Our PHP code, carried over from local dev, opened a plain TCP connection.
Here's the nasty part: the TCP connect succeeds. The port is open, and then the client waits for a TLS negotiation it never starts while the server waits for one that never comes. No exception, no error log, no timeout — the request just hangs. Our admin dashboard opened its own connection this way, and the page would spin for thirty seconds before anything gave up. Locally this never happened, because local Redis speaks plain TCP happily.
The lesson: use TLS from day one. If your production endpoint is TLS-only, configure TLS in the client before the first connection, not after the first mysterious freeze. And audit for code paths that open their own connections instead of using your shared, correctly configured connection helper — the dashboard bug was exactly that.
nginx served us stale files
The second time sink came after a routine deploy. We uploaded updated JavaScript, confirmed the file on disk was correct, and the browser kept getting the old version. Clearing the browser cache didn't help; the browser was never the problem.
The server's nginx config had open_file_cache enabled with a 24-hour validity window. nginx caches open file handles and metadata, so a replaced static file keeps serving its old contents until the cache expires or nginx reloads. PHP files were unaffected the whole time, because php-fpm reads from disk on every request — which made the bug look intermittent until we noticed the split fell exactly along the PHP/static line.
Our deploy procedure now ends with three steps: upload files, graceful nginx reload, and verify with a request through the domain. That last one deserves emphasis. Verify through the domain, not the disk. "The file is correct on disk" told us nothing about what users were being served, and it's a comforting sentence that cost us an evening.
Going live without going live
We're pre-launch, so we wanted the domain fully attached — real TLS, real webhook deliveries from our payment provider, real everything — while keeping the general public out. The load balancer's listener rules made this clean. One rule forwards requests carrying our office IP (stamped into a header at the edge, so clients can't spoof it) to the app. A separate rule exposes the payment webhook path to the world, because webhooks must be reachable. Everyone else gets a fixed-response branded landing page.
One surprise: fixed-response bodies are capped at 1024 bytes. Our landing page fits in a kilobyte, tags and all — a delightful design constraint. When launch day comes, go-live is flipping those rules to forward for everyone: one API call, no deploy.
What we'd tell anyone doing the same
- Make every change additive. New target groups, new rules, new vhosts. If a step requires modifying the existing tenant's anything, redesign the step.
- Take your own lane: own port, own web root, own vhost, graceful reloads only.
- Turn on TLS from day one. A plain TCP client against a TLS-only endpoint doesn't error — it hangs, and hangs don't show up in logs.
- After deploying static files, reload nginx and verify through the domain. The disk is not the truth; the response is.
- Verify the other tenant after every step, forever. It's the cheapest insurance in the whole plan.
- An IP gate plus a fixed-response landing page is a genuinely good pre-launch setup: real infrastructure, zero public exposure, one-call go-live.
The whole move took a couple of days including the bug hunts, and it's been boring ever since — the highest compliment infrastructure can get. The other site never noticed we moved in, which was the whole point.
Micro Learner AI is in pre-launch, with early access coming soon. Follow along at microlearnerai.com.