How to autoscale a web application on Hetzner Cloud without Kubernetes

Hetzner has no auto scaling group and you do not need Kubernetes to build one. What autoscaling needs on plain servers, the loop by hand, and where it bites.

Author
Published
Last updated
Tags
guide, hetzner, autoscaling, no-kubernetes

Hetzner Cloud has no auto scaling group. You can still autoscale a web application on Hetzner without Kubernetes: you need a stateless application, a load balancer with a health check, a snapshot to make new servers from, and a loop that adds a server when load is high and removes one when it is low. This article walks through the four parts, builds the loop by hand with the hcloud command line, and then lists the places where the hand-made loop bites, because those are the parts worth automating.

What autoscaling needs

Autoscaling means the number of servers follows the load. For that to work without a scheduler like Kubernetes, four things have to be true.

  1. The application is stateless. The database, sessions, uploads and caches live somewhere other than the server that will be copied and deleted. Anything on the server’s disk is lost when the server goes.
  2. A load balancer with a health check sits in front. It is the only way to add a server without touching DNS and to take one away without dropping requests. On Hetzner that is the Hetzner Cloud Load Balancer (€7.49 per month for the smallest type, Hetzner list price, September 2026) or Cloudflare Load Balancing if you are already behind the Cloudflare proxy.
  3. A new server can bring up your application on its own. A snapshot of a working server is the simplest way; cloud-init, a CI pipeline or Coolify are the others.
  4. Something decides. It reads CPU and request metrics, compares them to a rule, creates or deletes a server, and does both in the right order.

Kubernetes gives you the fourth part and most of the machinery around it, at the price of running Kubernetes. For one stateless service on two to ten servers, the four parts fit in an afternoon of setup and a script.

Step 1: make the server disposable

Check what lives on the disk. Run docker volume ls and read your docker-compose.yml for bind mounts; grep the application config for local paths. The usual suspects and where they go:

On the disk today Where it goes
PostgreSQL, MySQL, MongoDB A separate Hetzner server or a managed database
Sessions in files Redis on a separate server, or the database
Uploads in storage/ or media/ S3-compatible object storage, Hetzner Object Storage too
File cache Redis, or no shared cache at all
TLS certificates The load balancer (next step)

Until this is done, autoscaling is not possible and the rest of the article does not apply. Sticky sessions on the load balancer are not a fix; they hide the problem until the first server is removed.

Step 2: put a load balancer in front

Create a Hetzner Cloud Load Balancer in the same network zone as your servers, add your server as a target over the private network, give it an HTTP service with a health check on your application’s health endpoint, move TLS from the server to the balancer, and point DNS at the balancer. The step-by-step version, including how to move the certificate without downtime, is Put your Docker Compose app behind a Hetzner Load Balancer.

Two settings matter later. The health check (interval, timeout, retries) is what will decide when a new server may receive traffic and when an old one is considered dead. The private network is how the balancer reaches the servers, so the public ports on the servers can be closed.

Step 3: turn the server into a template

A Hetzner snapshot is a copy of the whole disk that new servers can boot from:

hcloud server create-image --type snapshot --description "web release 1" web-1

Then a second server from it:

hcloud server create --name web-2 --type cx33 --image <snapshot-id> \
  --location nbg1 --network app --ssh-key <key-name> --label role=web

When it boots, Docker starts and your containers come up if their restart policy is unless-stopped or always. Nothing else is needed on the server; the application is already configured, because the snapshot is the disk of a server that was already configured.

Three things to know about snapshots:

  • They contain everything on the disk, including .env files and any secret the application keeps locally. The snapshot stays in your Hetzner project, so this is only a concern if strict secret handling is required; in that case build new servers from a registry image with cloud-init or CI instead.
  • They are bound to the CPU architecture. A snapshot of an x86 server (CX, CPX, CCX) cannot boot an ARM server (CAX), and the other way round.
  • They are billed per GB, and creating one takes longer the bigger the disk is. Keep the server small: logs rotated, build caches out.

Every deploy from now on means a new snapshot, or a rolling replacement of the running servers from the new one. Servers that boot from an older snapshot run an older version of your application; treat the snapshot as the release.

Step 4: the control loop, by hand

Here is the loop in its simplest form. It reads the CPU of each server, decides, and acts.

