A mid-sized SaaS company came to us with a familiar complaint: the cloud bill kept growing faster than the user base. Monthly infrastructure spend had crossed EUR 14,000 for a product serving around 40,000 active users, and the engineering team of six could not explain where most of it went. They had adopted Kubernetes two years earlier, during a growth phase, on the advice of a contractor who no longer worked with them.
This is a common starting point for cloud cost optimization services engagements. Not because Kubernetes is bad technology, but because it is frequently adopted for problems a team does not have yet, and then nobody goes back to check whether the assumptions still hold.
The situation: a growth-stage SaaS team outgrowing its own tooling
The product was a B2B analytics platform: a Laravel API, a Vue frontend, a PostgreSQL primary with one read replica, Redis for queues and caching, and a handful of background workers processing webhook events and scheduled reports.
The entire stack ran across three managed Kubernetes clusters on a major public cloud: one for production, one for staging, one described internally as "experiments" that nobody could confidently say was still needed. Each cluster had its own node pools, its own ingress controller, its own cert-manager instance, and its own Prometheus and Grafana stack.
The team's complaints were specific:
- Deploys took 8 to 12 minutes and regularly failed at the Helm chart stage with unclear errors.
- Two of the six engineers understood the Kubernetes manifests well enough to debug them under pressure.
- Autoscaling never triggered as expected, so the team had set static replica counts "to be safe," defeating the point of having it.
- Nobody had a clear answer for what happened if the cloud account's Kubernetes control plane had an incident.
None of this had caused an outage yet. But the team was spending roughly 15 hours a week across the group just keeping the clusters healthy, which is a real cost even when it does not show up as a line item.
What we found during the audit
We started with two weeks of observation before touching anything: resource requests versus actual usage, node utilization over a full business cycle, and a line-by-line pass through the cloud invoice.
The numbers were stark:
- Average CPU utilization across all production nodes: 11%.
- Average memory utilization: 23%.
- Peak traffic, measured over 90 days, never exceeded what a single well-sized server could handle for the API tier.
- The three clusters combined ran 42 pods for a workload that, once we mapped it out, consisted of exactly 6 logical services.
- Node pools were provisioned for a worst case that had never occurred: a traffic spike 8x above the observed peak, based on a sales projection from 18 months earlier that had not materialized.
We also found cost sitting in less visible places: three separate managed load balancers, duplicated logging pipelines sending the same data to two destinations because of an incomplete migration, and a staging cluster running 24/7 at near-production size, used actively for maybe six hours a week.
None of this was negligence. It was the normal result of a team scaling infrastructure decisions for a future that hadn't arrived, then not revisiting them once it became clear it wouldn't arrive that way. This is the same pattern we described in overprovisioning vs right-sizing: provisioning for a peak is sometimes correct, but only if the peak is still realistic.
The approach we took and why
Kubernetes solves real problems: multi-team deployment isolation, workloads that genuinely need to scale from zero to hundreds of pods, or infrastructure shared across many independent services with different owners. This team had none of those. They had six services, six engineers, and a release cadence of two deploys a week.
We proposed collapsing to a simpler architecture: a small fleet of dedicated servers behind a load balancer, running the application via Docker Compose and systemd, with PostgreSQL and Redis managed directly rather than as in-cluster StatefulSets. The deciding factors, in order:
- Operational match. A six-person team with two Kubernetes-literate engineers was running infrastructure built for a team of thirty. Reducing the surface area meant every engineer could debug production, not just two.
- Cost shape. Kubernetes control planes, redundant node pools, and managed add-ons carry fixed overhead regardless of load. A workload at 11% utilization pays that overhead for nothing.
- Failure modes they could reason about. Systemd restarting a crashed process is something every engineer on the team already understood. Debugging a CrashLoopBackOff caused by an interaction between a readiness probe and an HPA was not.
We were explicit with the client that this was not a universal recommendation. If they had been running 20+ independently deployed services, or needed to scale a specific component to hundreds of replicas during traffic events, we would have kept Kubernetes and fixed the sizing instead. The decision followed the workload, not a philosophy.
Implementation details: what we actually changed
We ran the migration in phases over six weeks, treating it as a zero-downtime migration rather than a cutover weekend.
Phase 1: baseline and right-sizing the target
Based on the observed peak (not the theoretical one), we sized the new environment at:
- 2x application servers (8 vCPU, 16 GB RAM) behind HAProxy, running the API and frontend containers via Docker Compose.
- 1x PostgreSQL primary (8 vCPU, 32 GB RAM) with a streaming replica for failover and read traffic.
- 1x Redis instance with persistence enabled, replacing the in-cluster Redis StatefulSet.
- 2x worker nodes for queue processing, scaled by systemd timers and a simple job-count check rather than a Kubernetes HPA.
This is roughly a third of the node count the Kubernetes setup used, because it no longer had to account for pod scheduling overhead, duplicated system pods per node, or headroom for a scaling event that never happened.
Phase 2: deployment pipeline
We replaced the Helm-based CI/CD pipeline with a straightforward build-and-rsync deploy:
deploy:
stage: deploy
script:
- docker build -t app:$CI_COMMIT_SHA .
- docker save app:$CI_COMMIT_SHA | gzip | ssh deploy@app-01 'docker load'
- ssh deploy@app-01 'docker compose up -d --no-deps app'
- ./scripts/healthcheck.sh app-01
- docker save app:$CI_COMMIT_SHA | gzip | ssh deploy@app-02 'docker load'
- ssh deploy@app-02 'docker compose up -d --no-deps app'
HAProxy was configured to take each node out of rotation during its deploy window, so the rolling update kept the API available throughout:
backend app_servers
balance roundrobin
option httpchk GET /health
server app-01 10.0.1.10:8080 check inter 2s fall 2 rise 3
server app-02 10.0.1.11:8080 check inter 2s fall 2 rise 3
Phase 3: database and cache
PostgreSQL moved out of the cluster onto dedicated hardware with tuned parameters for the workload (shared_buffers at 25% of RAM, proper connection pooling via PgBouncer, which the in-cluster setup had been missing entirely). Redis moved to a standalone instance with AOF persistence, since it had previously been an ephemeral pod that lost queue state on every pod restart. That queue data loss had been an open bug for four months before the audit.
Phase 4: monitoring and runbooks
We replaced the three separate Prometheus and Grafana stacks with a single monitoring instance and wrote runbooks for the five most common failure scenarios: database failover, worker backlog, disk pressure, certificate renewal (handled via the approach in our SSL automation setup), and application rollback.
Results: the numbers six months on
| Metric | Before (Kubernetes) | After (dedicated servers) |
|---|---|---|
| Monthly infrastructure cost | EUR 14,200 | EUR 7,650 |
| Deploy time | 8-12 minutes | 90 seconds |
| p95 API response time | 410ms | 190ms |
| Engineers who can debug production | 2 of 6 | 6 of 6 |
| Weekly ops time (team-wide) | ~15 hours | ~4 hours |
| Uptime (trailing 6 months) | 99.91% | 99.97% |
The cost reduction, 46%, came almost entirely from eliminating redundant control plane overhead, collapsing three environments' worth of load balancers and logging pipelines into one, and right-sizing compute to actual usage instead of a hypothetical spike.
The p95 improvement was a secondary effect we did not initially promise the client. Removing a layer of network overlay (CNI routing, service mesh sidecars, kube-proxy iptables rules) took real latency off every request. Fewer hops, fewer places for a packet to wait.
The uptime improvement came from removing single points of confusion. In the old setup, an on-call engineer facing a failed pod often spent the first 20 minutes figuring out whether the problem was the application, the scheduler, the node, or the ingress controller. In the new setup, a failed health check and a systemd restart are the entire story most of the time.
What we'd do differently next time
We would push harder, earlier, for workload classification before any infrastructure decision. In this engagement, the first two weeks of pure observation delayed the actual fix, but they were also what made the client trust the recommendation. In hindsight, we could have run a lighter, one-week version of that audit and started phase 1 in parallel.
We would also set an explicit review trigger up front: a note in the infrastructure documentation saying "revisit this decision if services exceed 15, or if any single service needs more than 10 replicas under normal load." Architecture decisions age. Writing down the condition that should trigger a rethink is cheaper than waiting for someone to notice the bill.
One thing we got right: we did not tell this team Kubernetes was a mistake. It likely made sense for a hypothetical growth path that didn't materialize. Removing it was not a correction, it was keeping the infrastructure honest about the business it's actually running.
When Kubernetes is still the right call
To be clear about the boundary: if you are running dozens of independently owned services, need per-team deployment isolation, or have workloads with genuinely volatile scaling needs (a video processing queue that goes from 2 to 200 workers in minutes, for example), Kubernetes earns its complexity. The pattern we see repeatedly is teams adopting it for the scaling story while running a workload shaped more like a handful of stable services on predictable traffic. That is a high availability infrastructure problem with a much simpler solution.
Facing a similar challenge? Tell us about your setup and we will outline an approach.