{
    "componentChunkName": "component---src-templates-blog-post-js",
    "path": "/the-scaling-ladder-a-production-setup-that-scales-by-not-scaling-yet/",
    "result": {"data":{"site":{"siteMetadata":{"title":"catch { snail }","author":"Andrew"}},"markdownRemark":{"id":"5f74840e-0fa5-58a1-ae4d-e1097ef649d2","excerpt":"Since the terraform destroy post, the most common question in my inbox is some version of: “cute, but will your €5 setup scale?” I think that’s the wrong…","html":"<p>Since the <a href=\"/i-ran-terraform-destroy-on-my-own-production-on-purpose\">terraform destroy post</a>, the most common question in my inbox is some version of: <em>“cute, but will your €5 setup scale?”</em></p>\n<p>I think that’s the wrong question, and this guide is about the right one. Because here’s the dirty secret of every “scalable production architecture” diagram: <strong>you don’t get taken down by load. You get taken down by the rewrite you scheduled for the week the load arrived.</strong></p>\n<p>So the metric I design for isn’t requests per second. It’s <strong>minutes to the next rung</strong> — how long it takes to climb one step up when I actually need to. Scalability isn’t a building you construct upfront. It’s a series of doors you deliberately leave unlocked.</p>\n<p>Here’s the ladder, rung by rung, with each unlocked door pointed out.</p>\n<h2 id=\"rung-0-the-5-baseline-you-are-here\" style=\"position:relative;\"><a href=\"#rung-0-the-5-baseline-you-are-here\" aria-label=\"rung 0 the 5 baseline you are here permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Rung 0: the €5 baseline (you are here)</h2>\n<p>Recap for new readers: <a href=\"/terraform-destroy-ritual\">Terraform</a> builds a Hetzner VPS and DNS, cloud-init installs <a href=\"/swarm-to-k3s\">k3s</a>, and the cluster pulls its manifests from git. The entire company — population: me — fits in one repo, and the whole environment rebuilds from zero in under five minutes, which I know because I destroy it on purpose quarterly.</p>\n<p>That ritual matters for this post: <strong>a rehearsed rebuild is also a rehearsed migration.</strong> Every rung below is cheap precisely because reconstruction is a habit, not an emergency.</p>\n<h2 id=\"rung-1-the-app-must-be-a-leaf-not-a-root\" style=\"position:relative;\"><a href=\"#rung-1-the-app-must-be-a-leaf-not-a-root\" aria-label=\"rung 1 the app must be a leaf not a root permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Rung 1: the app must be a leaf, not a root</h2>\n<p>Nothing else on this ladder works unless the application itself is stateless: config from environment variables, sessions in a store rather than in memory, nothing precious on local disk, logs to stdout. Old news — the <a href=\"https://12factor.net/\">12-factor</a> checklist is a decade old — but it’s the rung people skip and then blame Kubernetes.</p>\n<p>The test is brutal and simple: <code class=\"language-text\">kubectl scale deployment app --replicas=3</code>. If anything breaks — sessions vanish, uploads disappear, a cron job runs three times — you found state hiding in your app. Evict it now, while it’s small.</p>\n<p><strong>Door unlocked:</strong> horizontal scaling becomes a number in a YAML file instead of a project.</p>\n<h2 id=\"rung-2-shrink-what-counts-as-state\" style=\"position:relative;\"><a href=\"#rung-2-shrink-what-counts-as-state\" aria-label=\"rung 2 shrink what counts as state permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Rung 2: shrink what counts as state</h2>\n<p>The Terraform post’s lesson keeps paying rent: after a destroy/rebuild, everything that came back automatically was never really state — it was <em>opinion in git</em>. Scaling is the same audit. Manifests, certs, DNS, the cluster itself: opinions. What’s left — the database and user uploads — is the actual state, and the strategy is to keep that pile as small and as boring as possible.</p>\n<p>Concretely:</p>\n<ul>\n<li><strong>Uploads go to object storage from day one</strong> (Hetzner Storage Box, Backblaze B2, whatever’s cheap). S3-compatible APIs are the closest thing infrastructure has to a universal adapter, and this is the cheapest optionality purchase on the entire ladder.</li>\n<li><strong>The database gets a thin seam.</strong> I don’t mean an abstraction layer for switching databases — you won’t. I mean: connection string from env, schema in migration files, backups tested by <em>restoring</em> them (restic + a monthly restore drill). SQLite→Postgres, or self-hosted Postgres→managed Postgres, are then evenings, not quarters.</li>\n</ul>\n<p><strong>Door unlocked:</strong> the only hard migration in your future is one small, well-mapped pile instead of “the server.”</p>\n<h2 id=\"rung-3-the-cluster-grows-by-invitation\" style=\"position:relative;\"><a href=\"#rung-3-the-cluster-grows-by-invitation\" aria-label=\"rung 3 the cluster grows by invitation permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Rung 3: the cluster grows by invitation</h2>\n<p>The reason I picked k3s over docker-compose finally cashes out here. Adding a worker node is:</p>\n<div class=\"gatsby-highlight\" data-language=\"bash\"><pre class=\"language-bash\"><code class=\"language-bash\"><span class=\"token comment\"># on the new €5 box (which Terraform created with count = 2)</span>\n<span class=\"token function\">curl</span> -sfL https://get.k3s.io <span class=\"token operator\">|</span> <span class=\"token assign-left variable\">K3S_URL</span><span class=\"token operator\">=</span>https://snail-01:6443 <span class=\"token assign-left variable\">K3S_TOKEN</span><span class=\"token operator\">=</span><span class=\"token variable\">$TOKEN</span> <span class=\"token function\">sh</span> -</code></pre></div>\n<p>That’s it — the node joins, the scheduler starts using it. And when the control plane itself needs to survive a node death, recent k3s can run an embedded etcd cluster across three servers instead of single-node SQLite. Same binary, same manifests, zero application changes.</p>\n<p>This is the rung that justifies the YAML tax from last summer: <strong>compose scales a machine; an orchestrator scales a fleet, and the manifests can’t tell the difference.</strong></p>\n<p><strong>Door unlocked:</strong> capacity becomes <code class=\"language-text\">count = N</code> in Terraform plus one join command.</p>\n<h2 id=\"rung-4-the-front-door-gets-a-receptionist\" style=\"position:relative;\"><a href=\"#rung-4-the-front-door-gets-a-receptionist\" aria-label=\"rung 4 the front door gets a receptionist permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Rung 4: the front door gets a receptionist</h2>\n<p>Right now DNS points at the node. One Terraform resource swaps that for a Hetzner load balancer pointing at <em>all</em> nodes, health checks included. Because rung 1 made the app stateless and rung 3 made nodes interchangeable, this is genuinely just a routing change — the LB is boring <em>because</em> the lower rungs did their jobs.</p>\n<p><strong>Door unlocked:</strong> nodes become anonymous. The uptime alert stops being about any particular machine.</p>\n<h2 id=\"rung-5-observability-or-receipts\" style=\"position:relative;\"><a href=\"#rung-5-observability-or-receipts\" aria-label=\"rung 5 observability or receipts permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Rung 5: observability, or, receipts</h2>\n<p>You can’t climb a ladder you can’t see. Prometheus + Grafana for metrics, Loki for logs — the kube-prometheus-stack Helm chart makes this an afternoon, and on a small cluster it costs more RAM than it has any right to, and it’s still worth it.</p>\n<p>Here’s the framing that made me stop treating monitoring as a chore: <strong>capacity plans are beliefs, and metrics are how beliefs meet evidence.</strong> Same epistemology as <a href=\"/terraform-destroy-ritual\"><code class=\"language-text\">terraform plan</code></a>, same failure mode as <a href=\"/playing-with-gpt2\">a language model inventing APIs</a> — a system confidently describing a reality it’s never checked. “We’ll scale when we need to” is a hallucination until a dashboard says when <em>need</em> is. My rule: I climb a rung when a graph tells me to, never when anxiety does.</p>\n<p><strong>Door unlocked:</strong> scaling decisions become diffs against data instead of vibes.</p>\n<h2 id=\"what-the-ladder-refuses-to-include\" style=\"position:relative;\"><a href=\"#what-the-ladder-refuses-to-include\" aria-label=\"what the ladder refuses to include permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>What the ladder refuses to include</h2>\n<p>No multi-region, no service mesh, no autoscaling, no microservices. Not because they’re bad — because each is a <em>building</em>, not a door, and buildings are premature until a graph screams. The most scalable decision I’ve made all year is the stuff I declined to install. Premature scaling is the root of all cloud bills.</p>\n<p>And the honest coda: I’m on rung 0 and expect to stay here. The ladder’s real product was never capacity — it’s that every rung is <em>rehearsed</em>, so growth is a checklist and not a crisis. I sleep well next to an unlocked door.</p>\n<h2 id=\"next\" style=\"position:relative;\"><a href=\"#next\" aria-label=\"next permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Next</h2>\n<p>The itch from October remains: git already holds the manifests, so why am I the one running <code class=\"language-text\">kubectl apply</code>? The GitOps rabbit hole is officially scheduled.</p>\n<p>Also, a certain API invite finally showed up in my inbox this week. The silly benchmark gets its rematch. Stay tuned.</p>","frontmatter":{"title":"The scaling ladder: a production setup that scales by not scaling yet","date":"February 14, 2021","description":"A guide to building small production systems where every upgrade is a swap, not a rewrite"}}},"pageContext":{"slug":"/the-scaling-ladder-a-production-setup-that-scales-by-not-scaling-yet/","previous":{"fields":{"slug":"/i-ran-terraform-destroy-on-my-own-production-on-purpose/"},"frontmatter":{"title":"I ran terraform destroy on my own production (on purpose)"}},"next":{"fields":{"slug":"/the-rematch-my-fine-tuned-gpt-2-vs-a-frozen-giant-with-five-examples/"},"frontmatter":{"title":"The rematch: my fine-tuned GPT-2 vs. a frozen giant with five examples"}}}},
    "staticQueryHashes": ["3435731857","426816048","63159454"]}