#!/bin/sh
# Sketch, not production code: no cooldown, no locking, no error handling.
LB=web-lb
MIN=2
MAX=6

count=$(hcloud server list -l role=web -o noheader | wc -l)
# Average CPU of the group over the last 3 minutes, as a whole number from 0 to 100. Read it per
# server with `hcloud server metrics --type cpu --start <time> --end <time> <server>`, divide by
# the server's vCPU count (see "What we measured on Hetzner" below) and average the samples.
cpu=$(./avg-cpu.sh)

if [ "$cpu" -gt 75 ] && [ "$count" -lt "$MAX" ]; then
  name="web-$(date +%s)"
  hcloud server create --name "$name" --type cx33 --image <snapshot-id> \
    --location nbg1 --network app --ssh-key <key-name> --label role=web
  hcloud load-balancer add-target "$LB" --server "$name" --use-private-ip
elif [ "$cpu" -lt 35 ] && [ "$count" -gt "$MIN" ]; then
  victim=$(hcloud server list -l role=web -o columns=name -o noheader | tail -n 1)
  hcloud load-balancer remove-target "$LB" --server "$victim"
  sleep 60
  hcloud server shutdown "$victim"
  sleep 30
  hcloud server delete "$victim"
fi

Run it from cron every minute and you have autoscaling. It scales out when CPU is above 75% and in when it is below 35%, between two and six servers. The numbers are the same defaults we use; scale-in is deliberately stricter than scale-out.

It also has at least eight problems, and each of them is a real incident waiting to happen.

Where the hand-made loop bites

Health before traffic. Adding a target does not mean the server is ready. Hetzner’s load balancer sends no traffic to a new target until it passes the health check (Hetzner support confirmed this to us in September 2026), which is good, but the loop above does not know whether the check ever passed. A server that boots into a broken state sits there, counted as capacity, receiving nothing, and the loop adds another one next minute. The loop has to wait for the balancer’s per-target health status and give up after a deadline.

Drain. The sleep 60 above is a guess at how long your requests take. Long uploads, WebSocket or server-sent events need more, up to the load balancer’s own cut-off; short request-response traffic needs less. And a server must never be deleted while it is still a target, nor without a graceful shutdown first: what the load balancer does with open connections in both cases is measured below.

The billing hour. Hetzner bills each server by the hour, capped at the monthly price, so the moment a server leaves decides what it costs, and the loop above deletes whenever CPU drops. The scale-in step should time the drain to the billed hour, measured below, and if that moment has passed, keep the server until its next one: the hour is paid either way, so the extra capacity is free.

Stock. hcloud server create can fail with resource_unavailable when the server type is out of stock in that location; CX and CAX types were out of stock in EU locations at times in September 2026. The loop needs a fallback: another location in the same network zone (targets must share the balancer’s zone), then another type of the same architecture, then “wait and tell someone”.

Deleting the wrong server. tail -n 1 picks a victim by name order. One day it picks the server you set up by hand, the one with the legacy price from before Hetzner’s price adjustment of 15 June 2026, which a new server can never get back. Every server the loop may delete should carry labels the loop wrote itself, and the loop should refuse to delete anything else.

Flapping. Without a cooldown before scale-in and a warm-up after a new server becomes healthy, the loop scales out, sees the average drop because the new server reports near-zero CPU while it warms up, scales in, and repeats.

Missing metrics. A rate-limited or failed metrics call returns nothing; awk prints a division by zero or an empty string; the comparison does whatever the shell decides. A scaling decision should never be made on missing, stale or partial data, and “no metrics” must never be read as “no load”.

Nobody knows why. At 03:00 a server disappears. Was it scale-in, a failed health check, a stock fallback, or a bug? The loop should write one line per action with the trigger, the metric values, the rule that fired, and the range it was allowed to act in.

There is a ninth point that is not a bug in the loop but a decision: one server is not enough. With a minimum of one, a failed server means an outage until the replacement is healthy. With a minimum of two, one server carries the load while the other is replaced. The second server is the price of self-healing.

What it costs

The load balancer is the fixed cost: €7.49 per month for the smallest type (September 2026), whether you scale or not. Servers are billed by the hour, so a server that runs six hours a day for your daily peak costs roughly a quarter of one that runs all month, and one that runs two hours on launch days costs a fraction of that.

