Hetzner Cloud Deployment Guide¶
How to run maintenant on Hetzner Cloud: a single server with cloud-init, a private network for the agents, Hetzner Volumes for the database, and Kubernetes clusters built with the usual Hetzner tooling.
Why it fits a Hetzner box¶
maintenant is a single Go binary with the frontend embedded and SQLite as its default store. It idles under 30 MB of RAM and needs no database server, no Prometheus, no Redis. That makes it a natural fit for a small Hetzner Cloud server that already runs something else: it does not need a machine of its own.
The image is published for linux/amd64 and linux/arm64, so the Ampere-based CAX line works as well as the Intel/AMD CX and CPX lines.
| Server type | Arch | Typical fit |
|---|---|---|
| CAX11 / CX22 | arm64 / x86 | One host, up to ~50 containers and a few dozen endpoint checks. Comfortable alongside the monitored workload. |
| CPX21 / CAX21 | x86 / arm64 | Central server for a fleet of agents, or a busy update/CVE scan schedule. |
| CPX31 and up | x86 | Only needed if you move the store to PostgreSQL and monitor hundreds of containers. |
The bottleneck is I/O, not CPU
Resource sampling and endpoint checks are cheap. What grows is the SQLite database and the write rate on it. On a shared-vCPU server, keep the database on a Hetzner Volume rather than on the boot disk if you retain long history.
Step 1: Provision the server¶
The repository ships a ready-to-use cloud-config at deploy/cloud-init/maintenant.yaml. It installs Docker from the official repository, writes /opt/maintenant/compose.yml, and starts the stack on first boot.
hcloud server create \
--name maintenant \
--type cx22 \
--image ubuntu-24.04 \
--location nbg1 \
--ssh-key my-key \
--user-data-from-file deploy/cloud-init/maintenant.yaml
Once the server is up:
# The UI listens on loopback only: reach it through an SSH tunnel
ssh -L 8080:127.0.0.1:8080 root@$(hcloud server ip maintenant)
Open http://localhost:8080. Every container on the host is already discovered.
The Docker socket is root on this server
The cloud-config mounts /var/run/docker.sock. :ro only protects the socket file: the Docker API behind it still accepts writes, so whoever controls maintenant can start containers on this server. Before you open the dashboard to anyone else, put a read-only docker-socket-proxy in front of the socket. See Security: Docker socket proxy, and enable IMAGES on the proxy as well if you use update checks.
Hetzner's Docker app image
Hetzner also publishes a docker-ce app image. It works, but the cloud-config installs Docker
itself so the same file stays valid on a plain ubuntu-24.04 image and on a rescued or rebuilt
server. Pick one, not both.
Step 2: Lock the Hetzner Cloud Firewall¶
The cloud-config publishes the UI on 127.0.0.1:8080 only, so nothing is exposed before you decide it should be. A Cloud Firewall makes that explicit at the network edge:
hcloud firewall create --name maintenant
# SSH from your address only
hcloud firewall add-rule maintenant \
--direction in --protocol tcp --port 22 \
--source-ips "$(curl -s https://ipv4.icanhazip.com)/32" \
--description "ssh"
# HTTP/HTTPS for the reverse proxy that fronts maintenant
hcloud firewall add-rule maintenant \
--direction in --protocol tcp --port 80 \
--source-ips 0.0.0.0/0 --source-ips ::/0
hcloud firewall add-rule maintenant \
--direction in --protocol tcp --port 443 \
--source-ips 0.0.0.0/0 --source-ips ::/0
hcloud firewall apply-to-resource maintenant --type server --server maintenant
Note what is not in that list: port 8080. Publish the dashboard through a reverse proxy with authentication, never as a raw port on the public IPv4.
maintenant will flag its own exposure
If you change the Compose file to publish "8080:8080", maintenant's own network security
scanner reports a critical Port exposed on all interfaces finding for its own container.
That is the intended behaviour, not a bug. See the note in
Installation → Docker Compose
and Configuration → Choosing a Bind Address.
Behind a reverse proxy¶
Authentication is the proxy's job: maintenant has none. Security has working Traefik, Caddy and nginx setups. Two settings in /opt/maintenant/compose.yml make maintenant behave correctly behind it:
environment:
MAINTENANT_BASE_URL: "https://maintenant.example.com"
MAINTENANT_TRUSTED_PROXIES: "172.18.0.1"
MAINTENANT_BASE_URLis the public address. It defaults tohttp://0.0.0.0:8080here, which is what heartbeat ping URLs, status page subscriber links and the MCP OAuth issuer would otherwise carry.MAINTENANT_TRUSTED_PROXIESlists the addresses the proxy connects from. Empty, no forwarded header is read and every visitor is counted as the proxy, so they share one rate limit. A proxy on this server reaches the port published on127.0.0.1through the Docker network's gateway, so list that address (docker network inspect maintenant_default, fieldGateway), not127.0.0.1. See Running behind a proxy.
Step 3: Put the database on a Hetzner Volume¶
The boot disk of a CX22 is 40 GB and disappears with the server. A Volume survives a rebuild and can be resized, which matters once you keep months of history.
hcloud volume create \
--name maintenant-data \
--size 10 \
--server maintenant \
--format ext4 \
--automount
Hetzner mounts it at /mnt/HC_Volume_<volume-id>. Point the Compose volume at that path:
# /opt/maintenant/compose.yml
volumes:
- /mnt/HC_Volume_123456789/maintenant:/data
environment:
MAINTENANT_DB: "/data/maintenant.db"
You do not need to create or chown the maintenant directory: Docker creates it as root if it is missing, and the entrypoint hands it to uid 65534 at start.
The first boot already started maintenant on the named volume maintenant_maintenant-data, and the database created there does not follow the new mount: the next start opens an empty database at the new path. That is fine on a server you have just created. To keep what was collected, copy it across while the stack is stopped (cp -a keeps the ownership):
docker compose -f /opt/maintenant/compose.yml down
mkdir -p /mnt/HC_Volume_123456789/maintenant
cp -a /var/lib/docker/volumes/maintenant_maintenant-data/_data/. /mnt/HC_Volume_123456789/maintenant/
# edit compose.yml as above, then
docker compose -f /opt/maintenant/compose.yml up -d
Do this before you enrol any agent: the server's database holds their identities. Once maintenant runs on the volume, docker volume rm maintenant_maintenant-data removes the old copy.
Size the volume for migrations, not just for the data
Schema migrations rebuild tables in place and transiently need several times the size of the database. A volume sized to the current database will fail an upgrade. 10 GB is a sane floor.
Watching more than one host? Consider PostgreSQL
The server holds the agents' identity and enrolment, the one thing they cannot rebuild on their own. On the default SQLite file, losing that server means re-enrolling every host by hand. Hetzner has no managed PostgreSQL, but if you already run one, pointing the server at it makes the instance replaceable. See PostgreSQL storage.
Monitoring several Hetzner servers over the private network¶
A Hetzner Cloud Network is the right place for agent traffic: it never leaves Hetzner's network and costs nothing in egress.
hcloud network create --name backend --ip-range 10.0.0.0/16
hcloud network add-subnet backend \
--network-zone eu-central --type cloud --ip-range 10.0.0.0/24
hcloud server attach-to-network maintenant --network backend --ip 10.0.0.2
hcloud server attach-to-network web-01 --network backend --ip 10.0.0.3
Enrolling hosts needs the Personal edition
Remote hosts require the Personal edition or above (MAINTENANT_LICENSE_KEY). Personal covers up to 20 hosts, Pro has no cap. On Community the server does not open the agent port: it logs agent gRPC listener not started: agents need the personal edition or above.
The gRPC listener defaults to 127.0.0.1:8443. Inside the container that address cannot be reached from the network, for the same reason as the HTTP port. The server must listen on all interfaces in the container, and the private address must be published in the Compose file. Edit /opt/maintenant/compose.yml:
maintenant:
ports:
- "127.0.0.1:8080:8080"
+ - "10.0.0.2:8443:8443"
volumes:
+ - /opt/maintenant/tls:/etc/maintenant/tls:ro
- maintenant-data:/data
environment:
MAINTENANT_ADDR: "0.0.0.0:8080"
MAINTENANT_DB: "/data/maintenant.db"
+ MAINTENANT_LICENSE_KEY: "<your license key>"
+ MAINTENANT_GRPC_LISTEN: "0.0.0.0:8443"
+ MAINTENANT_GRPC_URL: "grpcs://maintenant.internal.example.com:8443"
+ MAINTENANT_GRPC_TLS_CERT: "/etc/maintenant/tls/server.crt"
+ MAINTENANT_GRPC_TLS_KEY: "/etc/maintenant/tls/server.key"
Publish on the private address (10.0.0.2), never as a bare 8443:8443. That address must exist on the host when the container starts, or Docker refuses it with cannot assign requested address. Apply with docker compose -f /opt/maintenant/compose.yml up -d.
The certificate and key are read at startup by uid 65534, so they must be readable by it, and a renewed certificate is only picked up when the container restarts. The certificate has to cover the name in MAINTENANT_GRPC_URL. A DNS-01 ACME certificate works for a name that only resolves privately, as long as the domain is yours. With a private CA instead, issue the server certificate from it and give the agents the CA, as shown below.
Then enrol each other server. In the web UI, open Agents and click Generate enrollment token: the modal shows the token once, with a ready-made command per environment and the server address taken from MAINTENANT_GRPC_URL. Run it on the host, over the private address:
docker run -d \
--name maintenant-agent \
--restart unless-stopped \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
-v /proc:/host/proc:ro \
-v /etc/os-release:/host/etc/os-release:ro \
-v maintenant-agent-data:/var/lib/maintenant \
ghcr.io/kolapsis/maintenant:latest \
--mode=agent \
--server=grpcs://maintenant.internal.example.com:8443 \
--enrollment-token=mnt_enr_XXXXXXXXXXXXXXXX
The token is consumed on first enrolment and cannot be retrieved again. With a private CA, add these two lines to the command (the generated one does not include them):
-e MAINTENANT_CA_CERT=/etc/maintenant/ca.pem \
-v /opt/maintenant/ca.pem:/etc/maintenant/ca.pem:ro \
The file must be readable by uid 65534: an unreadable or invalid CA makes the agent stop at start with failed to load extra CA bundle. The full walkthrough, including the Compose and Kubernetes variants, is in Agent Setup.
Private does not mean plaintext
Publishing on 10.0.0.2 keeps the listener off the public internet, but the agent still speaks
TLS. Use a certificate that covers the internal hostname. Do not reach for
--grpc-insecure-skip-tls-verify outside a lab. The full matrix of TLS modes is in
Agent Setup → Step 1.
No Cloud Firewall rule is needed for 8443: Cloud Firewalls filter the public interface, and the port is published on the private address only. Keep it out of the public rules.
Hetzner Load Balancer¶
A Load Balancer hides target failures by design: it removes an unhealthy target and the public endpoint keeps answering 200. Checking only the LB tells you nothing until every target is down.
Configure two layers of endpoint checks:
| Check | Target | What it tells you |
|---|---|---|
| Public | https://app.example.com (LB public IP / DNS) |
The service is reachable for users. |
| Per-target | http://10.0.0.3:8080/health, http://10.0.0.4:8080/health |
Which backend is actually down behind a still-green LB. |
The per-target checks run over the private network from the maintenant server, so they need no public exposure. Add them from Endpoints → New endpoint; see Endpoint Monitoring.
Certificates on managed Load Balancers
If the Load Balancer terminates TLS with a Hetzner-managed certificate, add the public hostname to TLS Certificate Monitoring. Managed renewal usually works, and the point is to be told when it does not.
Kubernetes on Hetzner¶
maintenant runs unchanged on the clusters produced by the usual Hetzner tooling: terraform-hcloud-kube-hetzner, vitobotta/hetzner-k3s, terraform-hcloud-talos or a Cluster API deployment.
helm install maintenant ./deploy/helm/maintenant \
-n maintenant --create-namespace \
--set persistence.storageClass=hcloud-volumes \
--set persistence.size=10Gi
hcloud-volumes is the storage class created by the Hetzner CSI driver, which all of the above install by default. Without it, the PVC stays Pending.
Talos and other socket-less nodes
Talos exposes no Docker socket. The chart already sets runtime: kubernetes (its default),
and inside a pod maintenant finds the in-cluster API on its own, so there is nothing to
configure. Monitoring happens at the workload and pod level, which is what you want on Talos
anyway.
Full RBAC, namespace filtering and Helm values are in the Kubernetes Guide.
Backups and snapshots¶
A Hetzner snapshot copies the disk while the database is being written to. For SQLite in WAL mode that is a torn copy, not a backup.
The image does not ship a sqlite3 client. Install one on the server and copy the database from the file behind the volume, which works while maintenant keeps running:
apt-get install -y sqlite3
sqlite3 -readonly /mnt/HC_Volume_123456789/maintenant/maintenant.db \
".backup '/root/maintenant-backup.db'"
# Then snapshot, or ship the file to a Storage Box
hcloud server create-image --type snapshot \
--description "maintenant $(date -u +%F)" maintenant
Without a client, stop the stack for the few seconds the copy takes and copy the database files together:
docker compose -f /opt/maintenant/compose.yml stop
mkdir -p /root/maintenant-$(date -u +%F)
cp -a /mnt/HC_Volume_123456789/maintenant/maintenant.db* /root/maintenant-$(date -u +%F)/
docker compose -f /opt/maintenant/compose.yml start
Where to put the copy
A Hetzner Storage Box over SFTP or BorgBackup is the cheapest off-server destination, and it is reachable from the private network on Cloud servers in the same region.
Related¶
- Installation: Docker, Kubernetes and source builds
- DigitalOcean Deployment: The same ground on DigitalOcean
- Scaleway Deployment: The same ground on Scaleway
- OVHcloud Deployment: The same ground on OVHcloud
- Vultr Deployment: The same ground on Vultr
- Agent Setup: Enrolling additional hosts over gRPC
- Kubernetes Guide: RBAC, Helm values, workload monitoring
- PostgreSQL Storage: Making the server replaceable
- Endpoint Monitoring: HTTP/TCP checks behind a Load Balancer