Put your Docker Compose app behind a Hetzner Load Balancer
Create a Hetzner Load Balancer, add a health check, move TLS from the server to the balancer without downtime, point DNS at it. One server today, more later.
- Author
- Hetscale
- Published
- Last updated
- Tags
- guide, hetzner-load-balancer, docker-compose, tls
Putting a Docker Compose application behind a Hetzner Load Balancer is the one piece of work between a single server and a pool of servers you can grow, replace or shrink. It takes one to two hours: a private network, a plain HTTP port on the server, the load balancer with a health check, TLS moved from the server to the balancer, and DNS pointed at the balancer. When you are done, your server is a target, and a second server is a snapshot away. This guide is for the setup most people have: one server, Docker Compose, a reverse proxy with Let’s Encrypt on the box.
What changes
Today, traffic hits your server on ports 80 and 443, and a proxy container (Caddy, Traefik or nginx) terminates TLS and forwards to the application. After this guide:
- Traffic hits the load balancer on 80 and 443. It terminates TLS with a certificate Hetzner manages and renews.
- The balancer forwards plain HTTP to your server over the private network. The server’s public ports 80 and 443 can be closed.
- The balancer runs a health check against your application every few seconds and only sends traffic to servers that pass it.
The application does not change. The proxy container either goes away or keeps running as a
plain HTTP router. What must be true before you start: the database, sessions and uploads are
not on this server’s disk, or you are fine with a fixed pool for now; and the application has a
health endpoint, a URL that returns 200 when the application can serve requests. /health or
/up is common. If it does not exist, add one first; it should be fast and should not depend on
the database, or the whole pool goes unhealthy together when the database hiccups.
Step 1: a private network for the server
Hetzner Cloud Load Balancers reach their targets over a private network or over public IPs.
Private is simpler to secure and free of charge. Create a network, a subnet in the network zone
your server is in (eu-central for Falkenstein, Nuremberg and Helsinki), and attach the server:
hcloud network create --name app --ip-range 10.0.0.0/16
hcloud network add-subnet app --type cloud --network-zone eu-central --ip-range 10.0.1.0/24
hcloud server attach-to-network web-1 --network app
The server gets a private address such as 10.0.1.2. Ubuntu picks it up on the next network
restart or reboot; ip addr shows it.
Step 2: serve plain HTTP on the server
The load balancer will talk HTTP to the server, so the server must answer HTTP without redirecting to HTTPS. Two ways:
Keep the proxy container, turn off TLS. In Caddy, change the site address from
example.com to :80 (or http://example.com) so it stops requesting certificates. In Traefik,
remove the websecure entrypoint and the certificate resolver from the router. In nginx, keep the
listen 80 server block and drop the listen 443 ssl one. Publish only port 80.
Drop the proxy container. If it did nothing but TLS, publish the application’s port
directly. In docker-compose.yml:
services:
app:
image: ghcr.io/you/app:1.4.2
restart: unless-stopped
ports:
- '80:8080'
Either way, check from the server itself:
curl -sS -o /dev/null -w '%{http_code}\n' http://10.0.1.2/health
A 200 here is what the balancer will look for.
One thing to fix now rather than later: the application will no longer see HTTPS on its own
socket. Frameworks that build absolute URLs or redirect to HTTPS need to be told that the balancer
handles it. Log one request after the switch and look at which X-Forwarded-* headers arrive;
then configure the framework’s trusted-proxy setting (Laravel TrustProxies, Django
SECURE_PROXY_SSL_HEADER, Express trust proxy, Rails config.force_ssl with
assume_ssl). If the scheme header does not reach you, tell the framework to assume HTTPS
outright, for example APP_URL=https://example.com and URL::forceScheme('https') in Laravel.
Step 3: create the load balancer and add the server
In the Cloud Console, create a load balancer in the same location as your server, or with the command line:
hcloud load-balancer create --name web-lb --type lb11 --location nbg1
hcloud load-balancer attach-to-network web-lb --network app
hcloud load-balancer add-target web-lb --server web-1 --use-private-ip
lb11 costs €7.49 per month (Hetzner list price, September 2026) and takes up to 25 targets and
5 services, which is more than a small application will use. The target must be in the balancer’s
network zone: a server in Ashburn (us-east) cannot be a target of a balancer in Nuremberg
(eu-central), while Falkenstein, Nuremberg and Helsinki servers can all share one balancer.
--use-private-ip makes the balancer talk to the server’s 10.0.1.2 address. The balancer also
has a public IPv4 and IPv6 of its own; those are what DNS will point at.
Step 4: add the HTTP service and the health check
Add a service with protocol HTTP, listen port 80, destination port 80 (or whatever the server publishes):
hcloud load-balancer add-service web-lb --protocol http --listen-port 80 --destination-port 80
Then, in the Console, open the service and edit its health check. The settings and the values we recommend for a web application:
| Setting | Range | Recommended | Why |
|---|---|---|---|
| Protocol | HTTP | HTTP | Checks the application, not just the port |
| Path | /health |
Your endpoint from above | |
| Status codes | 200 |
Anything else is unhealthy | |
| Interval | 3–60 s | 5 s | How often each target is checked |
| Timeout | 1–60 s | 5 s | How long one check may take |
| Retries | 1–5 | 2 | Failures in a row before a target is marked unhealthy |
Interval times retries is how long a dead server keeps receiving traffic before the balancer notices: 10 seconds with the values above, 45 seconds with the defaults. It is also the shortest time a new server needs before it can receive traffic, because a new target gets none until it has passed the check (Hetzner support confirmed this to us in September 2026).
Leave proxy protocol off unless your proxy container is configured for it. Leave sticky sessions off; if your application needs them, it has state on the server, which is the problem from the introduction, not something to paper over here.
The target’s health is visible in the Console under the balancer’s targets, per target, with the last status code. Wait until it says healthy before the next step.
Step 5: move TLS to the balancer without downtime
A managed certificate from Hetzner is issued through Let’s Encrypt and needs the domain to point at the balancer already. If you switch DNS first and then request the certificate, HTTPS fails for the minutes in between. The order that avoids that:
- Upload the certificate you have. certbot keeps it in
/etc/letsencrypt/live/example.com/fullchain.pemandprivkey.pem; Caddy in its data directory undercertificates/; Traefik insideacme.json, which needs a small script to extract. In the Console, under Security → Certificates, upload the certificate and the key. - Add an HTTPS service on the balancer: protocol HTTPS, listen port 443, destination port 80, with the uploaded certificate. Give it the same health check as the HTTP service.
- Enable “Redirect HTTP to HTTPS” on the HTTP service.
- Test through the balancer before DNS changes:
curl -sS --resolve example.com:443:<balancer-ipv4> https://example.com/health. - Switch DNS (next step), wait for propagation.
- Create a managed certificate for the domain, swap it into the HTTPS service, delete the uploaded one. From here Hetzner renews it.
Why not pass TLS through (a TCP service on 443 to the server’s 443) and keep the certificate on the box? It works for one server. With two, Let’s Encrypt’s HTTP challenge lands on whichever server the balancer picks, and renewals fail at random. The balancer is the right place for the certificate as soon as there is more than one server, so put it there now.
Step 6: point DNS at the balancer
Lower the TTL of the A and AAAA records a day ahead if you can. Then change them to the
balancer’s public IPv4 and IPv6. Requests arrive at the balancer, pass the health-checked service,
and reach your server over the private network.
If your domain is behind the Cloudflare proxy (orange cloud), this still works: Cloudflare forwards to the balancer’s address. But if you want Cloudflare itself to balance and health-check several origins, that is Cloudflare Load Balancing, a different setup with a different prerequisite list; the behind Cloudflare case has it.
Step 7: close the server’s public ports
The balancer reaches the server over the private network, and Hetzner Cloud Firewalls filter only
the public interface, so a firewall that allows only SSH from your addresses closes 80 and 443 to
the internet without affecting the balancer. Apply it, then check that the site still loads and
that curl http://<server-public-ip>/ times out.
Also stop whatever renewed the old certificate on the box. A certbot timer that can no longer complete its challenge will only fill your logs.
What you can do now
Your server is a target. The things that were impossible with DNS pointing at one machine are now one command each:
- A second server from a snapshot of the first, added as a target, receiving traffic only after it passes the same health check.
- Removing a server without dropping requests: remove the target, wait for open connections to finish, then delete.
- Replacing a server that stops answering the health check with a fresh one from the snapshot.
Doing that by hand, and where the hand-made version goes wrong, is How to autoscale a web application on Hetzner Cloud without Kubernetes. Whether it would pay for itself with your traffic is in the calculator; for a steady site the honest answer is that the balancer buys you healing, not savings. If you want the loop run for you, the Docker Compose onboarding case starts exactly where this guide ends, with a read-only trial that reads your last 30 days of metrics before anything is created.
Things that go wrong
The site redirects to http://. The application does not know it is behind TLS. See the
trusted-proxy note in step 2.
The target flaps between healthy and unhealthy. The health endpoint is slow or touches the database. Make it answer from memory in milliseconds, and raise the timeout only as a last resort.
“Target not in network zone”. The balancer and the server are in different zones. Balancers and targets must share one; create the balancer where the server is.
Managed certificate stuck in “pending”. DNS does not point at the balancer yet, or the HTTP service on port 80 is missing; Let’s Encrypt needs it for the challenge.
Uploads break on the second server. Not a balancer problem: uploads are on the first server’s disk. Move them to object storage before adding a server.
Frequently asked questions
Do I pay for the balancer even with one server? Yes, €7.49 per month (September 2026). It buys you the health check, the managed certificate, and the ability to add or replace a server without a DNS change. If you are certain you will never run a second server, it buys you less.
Can I use Cloudflare instead of a Hetzner balancer? The Cloudflare proxy alone does not health-check or balance several origins; Cloudflare Load Balancing does, as a paid add-on. If you are already on Cloudflare, that is a reasonable path; see the behind Cloudflare case.
Do I need the private network? Not strictly; the balancer can target the server’s public IP. Then port 80 must stay open to the balancer, and you have to keep a firewall rule for it. Private is less to get wrong.
Does the balancer support IPv6? Yes. It has a public IPv6 address; add an AAAA record for
it alongside the A record.
What about WebSockets and long requests? They pass through the HTTP service. When a server is removed as a target, its open connections continue for up to five minutes before the balancer cuts them (Hetzner support, September 2026); plan removals with that window in mind.