Part 1 of a series on ten years of deployment evolution for one legacy app on AWS — no Kubernetes required. Series index.
I have deployed the same application in four fundamentally different ways over ten years. It is a workforce-management product — a ~10-year-old PHP/Node monolith on EC2, three environments, a DR region, and a small platform team. Same business logic at the core, same database it has always talked to, but the machinery that gets code from a git tag onto a server has been rebuilt three times.
I inherited the first version. I built the other three. That last part is the part I want to be honest about, because it is the part that makes this interesting rather than triumphant: I replaced my own work twice. The blue/green system I was proud of four years ago, I tore out. The split-AMI system I built to fix that, I collapsed a few weeks later. Neither replacement happened because the previous thing was wrong. Each one was right for the constraints it was born under, and each one created the next set of constraints.
That is the through-line of this whole series. Deployment architecture does not evolve from wrong to right. It evolves as a re-balancing of which pains you are willing to carry. When scale, team size, and circumstances change, the pain you optimized away last time stops being the one that hurts, and a different one moves to the front. This article walks the four generations in order, and each section ends with the one thing that generation taught me.
The version I inherited was the version a lot of small AWS shops still run, and there is no shame in that. Three EC2 hosts, each a long-lived pet. Deploys ran through AWS CodeDeploy pulling a revision onto the box and running lifecycle hook scripts. It worked. People had shipped features on it for years.
The problems were the kind you only notice once the fleet or the team grows past a certain size.
Every host was a snowflake. Configuration (the .env files, the tuning, the little manual fixes) lived on the hosts and nowhere else. If a box died, the knowledge died with it. There was no authoritative copy of "what is the correct state of a production server" anywhere except the production server. That is a terrifying thing to realize during an outage.
Runtime upgrades were manual. New PHP, new Node, new anything meant SSHing into each host and doing it by hand, which meant Test, Stage, and Prod drifted apart in ways nobody could fully account for. Library dependency conflicts showed up because two things on the same host wanted two different versions of the same underlying package, and there was no isolation to keep them apart.
And the network layout was a liability. The three hosts sat in the public subnet of the default VPC, each with its own Elastic IP, spread across only two availability zones. Every host was directly addressable from the internet, every host had a distinct public IP that external parties had to whitelist individually, and losing an AZ meant losing half the fleet. HA and security were compromised by the same decision.
None of this was incompetence by the people before me. It was a system that had grown by accretion, one reasonable small step at a time, until the sum of the steps was a machine nobody could confidently reason about.
What this generation taught me: the real cost of pet servers is not the servers. It is that the only correct copy of your system lives on the thing most likely to fail.
I led the migration off Generation 1 about four years ago, and it was a large piece of work. This was not a tweak. It was a new network, a new build system, a new deploy model, and a new source of truth, all at once.
The goals were explicit and I still stand by them: every EC2 machine should be disposable and stateless, the fleet should scale up and down with traffic, and releases should be zero-downtime. To get there:
.env on boot and be identical to its siblings.