Automating HAProxy – Configuration Management

Config management from human-readable documentation
Recently, my home lab grew to the point where manually updating HAProxy ACL rules became a chore. I needed to ensure my infrastructure could scale with minimal friction. Since HAProxy configurations can become unwieldy and difficult to manage, I designed and implemented a tool that lets me maintain a single, living document defining the business logic for HAProxy routing.
To tackle this, I chose Python as my weapon of choice. I needed something that could parse semi-structured data from a human-readable file format—something I could write quickly and clearly—and convert it into a format HAProxy could use.
I wanted HAProxy to interpret lines like the following:
https://www.foobar.com -> http://foobarcom-internal-server-1:80
https://foobar.com -> http://foobarcom-internal-server-1:80
https://stage.foobar.com -> http://stage-foobarcom-internal-server-1:80
My goal was to offload SSL termination to HAProxy so internal services wouldn’t need to manage HTTPS at all. I wanted to spin up new servers quickly, define a route, and go.
First Attempt: Functional Overkill?
My first draft used a set of functions: one to extract the config block from the README.md
, two more to generate HAProxy config sections, and a final multi-line f-string to build the file contents, with a context manager to handle writing.
This worked, but a good friend (and fellow co-potato-battery-piloting-a-meatsuit-doomed-to-inevitable-decay) pointed out that my approach felt more like software engineering than systems administration. For sysadmins, readability and linear structure often trump modularity, especially in one-off scripts. It was a valid critique, and one I took to heart.
Refining the Approach: Simpler, More Explicit
I restructured the script around two clearly separated tasks:
- Parse the config block from
README.md
- Generate the HAProxy configuration
Step 1: Extracting HAProxy Rules
with open('README.md', 'r') as file_handle:
configuration_data = re.match(
r'(?is).*# HAProxy Configuration.*`+\w*\n+(?P<conf>.*\w+)\n+`',
file_handle.read()
)
Then, I split the block and parsed each line:
for line in configuration_data.groupdict().get('conf').strip().split("\n"):
if match := re.match(
r'^\s+https://(?P<ingress_host>\S+)/?\s+->\s+http://(?P<destination_service>\S+)\s*$',
line
):
destination_service = match.group('destination_service')
ingress_host = match.group('ingress_host')
safe_ingress_host = re.sub(r'\W', '_', ingress_host)
destination_service += ':80' if not re.match(r'.*:\d+$', destination_service) else ''
configured_hosts[ingress_host] = {
'safe_ingress_host': safe_ingress_host,
'destination_service': destination_service
}
Step 2: Generating the HAProxy Config
We begin by printing the static HAProxy preamble:
print("""
global
# intermediate configuration
...
defaults
timeout connect 5000
timeout client 50000
timeout server 50000
""")
Note: The preamble here is an excerpt for brevity.
For the full version please see the referenced GitHub repo: haproxy_config_automation_example
Then generate the ACLs:
for ingress_host in sorted(configured_hosts.keys()):
print(f"""
acl host_{configured_hosts[ingress_host]['safe_ingress_host']} hdr(host) -i {ingress_host}
use_backend {configured_hosts[ingress_host]['safe_ingress_host']}_backend if host_{configured_hosts[ingress_host]['safe_ingress_host']}
""")
Set the default backend:
print("""
default_backend catchall_backend
backend catchall_backend
mode http
http-request deny deny_status 404
""")
Finally, define each backend:
for ingress_host in sorted(configured_hosts.keys()):
print(f"""
backend {configured_hosts[ingress_host]['safe_ingress_host']}_backend
mode http
server only_server {configured_hosts[ingress_host]['destination_service']} check
""")
This creates a clean, maintainable HAProxy config generated directly from documentation. Future improvements (like supporting load balancing across multiple destinations) can easily build on this foundation—but that’s a topic for another post.