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

  1. Upload the certificate you have. certbot keeps it in /etc/letsencrypt/live/example.com/fullchain.pem and privkey.pem; Caddy in its data directory under certificates/; Traefik inside acme.json, which needs a small script to extract. In the Console, under Security → Certificates, upload the certificate and the key.
  2. 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.
  3. Enable “Redirect HTTP to HTTPS” on the HTTP service.
  4. Test through the balancer before DNS changes: curl -sS --resolve example.com:443:<balancer-ipv4> https://example.com/health.
  5. Switch DNS (next step), wait for propagation.
  6. 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.

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

Connect read-only