# You don't need Kubernetes. You need a good Dockerfile and a place to run it.

Every few months a team of three to five engineers asks us whether they should move to Kubernetes. The application is a web front-end, an API, a couple of workers and a database. It runs on one or two servers, or on a platform-as-a-service that's getting expensive. Someone has read that Kubernetes is how real companies do it, and they'd like to be a real company.

The answer we give, almost every time, is no. Not "not yet". No. And since that answer sounds like laziness or contrarianism, this article is the reasoning behind it, the alternative we'd build instead, what it costs, and the specific conditions under which the answer changes.

## What Kubernetes is for

Kubernetes solves scheduling: given a fleet of machines and a set of workloads with resource requirements, place the workloads on the machines, keep them running, replace them when they die, and expose them to each other and to the world. It solves this extremely well, at any scale, for any kind of workload, with an ecosystem for everything you could want.

Notice what the problem statement requires: a fleet of machines. If you don't have a fleet, you don't have the scheduling problem, and Kubernetes becomes a very sophisticated answer to a question you didn't ask. A team with three services doesn't need bin-packing. It needs its containers to run, restart, scale from two to six when there's traffic, and get an HTTPS URL.

## What you get with a managed cluster anyway

"Managed Kubernetes" (EKS, GKE, AKS) manages the control plane. It does not manage the rest, and the rest is where the time goes. For a production cluster you will own:

- Node groups, their instance types, their AMI updates, their autoscaling.
- Networking: a CNI, an ingress controller, a load balancer per ingress or a shared one, cert-manager for TLS, external-dns for records.
- Observability: metrics-server, a logging agent, and everything you'd have needed anyway.
- Security: pod security standards, network policies, IRSA or workload identity for AWS access, image scanning, an admission controller if you're serious.
- Upgrades: Kubernetes releases three times a year and managed providers drop support for old versions about a year after release. Every upgrade touches the API server, the nodes and, occasionally, the manifests, because something got deprecated.
- Deployment tooling: Helm or Kustomize, an environments strategy, and probably ArgoCD or Flux once "kubectl apply from a laptop" has bitten you once.

None of this is hard for people who've done it. All of it is a permanent tax on a team that hasn't, and on a team of three it's a person. We've watched it happen: the strongest engineer becomes the cluster engineer, the product velocity halves, and eighteen months later they have a beautiful platform under a product that stopped growing.

<div class="article-figure">
<svg viewBox="0 0 900 280" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Two columns comparing what a team owns. Left, managed Kubernetes: node groups and AMI updates, CNI and ingress controller, cert-manager and external-dns, metrics-server and logging agent, pod security and network policies, workload identity, three upgrades a year, Helm or Kustomize plus ArgoCD, about one engineer of ongoing work. Right, containers on a managed runtime such as App Runner or Fargate: a Dockerfile, a health route, a task or service definition in CDK, autoscaling settings, the same observability agent, about a day a month of ongoing work.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="20" y="20" width="420" height="240" rx="14" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/>
<text x="230" y="46" text-anchor="middle" fill="#ff6b8a" font-size="14" font-weight="700">Managed Kubernetes · what you still own</text>
<g fill="#f1f3ff">
<text x="40" y="74">node groups · AMIs · autoscaler</text>
<text x="40" y="96">CNI · ingress controller · load balancers</text>
<text x="40" y="118">cert-manager · external-dns</text>
<text x="40" y="140">metrics-server · logging agent</text>
<text x="40" y="162">pod security · network policies · workload identity</text>
<text x="40" y="184">3 upgrades a year · deprecations in manifests</text>
<text x="40" y="206">Helm or Kustomize · ArgoCD or Flux</text>
</g>
<text x="230" y="244" text-anchor="middle" fill="#ff6b8a" font-weight="700">≈ one engineer, permanently</text>
<rect x="460" y="20" width="420" height="240" rx="14" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/>
<text x="670" y="46" text-anchor="middle" fill="#4fffb0" font-size="14" font-weight="700">Containers on a managed runtime · what you own</text>
<g fill="#f1f3ff">
<text x="480" y="74">a Dockerfile per service</text>
<text x="480" y="96">a health route that says "ready" when it's ready</text>
<text x="480" y="118">a service definition in CDK: image, size, min/max</text>
<text x="480" y="140">autoscaling on requests or CPU</text>
<text x="480" y="162">the same observability agent as a sidecar or a layer</text>
<text x="480" y="184">TLS, load balancing, node OS: not yours</text>
<text x="480" y="206">upgrades: change the base image tag</text>
</g>
<text x="670" y="244" text-anchor="middle" fill="#4fffb0" font-weight="700">≈ a day a month</text>
</g>
</svg>
</div>

