1. IIS → Docker + Kubernetes
The starting point was a Windows monolith on IIS, deployed by hand, with no observability and a release cadence gated by how long a manual deploy took. Customer growth was capped less by the product and more by how nervous everyone was to ship. We moved it to Docker + Kubernetes on an on-prem cluster (~15 nodes, 100+ containers on Hyper-V) and cut release cycle time by roughly 60% with zero-downtime deployments.
What broke
The application was never written to be stateless. The first real outage in staging came from in-process session state that quietly assumed a single host — the moment there were two replicas behind an ingress, users bounced between pods and lost their sessions. The fix wasn't glamorous: externalize session and cache state (Redis), make the container genuinely restart-safe, and treat "can this pod be killed at any instant?" as an acceptance criterion rather than an afterthought. Windows-container base image sizes were the second tax — pull times were long enough to matter to rollout speed until we got the layering and multi-arch builds right.
What didn't break
The business logic. The instinct going into a migration like this is to "fix everything while we're in here." We didn't rewrite the app — we containerized it as-is and changed the environment around it. That restraint is the reason it shipped. The rewrite temptation is where these projects go to die.
What I'd undo
I'd wire up observability before the cutover, not after. We deployed Prometheus, Grafana, and Loki as part of the migration, but for the first couple of weeks we were flying with less visibility than we should have had during the single riskiest window. Metrics and logs are cheapest to install when nothing is on fire.
2. TeamCity + Octopus Deploy → Azure DevOps
The legacy CI/CD stack was TeamCity for builds and Octopus for deploys, spread across multiple product lines. We consolidated onto Azure DevOps — 200+ pipelines by the end. The goal wasn't novelty; it was one place to reason about builds, releases, and approvals instead of three.
What broke
Hidden coupling. A surprising amount of "how we ship" lived in TeamCity build-step configuration and Octopus variables that nobody had written down. Re-expressing those as pipeline YAML forced every implicit assumption into the open — which is the point, but it meant the first few migrated pipelines were slower to land than estimated because we were reverse-engineering intent, not just translating syntax.
What didn't break
Developers' day-to-day. Because we migrated pipeline-by-pipeline rather than flipping everything at once, most engineers only noticed when their product line moved. A big-bang cutover would have turned a manageable migration into a company-wide incident.
What I'd undo
I'd templatize sooner. We initially ported pipelines one at a time, which meant the same fixes got applied 200 times. Investing in shared pipeline templates earlier would have paid for itself long before pipeline number fifty.
3. Sequential branches → label-driven GitOps
This is the migration I'm proudest of. The old model was a sequential branch topology with manual cherry-picking between release and main, hand-written ServiceNow change tickets, and ECS deploys done by hand. Release errors were frequent and slow to attribute. We replaced it with a label-driven GitOps flow: labels on a PR drive the pipeline. Add fbe and a full-stack ephemeral environment (ECS + RDS + S3 + SQS + SNS) gets provisioned via Terraform; add staging and it deploys to staging; merge to main and the pipeline auto-opens a ServiceNow change request, authenticates to AWS with OIDC (no long-lived keys), deploys, and closes the CR. Result: roughly 70% fewer release errors and PR environment setup down from days to minutes.
What broke
Cost, at first. Ephemeral environments are wonderful right up until you have a dozen of them idling over a weekend. The failure mode wasn't technical — it was the AWS bill. The answer was scheduled teardown: a Friday-night job that destroys every FBE, plus scale-to-zero on non-prod. Ephemeral has to actually be ephemeral or it's just more always-on infrastructure with extra steps.
What didn't break
Change management. I expected the ServiceNow integration to be the fragile part — automating a compliance process usually is. But modeling the CR lifecycle as something the pipeline opens and closes in lockstep with the deploy turned out to be more reliable than humans filing tickets, because the pipeline never forgets and never back-dates.
What I'd undo
Nothing structural — but I'd document the label contract on day one. The labels (fbe, staging, prod) are the interface to the whole deployment system, and for a while that contract lived in my head and a pinned message. A system this powerful deserves a README, not tribal knowledge.
The through-line
Three different migrations, one repeated lesson: change the environment, not the thing running in it, and change it in slices you can roll back. The wins came from restraint — containerize as-is, migrate pipeline-by-pipeline, let labels drive the flow — and the scars all came from the parts I tried to leave for "later": observability, templates, documentation. Later is more expensive than it looks when the system is already live.