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. Read it per server with
# `hcloud server metrics --type cpu --start <time> --end <time> <server>` 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. Removing a target stops new connections; open ones continue for up to five minutes (same support answer). 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 five-minute limit; short request-response traffic needs less. And a server must never be deleted while it is still a target: deletion cuts every connection at once.

The billing hour. Hetzner bills each server per started hour, capped at the monthly price. A server deleted one minute into its fourth hour costs four hours. The scale-in step should start draining a few minutes before the next hour boundary, and if that boundary cannot be met, keep the server until the 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 after each action 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 per started 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.

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. 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