## What we build instead

A good Dockerfile, and a managed runtime that takes an image and gives back a URL. On AWS that's App Runner for the simple case and ECS Fargate when you need a VPC-internal service or more control; the equivalents on the other clouds are Cloud Run and Container Apps, and the argument is identical.

**The Dockerfile** is where the engineering effort goes, and it's effort that would have been spent on Kubernetes too: multi-stage build, a non-root user, a pinned base image, a `HEALTHCHECK`, no secrets baked in, an image under 200 MB so cold starts are fast. We've [written about the Node version of this](/en/blog/docker-images-for-node-in-2026-distroless-multi-stage). Get it right once and it's the same file on any runtime, including Kubernetes if you ever do move.

**The runtime** does what the cluster would have: pulls the image, runs N copies, health-checks them, replaces the dead ones, scales between min and max, terminates TLS, routes traffic. It does it with a service definition of about twenty lines of CDK, and it does it without a node OS for you to patch.

**The rest of the stack** doesn't change. Aurora or DynamoDB for data, SQS for queues, Lambda for the truly event-shaped things, the OpenTelemetry collector for observability. Nothing in that list ever needed a cluster.

## The cost, honestly

The usual defence of Kubernetes on cost is that Fargate and App Runner charge a premium per vCPU-hour over raw EC2. True. Here's what that premium buys at the scale in question, for three services with two warm instances each in production:

| | Managed Kubernetes (EKS) | Containers on App Runner / Fargate |
|---|---|---|
| Control plane | $73 / month | $0 |
| Nodes / compute | 3 × m6i.large ≈ $210, mostly idle for headroom | 6 × 1 vCPU / 2 GB ≈ $250 at App Runner rates |
| Load balancer | 1 ALB ≈ $25 + LCUs | included (App Runner) or the same ALB (Fargate) |
| Add-ons that consume node capacity | ingress, cert-manager, DNS, metrics, logging ≈ 1 node's worth | none |
| Upgrades and patching | 3 cluster upgrades / year, node AMI rotation monthly | base image tag bump |
| Engineering time | ≈ 0.5–1 FTE | ≈ 0.05 FTE |
| **Monthly, all in** | **~$310 + $5,000–10,000 of engineer** | **~$275 + ~$500 of engineer** |

The infrastructure lines are within $50 of each other. The engineering line is the whole comparison. At three services, Kubernetes is not more expensive to run; it's more expensive to *own*, by an order of magnitude, and the premium per vCPU-hour is noise against that.

## When the answer changes

We're not against Kubernetes. We run it, for clients who need it. The answer flips when one of these is true, and it usually takes two:

- **Many services, many teams.** Above roughly fifteen to twenty services with several teams deploying independently, the managed runtimes' per-service configuration becomes its own sprawl, and a cluster with namespaces, quotas and a GitOps controller is genuinely simpler.
- **Workloads that don't fit the runtime's shape.** Requests longer than the runtime's timeout, GPUs, stateful sets, daemons that need host access, custom networking. App Runner's 120-second limit is real; if your workload needs 20 minutes of websocket, you've outgrown it.
- **Steady, large compute.** At hundreds of vCPUs running around the clock, the per-vCPU premium is no longer noise and reserved nodes in a cluster are materially cheaper. This is the cost crossover, and it's much higher than people assume.
- **Portability as a requirement.** If a contract says "must be deployable to the customer's own cluster", the runtime is Kubernetes because the customer's is. This is the situation with [our agent platform](/en/blog/agent-factory-platform), which is why it ships as Helm charts and why we know the tax first-hand.
- **You already have the person.** If someone on the team has run production clusters and wants to keep doing it, the ownership cost drops and the calculation changes. Be sure they want to, and be sure what happens when they leave.

