Skip to content

Configuration

Everything is set from the UI and stored in one file, /var/lib/haproxy-manager/config.json (mode 0600 — it holds the API key, the session secret, the peers' keys and the password hash). There is no database behind it — no PostgreSQL, no Redis — so that one file is the whole state: back it up, copy it to new hardware, and you have moved the installation. HAProxy's and Keepalived's own configuration files are generated from it on Apply; editing them by hand is pointless, because the next Apply overwrites them (keeping a .bak).

The model

A service is the unit you work with: one or more public URLs, a pool of real servers behind them, and optionally a health check and a certificate. Publishing one creates the underlying HAProxy objects — a frontend binding, a host-matching condition, a routing rule, a backend and its servers.

The advanced pages expose those objects directly if you need them. Anything a published service owns is marked service there, and its editor says which service and warns that publishing that service again rebuilds it. Ownership is worked out from what the services actually reference, so it stays right even after objects are edited or rebuilt. Anything the wizard created is marked as such, and hand-edits to those objects survive: the wizard updates in place.

Nothing reaches HAProxy until you press Apply, which renders the configuration, validates it with haproxy -c, writes it only if valid, and reloads. A configuration that does not validate is never written.

Publishing a service

The publish wizard, started from a recipe

Services → Publish a service. The form is in sections. The first four are always there; the other five each open with one checkbox and show their settings only once it is ticked — and an unticked section is off, whatever its fields still hold.

Section Settings
Service Public URLs: https://shop.example.com, several one per line — they must share a scheme and port — a path (/api) routes only that prefix, and tcp://0.0.0.0:3306 forwards a raw port. Forward to: 10.0.0.5:8080, or name=10.0.0.5:8080 to name the server; several make a pool. A name, and whether to apply at once.
Certificate HTTPS only. Reuse an existing one that covers the name (a wildcard included), always request a new one, or none; the ACME account and challenge for a new one; redirect HTTP to HTTPS.
Health check Ticked by default. TCP connect, HTTP request, SSL, PostgreSQL login or MySQL/MariaDB login, with its interval and the check's own settings; a separate check port; logging of up/down changes; Alert when — any server lost (default), only a full outage (for pools where one passing server is the design, like Patroni), or never.
Balancing and timeouts Round robin, least connections, source, static round robin or URI hash. Stickiness: none, source-IP (a stick table with its type, size and expiry) or cookie. Connect and server timeouts for this pool.
Allowed networks Off unless ticked. One address or CIDR per line; requests from anywhere else are refused, TCP services included.
Rate limiting Off unless ticked. Requests per client address over a sliding window (10 s unless changed); a client over it gets a 429 (HTTP) or is dropped (TCP) until it slows down.
Sign-in Off unless ticked, HTTPS only. Basic authentication: the groups admitted, the prompt, and the networks that skip it.
Single sign-on (OIDC) Off unless ticked, HTTPS only. The identities allowed, and whether the signed-in email is passed on to the servers.

Recipes

The wizard asks for a dozen settings, and for a well-known service there is one right answer for nearly all of them. Start from a recipe fills those in and leaves the two things only you know: the name to publish and the servers behind it.

Web

Recipe What it sets up
Web application An ordinary HTTP or HTTPS site with a health check on /
Web application with WebSockets As above, but connections are allowed to stay open for hours

Databases

Recipe What it sets up
Apache Solr HTTP 8983 checked with the ping handler
Cassandra / ScyllaDB TCP 9042 for CQL clients
ClickHouse HTTP 8123 checked with /ping
CockroachDB TCP 26257 with the check aimed at the HTTP readiness endpoint
CouchDB HTTP 5984 checked with /_up
etcd HTTP 2379 checked against /health
InfluxDB HTTP 8086 checked against /health
Kafka (single broker) TCP 9092 to one broker
MariaDB / MySQL — Galera cluster TCP 3306 with every client pinned to one node
MariaDB / MySQL — single server TCP 3306 with a MySQL login check
Meilisearch HTTP 7700 checked with /health
Memcached TCP 11211 with a connection check
MongoDB TCP 27017 with a connection check
Neo4j (Bolt) TCP 7687 for the Bolt protocol
PostgreSQL — Patroni, read-only TCP 5433 spread across the replicas, never the leader
PostgreSQL — Patroni, writes TCP 5432 to whichever node is the leader, found by asking Patroni
PostgreSQL — single server or streaming replica TCP 5432 with a PostgreSQL login check
Qdrant HTTP 6333 checked with /healthz
Redis / Valkey TCP 6379 with a connection check
Typesense HTTP 8108 checked with /health
VictoriaMetrics HTTP 8428 checked with /health
Weaviate HTTP 8080 checked against its readiness endpoint
ZooKeeper TCP 2181 for clients

Applications

Recipe What it sets up
Actual Budget HTTP 5006 with clients pinned for sync
Alertmanager HTTP 9093 checked with /-/healthy
Apache Guacamole HTTP 8080 with sessions pinned to one node
Argo CD HTTP 8080 checked with /healthz
Audiobookshelf HTTP 13378 checked against /healthcheck
Authelia HTTP 9091 checked against /api/health
authentik HTTP 9000 checked against its liveness endpoint
Bambuddy (Bambu Lab printers) HTTP 8080 with live print status kept open
Bitwarden (official server) HTTP 8080 for the self-hosted Bitwarden stack
BookStack HTTP 80 checked against /status
Calibre-Web HTTP 8083 for the library
Channels DVR HTTP 8089 with timeouts sized for streaming a recording
Checkmk HTTP 5000 for the interface
Consul HTTP 8500 checked against the leader endpoint
Directus HTTP 8055 checked against /server/health
Discourse HTTP 3000 checked with /srv/status
Docker registry HTTP 5000 with room to push a large image
Dockge HTTP 5001 with the WebSocket its interface needs
Duplicati HTTP 8200 for the backup interface
Elasticsearch / OpenSearch HTTP 9200 checked against the cluster health endpoint
ERPNext / Frappe HTTP 8000 with sessions pinned
ESPHome HTTP 6052 with log streaming kept alive
File Browser HTTP 80 checked with /health
Firefly III HTTP 8080 checked with /health
Frigate NVR HTTP 5000 with camera streams kept open
Gatus HTTP 8080 checked with /health
Gerrit HTTP 8080 checked against its version API
Ghost HTTP 2368 for publishing
Gitea / Forgejo HTTP 3000 with room for a large clone
GitLab HTTP with the timeouts a git push needs
Gotify HTTP 80 with the message WebSocket kept open
Grafana HTTP 3000 checked against its own health endpoint
Graylog HTTP 9000 checked against the endpoint meant for load balancers
Grocy HTTP 80 for household management
Harbor registry HTTP with Harbor's health endpoint and room for large layers
HashiCorp Vault HTTP 8200 to the active node, found by its own health endpoint
Healthchecks HTTP 8000 for cron monitoring
Home Assistant HTTP 8123, with the WebSocket the interface depends on
Homebox HTTP 7745 checked against its status endpoint
Homebridge HTTP 8581 for the management interface
Homepage / Homarr HTTP 3000 for a dashboard
Immich HTTP 2283 with timeouts sized for uploading a phone's library
Jellyfin / Emby HTTP 8096 with timeouts long enough to watch a film
Jenkins HTTP 8080, sticky, with room for long builds
JFrog Artifactory HTTP 8082 checked with its system ping
Joplin Server HTTP 22300 checked with /api/ping
Karakeep / Hoarder HTTP 3000 for bookmarks
Kavita HTTP 5000 checked against its health API
Keycloak HTTP 8080 checked on its separate management port
Kibana HTTP 5601 checked against its status API
Kimai HTTP 8001 for time tracking
Komga HTTP 25600 checked with its actuator
Kopia HTTP 51515 for the backup interface
Lemmy HTTP 8536 for the API and federation
LibreNMS HTTP 8000 for the interface
Loki HTTP 3100 checked with /ready
Mailpit HTTP 8025 for the interface, checked with /readyz
Mainsail / Fluidd (Klipper) HTTP 80 with Moonraker's WebSocket kept open
Mastodon HTTP 3000 checked with /health
Matomo HTTP 80 for analytics
Matrix (Synapse) HTTP 8008 checked against the client API
Mattermost HTTP 8065, sticky, with WebSockets
Mealie HTTP 9000 checked against its about endpoint
Miniflux / FreshRSS HTTP 8080 checked with /healthcheck
MinIO / S3-compatible storage HTTP with MinIO's liveness endpoint and long timeouts
Moodle HTTP 80 with sessions pinned
n8n HTTP 5678 checked with /healthz
Navidrome HTTP 4533 checked with /ping
Netdata HTTP 19999 checked against its info endpoint
Nextcloud HTTP with long timeouts and clients pinned to one server
NocoDB HTTP 8080 checked against /api/v1/health
Node-RED HTTP 1880 with the editor's WebSocket kept open
ntfy HTTP 80 with subscriptions held open
OctoPrint HTTP 5000 with the print stream kept open
Odoo HTTP 8069 with sessions pinned
Ollama HTTP 11434 with timeouts long enough to generate an answer
Open WebUI HTTP 8080, sticky, with streaming replies
Outline HTTP 3000 checked with /_health
Overseerr / Jellyseerr HTTP 5055 checked against its status API
Owncast HTTP 8080 with the stream and chat kept open
Paperless-ngx HTTP 8000 with WebSockets for the consumption status
PeerTube HTTP 9000 with room to upload and watch
pgAdmin HTTP 80 checked with /misc/ping
PhotoPrism HTTP 2342 checked against its status API
PhotoStructure HTTP 1787 with timeouts sized for a library import
phpMyAdmin HTTP 80 with the session kept on one server
Planka / Focalboard HTTP 3000 with live board updates
Plausible HTTP 8000 checked with /api/health
Plex Media Server HTTP 32400 with timeouts long enough to watch a film
Portainer HTTPS 9443 passed through, with the console WebSocket kept open
Prometheus HTTP 9090 checked against /-/healthy
qBittorrent HTTP 8080 for the web interface
RabbitMQ (AMQP) TCP 5672 for AMQP clients
Rancher HTTPS 443 passed through to a self-signed backend
Redmine HTTP 3000 with sessions kept in place
Rocket.Chat HTTP 3000 checked against /api/info
Roundcube HTTP 80 with the session kept on one server
SABnzbd HTTP 8080 for the web interface
Scrypted HTTPS 10443 passed through, with camera streams kept open
Seafile HTTP 8000 with room to sync
Snipe-IT HTTP 80 for asset management
SonarQube HTTP 9000 checked against its system status
Sonarr / Radarr / Lidarr HTTP 8989 checked with /ping
Sonatype Nexus HTTP 8081 checked against its status endpoint
Stirling PDF HTTP 8080 for PDF tools
Tandoor Recipes HTTP 8080 for the recipe manager
Tautulli HTTP 8181 checked with /status
TeamCity HTTP 8111 with build logs streaming
Transmission HTTP 9091 for the web interface
Umami HTTP 3000 checked with /api/heartbeat
UniFi Network controller HTTPS 8443 passed through to a self-signed backend
Uptime Kuma HTTP 3001 with the WebSocket its interface is built on
Vaultwarden / Bitwarden HTTP with the notification WebSocket kept alive
Verdaccio (npm registry) HTTP 4873 checked with /-/ping
Vikunja HTTP 3456 checked against /api/v1/info
Wallabag HTTP 80 for read-it-later
Wiki.js HTTP 3000 checked with /healthz
Woodpecker CI HTTP 8000 checked with /healthz
WordPress HTTP with clients pinned and room for uploads
Z-Wave JS UI HTTP 8091 with the control WebSocket kept open
Zabbix web interface HTTP 8080 with the session kept on one server
Zigbee2MQTT HTTP 8080 with the WebSocket its interface uses

Infrastructure

Recipe What it sets up
AdGuard Home HTTP 3000 for the admin interface
Cockpit HTTPS 9090 passed through, with the terminal WebSocket kept open
CrowdSec local API HTTP 8080 checked with /health
IMAP TCP 993 for mail clients
Kubernetes API servers TCP 6443 across the control plane
LDAP directory TCP 389 across the directory servers
LLDAP (directory) TCP 3890 for LDAP clients
MQTT (Mosquitto) TCP 1883 for long-lived publish/subscribe connections
Mumble TCP 64738 for voice chat
Nginx Proxy Manager HTTP 81 for the administration interface
OPNsense / pfSense HTTPS passed through to the firewall's own interface
Pi-hole HTTP 80 for the admin interface
Proxmox VE HTTPS 8006, passed through to a self-signed backend
Remote Desktop (RDP) TCP 3389 with each client returning to the same host
SMTP relay TCP 25 to a pool of mail servers
SMTP submission TCP 587 for authenticated mail submission
SSH bastion TCP 22 to a pool of jump hosts
Syncthing (interface) HTTP 8384 checked against its unauthenticated health endpoint
Syslog over TCP TCP 514 to a pool of log collectors
Teleport TCP 443 passed through without terminating TLS
Traefik dashboard HTTP 8080 checked with /ping
TrueNAS HTTPS passed through to the appliance
Unraid HTTP 80 passed through, with the dashboard's live updates kept open
Zabbix server TCP 10051 for agents reporting in

Every one carries example servers, so the shape of the answer is visible before you replace it with your own.

Recipes are one JSON file each, read when the wizard asks for them — no restart. The shipped ones live in static/recipes/ inside the application directory; put your own in /var/lib/haproxy-manager/recipes/ (the data directory), which is where they survive an upgrade — in Docker the application directory is the image itself, replaced wholesale by the next pull, while the data directory is the volume. A local file with the same name as a shipped one replaces it, so a shipped recipe can also be adjusted without touching the image. A file that is not valid JSON is skipped and the reason logged, so one bad recipe cannot empty the list. The filename is the recipe's identity; a minimal one looks like this:

{
  "name": "My application",
  "category": "Applications",
  "summary": "What it sets up, in a line.",
  "notes": "Why these settings and not others.",
  "fields": { "url": "https://app.example.com", "target": "10.0.0.10:8080",
              "health": "http", "health_uri": "/healthz", "health_status": "200" }
}

The interesting ones are the databases, because the right configuration is not obvious:

  • Patroni answers GET /primary with 200 on the leader and 503 everywhere else, so the health check does the routing. Every replica is simply down for that pool, and a failover moves traffic with nothing to change here. Traffic goes to 5432 while the check goes to 8008 — different port, different protocol.
  • Galera accepts writes on any node, which is the problem: two nodes writing the same rows deadlock on commit. Source stickiness keeps each client on one node, so the cluster behaves as a single writer with the others ready.

A recipe is a starting point, not a constraint: every field stays editable, and recipes are only offered for a new service — applying one to an existing service would rewrite settings already in use.

TCP services. Use tcp://0.0.0.0:3306 as the public URL and the service is published in TCP mode — the way to front Galera, PostgreSQL or anything that is not HTTP.

Health checks on a different port or protocol. A Patroni cluster is the classic case: traffic goes to PostgreSQL on 5432, but the check is an HTTP request to 8008 that only the leader answers. Set Check port and choose an HTTP check; the rendered backend then checks one place and routes to another.

Requiring a sign-in

Authentication has a guide of its own — it covers all three doors: this management UI's login (with optional TOTP two-factor), HTTP basic authentication on published services (users, groups, and the userlist HAProxy checks), and single sign-on through any OpenID Connect provider (Authentik, Keycloak, Authelia, Pocket ID, Google, Entra), with per-service allow-lists, identity-header forwarding, the trust model, and what to do when something refuses.

The short version: manage service accounts under Sign-in → Users and Groups; connect a provider under Sign-in → Single sign-on; then tick Require a sign-in or Require single sign-on on the service — one or the other, never both. Source-address controls (Allowed networks, Skip the sign-in from) compose with either. A sign-in nobody can satisfy renders as http-request deny: refusing everyone is safer than quietly becoming public.

Certificates

Certificates → Request a certificate walks through an ACME account, a challenge type and the domains. The accounts, the challenge types and the issuance settings are all on one page, Settings → ACME Settings.

  • HTTP-01 needs port 80 reachable from the internet. The app runs acme.sh's standalone listener on 9080 and routes /.well-known/acme-challenge/ to it automatically when "HAProxy integration" is on.
  • DNS-01 needs an API hook and its credentials. The UI lists all 191 hooks acme.sh ships with and shows exactly which variables each one needs.
  • Wildcards require DNS-01. The wizard offers an existing wildcard when it covers the name you are publishing, instead of issuing another certificate.

Renewal is automatic and runs only on the node that holds the virtual IP — that node has port 80, so it is the only one that can answer an HTTP-01 challenge. A renewed certificate is deployed, pushed to the other nodes, and HAProxy is reloaded, without anything to configure.

If acme.sh is missing or broken the Certificates page says so, with the reason.

The account

The gear beside your name at the foot of the menu opens Account: the username, an email, and the password.

Changing the password or the username asks for the current password — a session alone is not enough to change how you sign in. The email is not a credential and there is no reset-by-email here, so it can be set without one. It is offered as the default when an ACME account is created and when notifications are set up, so it is only typed once.

Applying it to the other nodes. The login is stored per node, so changing it here changes it only here — and a node with the old password is something you usually find out about during a failover, at the worst possible moment. So when the cluster has other nodes, the dialog offers Apply to the other nodes, ticked by default. It sends the stored salt and digest — not the password — to each node's /api/admin/receive, authenticated with that node's API key. A browser cannot reach that endpoint: a session on the receiving node is not enough, only the key is. Nodes that do not take it are named, with the reason, and keep the login they had.

The login is node-local: set it on each node.

Two-factor authentication is optional, per administrator, from the same dialog — standard TOTP with recovery codes; the details, along with the rest of how signing in works, are in the authentication guide. The escape hatch for a lost phone is app.py disable-2fa on the node's shell.

Serving the UI over HTTPS

Web UI access: this node's address, the shared address, and which names it answers for

Settings → Web UI access. Give it a name (https://proxy.example.com) and it publishes this UI as a normal service with a certificate, so you stop using plain HTTP.

If the service appears more than once on the Services page, more than one rule routes to it — a leftover from a version that could build it twice. Both send traffic to the same pool, so nothing is broken and nothing is served twice, but two rows for one service is a puzzle rather than information. Web UI access names the extras and offers to remove them: the rule this node uses is kept, the service is rebuilt, and the result applied.

The page says which names this node actually answers for, read from the objects rather than from the setting that made them. The watchdog checks the same thing every round: if a configured address stops being routed, it rebuilds the service, applies, and says so in the log and by notification. It does that at most twice an hour — if the address goes again inside that, something is actively removing it, and rebuilding on a loop would reload HAProxy every round while hiding the cause, so it reports and leaves it alone. Those come apart — a name that was never published, or one that stopped being — and when they do, the form still shows the address that was typed while the node no longer routes it. A name listed as not routed here is put back by pressing Save.

Two addresses, and they do different jobs:

This node's address https://proxy1.example.com — reaches this node specifically. Node-local: each node has its own.
Shared address https://proxy.example.com — point it at the virtual IP and it reaches whichever node is active. Shared across the cluster, so every node answers for it.

Both names sit on one listener, so they must use the same scheme and port, and the certificate covers both. The shared name is the one to bookmark; the per-node names are how you reach a specific node when you need to.

This service is deliberately not editable on the Services page: each node builds its own copy from these settings.

Do this early. Over plain HTTP both the password and the session cookie cross the network in clear.

Clustering

Cluster settings, shared by every node

Cluster holds everything about running more than one node.

  • This node — its URL (how the others reach it) and its API key.
  • Shared settings — VRID, virtual IPs, authentication, advertisement interval. These must match on every node, and they propagate.
  • Per node — interface, priority, state. These never propagate.
  • Nodes — the members, their keys and their health.

Joining. On a new node choose Join an existing cluster and give it any existing node's URL and API key. It pulls the shared configuration and the membership list, and tells the others about itself. Unicast peers are derived from the member list — there is no unicast setting to maintain.

Only the active node is editable. Passive nodes are read-only, so two people cannot edit two nodes into conflict. The lock can be lifted deliberately for a single session; signing out or in re-locks it.

Split brain — more than one node holding the VIP — is detected and reported on the Overview, and notified if you have notifications configured. It means the nodes cannot see each other's VRRP.

Keeping the nodes in step. The shared configuration carries a revision: a counter that moves whenever it changes, and a fingerprint of its contents. Both travel with every push, every node reports them, and the Cluster page shows which node holds what.

  • A node offered a configuration older than the one it already has refuses it, and says both revisions. Without that, an isolated node that was edited and later reconnected would overwrite the current configuration with its own.
  • Overwrite is how you discard a change made on the wrong node. Editing a passive node moves it ahead of the active one, so an ordinary Sync from the active node is refused — correctly, because it is older. Cluster → Other nodes offers Overwrite beside each node and Overwrite every node below the list. It lifts this node's revision above every other node's and then pushes normally, so afterwards every node holds the same configuration and the same revision; nothing is left disagreeing about who is newest. What was on the other node is discarded.
  • Keep the nodes in step (Cluster → Sync) does three things: pushes after every Apply, brings any node reporting an older revision up to date in the background, and takes the newest configuration from the cluster when this node starts. It is on by default for a new installation; an existing one keeps whatever it was set to. Turn it off to move configuration between nodes by hand.

Reconciliation runs from the node holding the virtual IP, on the health it already collects, so there is no queue of pending pushes to lose: a push that fails is simply observed again next round.

When the nodes do not agree, the Cluster page lists what differs: the object, whether it is missing from a node or present everywhere with different contents, and which nodes hold which version. Settings have no identity of their own and are compared whole.

The health behind that view is collected in the background and can be up to a minute old, so anything that changes or pushes the configuration throws the collected copy away — the next look asks the nodes again rather than repeating a verdict from before the change.

What is shared, and what is this node's. They are separate containers in the configuration, not one container with some objects marked. The shared sections hold what every node has; local holds what only this node has — and that includes whole objects, not only settings: the pool, server, health monitor, rule, conditions and certificate that publish this node's own management UI.

That makes sharing a copy rather than a filter. What is sent to the other nodes is the shared sections as they are, and what is compared is the same value, so the two cannot drift apart. There is no marking to honour and nothing that has to remember to honour it — which is where four separate faults came from.

A listener is shared while the rule attaching this node's UI to it is not, so local.attach records that by the listener's name (every node has one called https-443, with a different id) and by position — HAProxy takes the first matching use_backend, and serves the first certificate to clients that send no SNI. Rendering merges the two; nothing else needs to know.

Each node still checks itself after taking a configuration: it compares its own fingerprint with the one that arrived, and records a mismatch. That should now be impossible, which is exactly why it is worth reporting if it happens.

What counts as HAProxy being up. Keepalived gives the virtual IP up when its tracking script fails. The default script asks HAProxy through its admin socket whether it is serving, so an instance that is running but wedged fails the check; process only looks for a process by that name, which a hung one still has. The script is written next to keepalived.conf by the Apply that writes it, and falls back to looking for the process where there is no admin socket to ask — a node whose HAProxy is fine should never lose the virtual IP to the health check itself.

Addressing nodes

Use IP addresses for peer URLs, not DNS names. The Cluster page marks any peer addressed by name.

Every health check resolves that name again, because nothing caches DNS on a stock Debian or Ubuntu server: there is no caching resolver installed, and glibc has none of its own. Measured against a local DNS server, five lookups of one name produced ten queries (an A and an AAAA each) — none reused. With the default timeout:5 attempts:2, a nameserver that does not answer costs 10 seconds per lookup, every time, and a failure is precisely the thing no cache can help with. The name may also be published by the very cluster that is in trouble.

If you must use names, put them in /etc/hosts, or shorten the resolver's patience with options timeout:1 attempts:1 in /etc/resolv.conf, or install a caching resolver.

Watchdog

Settings → Watchdog. Each node supervises its own services. The distinction that matters is between stopped and hung: systemctl is-active reports a wedged process as healthy, so each service must prove it is answering.

Service Probe
HAProxy show info on its stats socket
Keepalived the service is running when the cluster wants it
The app itself a real HTTP request to its own listener
Setting Default Meaning
Run the watchdog on supervision on this node
Supervise HAProxy / Keepalived on per service
Check every 20s one round
Restarts allowed per window 3 then it stops trying and reports
Window 900s the window those restarts are counted in

It will not restart a service whose configuration does not validate (that is a loop which hides the fault — it reports the offending line instead), nor one you have masked or disabled, and never more than the budget above.

Watching the app itself. A watchdog inside a process cannot restart that process, so systemd does: the unit sets WatchdogSec=90, and the app pings systemd only when a real request to its own listener succeeds. In Docker there is no systemd, so a hung manager is reported by the container health check but not repaired.

Probe the published URLs (on by default) asks for every public name once a minute from the node holding the virtual IP, the way a browser would — DNS, connection, TLS, request. Failures appear beside the URL on the Services page and as notifications under the service event. A 401 from a sign-in and a 503 from a pool already alerted on are both counted as answering. Turn it off when the names only resolve from outside your network.

Notifications

Notification destinations, what they are told about, and recent attempts

Settings → Notifications. Email (SMTP), Pushover and a JSON webhook. Nothing extra is installed for any of it.

Alerts fire on transitions, not conditions: the watchdog runs every twenty seconds, so a message per round would be thousands a week. An unresolved problem is repeated every repeat_hours (6 by default) until it clears.

Setting Meaning
Only at or above error for breakage only; warning adds repairs; info adds recoveries and new versions
Combine service alerts within window (60s by default) in which service alerts are gathered into one message naming them all — a host with ten services on it is one event, down and back up. Alerts from the same check are always combined; 0 combines only those.
Wait before alerting a service is down grace period (30s by default) before a service losing servers is reported — a reboot or an update that takes it down for a few seconds is not paged at all. The clock starts when it first goes bad; 0 alerts on the first check.
Categories certificates, watchdog, apply, cluster, updates — each switchable

Each destination has a Test button that sends a real message. Settings are shared, so configure them on one node and they propagate — which also means SMTP passwords and Pushover tokens travel in the sync payload: run peer sync over HTTPS, or keep it on a trusted network.

Because every node watches every other, a node that vanishes is reported by each of its peers.

Home Assistant

Settings → Notifications → Home Assistant. Point it at an MQTT broker and the entities appear in Home Assistant by MQTT discovery — no YAML on either side. The settings are shared, like the rest of the notification settings, so every node reaches the same broker.

Setting Meaning
Broker host / port the MQTT broker Home Assistant already uses (Mosquitto, typically port 1883)
Username / password if the broker requires them; a blank password keeps the stored one
TLS for a broker behind mqtts://
Discovery prefix homeassistant unless yours was changed
Topic prefix where states are published; haproxy-manager by default
Allow control from Home Assistant off by default — adds a maintenance switch per service; see below

Each node publishes its own device (holds the virtual IP, HAProxy answering), and the node holding the virtual IP publishes the cluster device: a problem sensor per service honouring its Alert when setting, a connectivity sensor per published URL, days-to-expiry per certificate, configuration drift, nodes reachable, and requests per minute per service. On failover the new active node continues the same topics. Availability uses an MQTT will, so entities grey out the moment a node dies.

Allow control adds a switch per service that pauses it — every request answered with a clean 503 — and resumes it, the same operation as Pause on the Services page. It is opt-in because the power travels with the broker: anyone who can publish to the command topics can pause services, so leave it off unless the broker is behind credentials you trust.

Upgrades and the browser cache

The page is served with no-cache, and everything it loads lives under a path that contains the version — /static/v/1.79.0/js/main.js. Those files are then cached for a year, which is safe because the URL changes whenever the version does.

The version has to be in the path rather than a query string: each import "./shell.js" inside a module is its own request, resolved relative to the module that wrote it, so a version in the path is inherited by every import without touching any of them. After an upgrade a browser fetches the whole set fresh, and can never hold a mixture of old and new modules.

If you put a caching proxy in front of the UI, let it honour those headers.

Sorting tables

Every table in the UI sorts: click a heading, click it again to reverse. The column keeps its arrow so it is clear what the order is.

Values sort by what they mean rather than by how they are spelled — 900 MB before 1.2 GB, srv9 before srv10, 30 mins before 2 days — numbers come before words, blanks stay at the bottom whichever way the column is sorted, and a totals row stays where it belongs. Pages that refresh on a timer, like Statistics and Logs, keep the sort you chose instead of resetting it every few seconds.

Logs

Logs (its own page in the menu) merges four sources into one timeline: the UI's own log, HAProxy, acme.sh and Keepalived. Filter by source, level and text, and download exactly what you are looking at.

The app's log is /var/lib/haproxy-manager/haproxy-manager.log (mode 0600, rotated at 4 MB, three kept) and also goes to stdout, so journalctl -u haproxy-manager shows the same lines. Every configuration change is logged with who made it and from where; request bodies never are, so passwords and credentials do not reach the log.

"soft-stop running for too long, performing a hard-stop"

Seen in the HAProxy log after an Apply, followed by a line per listener saying how many connections it closed:

[WARNING] : soft-stop running for too long, performing a hard-stop.
[WARNING] : Proxy fe_https-443 hard-stopped (1 remaining conns will be closed).
[WARNING] : Proxy fe_galera-listener hard-stopped (2 remaining conns will be closed).

Nothing is broken. A reload starts a new HAProxy and leaves the old one running so connections opened before it can finish; Hard stop after (Advanced · HAProxy → Settings, 60s by default) is how long it waits. When it expires the old process closes what is left, and says so.

What gets closed is anything longer-lived than that grace period — a database session, a WebSocket, a stream — so those reconnect on every Apply. The recipes give such services long server timeouts precisely because their connections are meant to last, which is why they are the ones that show up here.

The choice is between two costs:

Setting What it costs
60s (default) long-lived connections are cut on every reload and reconnect
something longer fewer cuts, and old processes hang around that much longer
empty nothing is ever cut, and an old process lingers after every reload for as long as its longest connection — with an hour-long server timeout, potentially an hour, one per reload

For a database pool, clients reconnect and source stickiness sends them back to the same node, so the default is usually right. Raise it if a reconnect is disruptive to what is behind the proxy.

Traffic history

A day of traffic per pool, with server errors over the requests

Each node records, once a minute, how many requests each pool served and how many server errors it returned, and keeps a day of it in $HAM_DATA_DIR/traffic.json. The Statistics page draws it; the Services page shows a sparkline per service.

Resolution one point a minute
Kept 24 hours
Scope this node only
Written to traffic.json, never the configuration — it must not move the shared revision
Not counted requests the app makes itself (the URL probes), subtracted per pool at sampling time

Counts are what happened during that minute, not running totals, and a counter that has gone backwards is treated as HAProxy having restarted rather than as negative traffic. Every series carries one value per timestamp, so a pool that appeared later shows zeros before it existed rather than having its history slid backwards in time.

Metrics

GET /metrics is Prometheus exposition, guarded by the node's API key sent as Authorization: Bearer <key> (or X-API-Key). Scrape every node. The pool counters are HAProxy's own and include the app's URL probes; the built-in traffic history subtracts them, the raw counters do not.

Backup and restore

Settings → Backup & Export downloads the whole configuration as JSON, and the rendered haproxy.cfg and keepalived.conf for inspection.

Restoring replaces the shared objects and keeps node-local settings — the API key, this node's URL, its Keepalived identity — so a restore does not turn a node into a copy of another one. Single sign-on settings (the client secret, the signing secret) are kept as they are on the node rather than replaced, so a restore never disables SSO. Nothing is applied until you press Apply.

The backup carries no secrets: service password hashes, DNS-provider credentials, EAB keys and the SSO client secret are all stripped. A restore onto the same node fills them back from what is stored; a restore elsewhere leaves them to be entered again. It is safe to copy around.

Configuration history

Settings → History. A snapshot of the shared configuration is kept each time it changes — the last 50 states, on each node's own disk, in history/ under the data directory. Adopting a peer's configuration is snapshotted too, although it does not move the revision counter: a peer overwriting this node's configuration is the first thing you would want to undo.

Each entry says what it changed against the one before. Diff vs now names every object between that state and the present one; Restore puts the state back as a new change — next revision, nothing applied or synced until Apply — and leaves node-local settings alone. The restored state is itself snapshotted, so a restore can be undone the same way.

What is shared and what is per node

Shared (propagates) Per node (never propagates)
HAProxy objects: services, pools, servers, rules API key
ACME accounts, challenge types, certificates This node's URL
Cluster VRRP settings: VRID, VIPs, auth, interval Keepalived interface, priority, state
Notification settings and destinations The UI's own HTTPS service, and its certificate
Service sign-in users and groups Configuration history (each node remembers what it saw)
Administrator login
Watchdog settings

The UI's own service is the reason the right-hand column matters more than it looks. Every node publishes it under the same name, for a different host, so everything it is made of — the pool, the server, the health monitor, the rule, the conditions it tests and the certificate for that node's own name — belongs to the node that made it. Anything of that sort left in the shared configuration travels to the other nodes, where it collides with theirs; the wizard then makes a numbered copy, which travels in turn, and the cluster never agrees with itself again.

None of that can happen now: those objects are not in the shared sections at all, so there is nothing to send. Upgrading moves what was there into place, once, and the generated haproxy.cfg is unchanged by the move apart from where one backend block sits in the file — which HAProxy resolves by name.

Environment variables

Read by the app at startup. Set them in the unit (systemctl edit haproxy-manager) or the container's environment.

Variable Default Purpose
HAM_DATA_DIR /var/lib/haproxy-manager config.json and the log
HAM_CERT_DIR /etc/haproxy/certs deployed certificate PEMs
HAM_HAPROXY_CFG /etc/haproxy/haproxy.cfg generated HAProxy config
HAM_KEEPALIVED_CFG /etc/keepalived/keepalived.conf generated Keepalived config
HAM_ACME_HOME ~/.acme.sh ACME accounts and issued certificates
HAM_ACME_SH $HAM_ACME_HOME/acme.sh the acme.sh executable
HAM_STATS_SOCK /run/haproxy/admin.sock HAProxy's admin socket
HAM_LISTEN 0.0.0.0 bind address
HAM_PORT 8080 UI and API port
HAM_THREADS 16 waitress worker threads (single process — see below)
HAM_LOG_FILE $HAM_DATA_DIR/haproxy-manager.log the app's log
HAM_DEBUG — 1 for verbose logging
HAM_PEER_CONNECT_TIMEOUT 3 waiting for a node to accept a connection
HAM_PEER_READ_TIMEOUT 5 waiting for it to answer a health query
HAM_PUSH_READ_TIMEOUT 90 waiting for it to accept a pushed configuration
HAM_CLUSTER_POLL 15 how often node health is collected in the background
HAM_CLUSTER_MAX_AGE 60 age at which a stale snapshot is collected inline
HAM_WATCHDOG_PROBE_TIMEOUT 5 how long a service may take to answer its probe
HAM_WATCHDOG_SELF_TIMEOUT 10 how long the app's self-check may take
HAM_REPO / HAM_REF avandeputte/haproxy-manager / main where updates come from
HAM_VERSION_URL / HAM_INSTALL_URL GitHub override for a fork or mirror
HAM_DRY_RUN — 1 renders and validates but never reloads services

Do not run this behind a multi-process WSGI server. It is served by waitress as a single process with a thread pool on purpose: the lock that makes configuration writes atomic, the failed-sign-in counters and the renewal timer all live in process memory. Several worker processes would each get their own copy, and concurrent edits would overwrite one another. Raise HAM_THREADS if you need more concurrency.

Ports

Port What Who needs to reach it
8080 UI and API you, and the other nodes (peer sync)
9080 acme.sh HTTP-01 standalone listener nothing directly — HAProxy routes to it
80 / 443 whatever your services bind your users, and Let's Encrypt for HTTP-01
— VRRP (protocol 112, multicast or unicast) the other nodes

The API key is a bearer token with full control of a node. Restrict who can reach port 8080, and put the UI behind TLS.