Micro Learner AI Blog

Moving a PHP App onto a Shared EC2 Without Touching the Other Tenant

2026-07-23 · Micro Learner AI team

Aisle between rows of server racks in a data center

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

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.