<div class="article-figure">
<svg viewBox="0 0 900 200" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Decision flow. Do you have more than fifteen services and several teams, or workloads that do not fit a managed runtime such as GPUs, long-lived connections or daemons, or hundreds of steady vCPUs, or a contractual requirement to deploy into a customer's cluster? If any is true, Kubernetes, with a named owner. If none is true, containers on a managed runtime: App Runner, Fargate, Cloud Run or Container Apps, and revisit in a year.">
<defs><marker id="arrK" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#7b8cff"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="20" y="40" width="380" height="120" rx="12" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/>
<text x="210" y="64" text-anchor="middle" fill="#f1f3ff" font-weight="700">Is any of these true?</text>
<text x="40" y="88" fill="#9aa3c7">· 15+ services, several teams deploying independently</text>
<text x="40" y="106" fill="#9aa3c7">· GPUs, long-lived connections, daemons, custom networking</text>
<text x="40" y="124" fill="#9aa3c7">· hundreds of vCPUs, steady, around the clock</text>
<text x="40" y="142" fill="#9aa3c7">· contract says "deploy into the customer's cluster"</text>
<line x1="402" y1="80" x2="518" y2="80" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrK)"/><text x="460" y="72" text-anchor="middle" fill="#9aa3c7">yes, at least one</text>
<line x1="402" y1="130" x2="518" y2="130" stroke="#7b8cff" stroke-width="1.5" marker-end="url(#arrK)"/><text x="460" y="150" text-anchor="middle" fill="#9aa3c7">none</text>
<rect x="520" y="50" width="360" height="56" rx="12" fill="#0d1120" stroke="#ff6b8a" stroke-width="1.5"/><text x="700" y="73" text-anchor="middle" fill="#ff6b8a" font-weight="700">Kubernetes, with a named owner</text><text x="700" y="93" text-anchor="middle" fill="#9aa3c7" font-size="11">budget the person before the cluster</text>
<rect x="520" y="114" width="360" height="56" rx="12" fill="#0d1120" stroke="#4fffb0" stroke-width="1.5"/><text x="700" y="137" text-anchor="middle" fill="#4fffb0" font-weight="700">a good Dockerfile + a managed runtime</text><text x="700" y="157" text-anchor="middle" fill="#9aa3c7" font-size="11">App Runner · Fargate · Cloud Run · Container Apps · revisit in a year</text>
<text x="450" y="192" text-anchor="middle" fill="#9aa3c7">The Dockerfile is the same in both branches. Nothing you build on the bottom branch is wasted if you later take the top one.</text>
</g>
</svg>
</div>

## The objection: "but we'll have to migrate later"

Maybe. Here's what a later migration from a managed runtime to Kubernetes actually involves, for a team that built it the way described: the Dockerfiles are unchanged; the health routes are unchanged; the environment variables and secrets references become a ConfigMap and an ExternalSecret; the twenty lines of CDK per service become forty lines of Helm values. The observability agent moves from a sidecar to a DaemonSet with the same config. It's a two-week project for someone who's done it, and by the time you need it you'll have the person, because needing it is what justifies hiring them.

What isn't recoverable is the eighteen months of a small team's attention spent on a cluster it didn't need. That's the cost we're actually arguing about.

## The short version

Kubernetes is a scheduler for fleets. If you don't have a fleet, run your containers on something that gives you a URL, put your effort into the Dockerfile and the health check, and revisit the question when you have fifteen services, a GPU, or a customer who insists. Until then, the most senior engineer on your team should be building the product.

If you're deciding this right now and want a second opinion that isn't selling either answer, [talk to us](/contact).