Whether autoscaling saves money depends on the shape of your load, not on the tool. A steady site with a peak at 1.3× the average gains nothing from scaling and should simply run one size up. A site with business-hour peaks at two times the average, or campaign spikes at three, can run its baseline on fewer servers and rent the peak by the hour. Prices for new servers went up on 15 June 2026 (a CX33 is €8.49 per month for new orders) while existing servers kept their old price, so the saving is smaller than it was and the server you already have is worth keeping.

The calculator runs this arithmetic on Hetzner’s dated list prices for your server type, your fleet size and a traffic shape you set (peak hours, peak days, how much busier), and shows the assumptions. For small, steady setups it says so: the reasons to add a loop there are capacity and healing, not cost.

What we measured on Hetzner (September 2026)

We measured these in a Hetzner project of our own in September 2026, and Hetzner support confirmed the billing rule.

CPU is a sum over the vCPUs. Hetzner’s CPU series is the sum over a server’s vCPUs, 100 per vCPU: a 4-vCPU server at full load reads 400 %. A threshold written as a percentage only works after dividing by the vCPU count; read raw, a 75 % rule fires at 37.5 % real load on a 2-vCPU server and at 18.75 % on a 4-vCPU one. Hetscale divides before any rule reads it (what a scaling policy measures).

Removing a target cuts open connections after five minutes. remove_target finishes within a second. Open connections survive it and the load balancer sends no new ones, though a new request can still reach the server one to two seconds after the removal finished. The load balancer cuts the remaining connections about five minutes later, finished or not (302 s in our test). A server deleted without a graceful shutdown leaves its clients waiting until the load balancer’s 50 s idle timeout (how Hetscale removes a server).

Label-selector targets work, with one catch. They report per-target health like explicit targets. A labelled server is a member as soon as its create finishes and stays unhealthy until its checks pass, and a server joins or leaves within about seven seconds of the label changing. The catch: every server that carries the label gets traffic as soon as it passes the health check, a half-configured one included. That is why an autoscaler may prefer to add targets one by one, explicitly, as the loop above does (how a node joins the load balancer).

The billed hour counts from creation. Hetzner counts a server’s hour from its creation, not by the clock hour, and bills every started hour until the delete is answered. Each calendar month is rounded on its own: a server living from 23:50 to 00:10 across a month change pays two hours. So remove a server just before its hour is complete, not just after (when in the hour a server leaves).

Where Hetscale fits

The loop above, with the eight problems solved, is what Hetscale is: a control plane that reads your metrics, calls the Hetzner API in your project, waits for your load balancer’s health check before a server counts, drains before it deletes, aligns deletion with the billing hour, follows a fallback when a type is out of stock, deletes only servers carrying its own labels, and writes the reason for every action. Your traffic goes from your load balancer to your servers; it never passes through us, and we hold no SSH key to your servers.

It starts read-only. You connect a Hetzner API token with Read permission, which Hetzner itself prevents from creating or deleting anything, and for 14 days Hetscale records what it would have done with your real load. If the setup above is what you have, the Docker Compose case describes the path from one server to a group of two to four, and the quickstart has the four steps.

Frequently asked questions

Do I need Kubernetes to autoscale three web servers? No. Kubernetes is the right tool for many services with different scaling and scheduling needs. For one stateless service, a load balancer, a snapshot and a loop do the same job with far less to operate.

Can I keep Docker Compose? Yes. Each new server boots from the snapshot and Docker’s restart policy brings the containers up. Nothing about how you run the application changes; only where the database, sessions and uploads live.

What about the database? It does not scale with this method, and it should not be on a server that can be deleted. Put it on its own server or a managed service first. If the database is your bottleneck, more web servers will not help, and this article is not for you yet.

How fast does a new server become available? We do not publish a number until we have measured it on customers’ own setups. The time is dominated by snapshot size and by your health check’s interval and retries, which are your settings; we will publish measured ranges per method once we have them.

Is it cheaper than one big server? Sometimes. Run your numbers in the calculator; for steady load the honest answer is “no, but a dead server gets replaced”.

See what Hetscale would have done with your real data — connect read-only, get your report in minutes.

Connect read-only