Create an HAProxy load balancer with Amazon EC2 and Terraform

Load balancers route traffic to backend servers using algorithms such as round-robin, least-connections, or weighted routing based on server capacity. As of June 2026, AWS provides managed Application and Network Load Balancers, which abstract away routing logic so software engineering teams can focus on application development. However, engineering teams sometimes need full control over traffic distribution, such as dynamic weight adjustment based on real-time server load or routing algorithms beyond what AWS load balancers offer. HAProxy is an ideal solution in those cases, providing visibility and control over every routing decision.

Per HAProxy.org, HAProxy is a free, very fast and reliable reverse-proxy offering high availability, load balancing, and proxying for TCP and HTTP-based applications. It is open source software that can be installed on a compute instance (Amazon EC2) and used to route traffic to backend EC2 instances. It does so by accepting incoming connections on a frontend port, evaluating them against configured routing rules, and forwarding them to one of several backend servers based on the selected algorithm.

In this note, I will set up an Amazon EC2 instance with HAProxy to route traffic to backend EC2 instances using the round-robin routing algorithm. The next sections outline the solution and implementation steps.

Solution Overview

This solution is designed within an Amazon Virtual Private Cloud with public and private subnets spread across two availability zones. The setup includes an internet gateway, two NAT Gateways in two public subnets, two security groups with specific ingress and egress rules, an HAProxy Amazon EC2 instance in the public subnet, and two backend EC2 instances in the private subnets across the two availability zones. A common IAM role is attached to all EC2 instances. I also configured the Amazon CloudWatch Logs agent on all EC2 instances to collect and send logs to CloudWatch.
HAProxy architecture diagram
The diagram above shows the solution’s high-level architecture. The solution also includes AWS Key Management Service (AWS KMS) to encrypt the logs stored in the Amazon CloudWatch Logs group.

If you want to follow along, the code for this solution is available in my GitHub repository: kunduso/haproxy-ec2-terraform. The code includes Terraform configurations, GitHub Actions CI/CD, and security scanning with Checkov.

Prerequisites

Before we proceed with the implementation, please note that the deployment workflow is automated via GitHub Actions. This workflow requires an AWS IAM role with an OIDC trust policy that allows GitHub Actions to securely assume the role and create AWS cloud resources without long-lived credentials.

For detailed setup instructions, please see: Securely integrate AWS credentials with GitHub Actions using OpenID Connect. I followed the same approach and stored the role ARN as a GitHub Actions secret named IAM_ROLE, which is referenced in the GitHub Actions workflow file.

Implementation

This use case consists of six steps. These are:
1. Create the network layer
2. Create the security groups and ingress and egress rules
3. Create an IAM Role for Amazon EC2 instances
4. Create an encrypted Amazon CloudWatch Log Group
5. Create the backend Amazon EC2 instances in the private subnets
6. Create the HAProxy Amazon EC2 instance in a public subnet

Let me now elaborate on these steps, starting with the network layer.

Step 1: Create the network layer
I used a VPC module that I created for all my use cases. It is stored in my GitHub repo: kunduso/terraform-aws-vpc.
VPC module configuration
The module creates the VPC and public and private subnets based on the values passed while invoking the module. With the values for enable_dns_hostnames, enable_dns_support, enable_flow_log, enable_internet_gateway, enable_nat_gateway set to true the module enables DNS hostnames and support so that instances receive resolvable DNS hostnames. Setting enable_flow_log to true ensures that I’m tracking the network traffic logs. And the last two properties ensure that the VPC contains an Internet Gateway and NAT Gateways. The module (as of version 1.0.7) creates as many NAT Gateways as public subnets.

Step 2: Create the security groups and ingress and egress rules
I created two security groups in this use case, one for the HAProxy EC2 instance and the other for the Backend EC2 instances. The HAProxy EC2 instance has three ingress rules. These are on port 80, 443, and 8404 from a selected CIDR (my IP address).
HAProxy security group ingress rules
For this use case, I restricted all three ingress rules to my local IP address (passed as the allowed_ip variable from a GitHub Actions secret) to prevent unauthorized traffic from reaching the instance during development. In a production environment, you would open port 80 and 443 to 0.0.0.0/0 for public access while keeping port 8404 restricted since HAProxy exposes stats on that port.

For egress, I created four specific rules to allow traffic to reach the internet on ports 443 and 53 for tcp and udp and on port 80 to the Backend EC2 security group.
HAProxy security group egress rules
For the Backend EC2 instance’s security group, I created two ingress rules from the HAProxy EC2 instance’s security group on port 80 and 8888. Port 80 for content and 8888 for agent-check (used in a future article).
Backend security group ingress rules
The Backend EC2 instance’s security group’s egress rules are to enable traffic on port 443 and 53 for tcp and udp. These are to allow the EC2 instances to download required installers and resolve DNS.
Backend security group egress rules

Step 3: Create an IAM Role for Amazon EC2 instances
In this step, I created an IAM role that both the HAProxy and backend EC2 instances share.
IAM role configuration
The assume_role_policy allows the ec2.amazonaws.com service principal to assume this role, which is how EC2 instances obtain temporary credentials. I then attached two managed policies to the role:
AmazonSSMManagedInstanceCore — enables Session Manager access (no SSH keys needed)
CloudWatchAgentServerPolicy — allows the CloudWatch agent to push logs from the instances

For details on these integrations, see my notes on provisioning EC2 with Session Manager access and installing the CloudWatch logs agent on EC2.

Finally, I created an aws_iam_instance_profile resource that wraps the IAM role so it can be attached to EC2 instances.
IAM instance profile

Step 4: Create an encrypted Amazon CloudWatch Log Group
I created a KMS key with key rotation enabled and a key policy that grants the CloudWatch Logs service permission to use it for encryption. Then I created the CloudWatch Log Group with this KMS key, providing encryption at rest for all logs collected from the HAProxy and backend instances.
KMS key and CloudWatch Log Group

Step 5: Create the backend Amazon EC2 instances in the private subnets
In this step, I created the backend Amazon EC2 instances running nginx. Since the instances are in private subnets, they reach the internet for package downloads via the NAT gateways in their respective availability zones. The key configuration properties are:
subnet_id — the expression module.vpc.private_subnets[count.index % length(module.vpc.private_subnets)].id distributes instances across available private subnets in a round-robin fashion. With two private subnets and two instances, each backend lands in a different availability zone.
iam_instance_profile — attaches the IAM role from Step 3, giving the instance permissions for Session Manager and CloudWatch.
user_data — uses templatefile() to render a shell script that installs nginx, creates a health check endpoint, and configures the CloudWatch agent. The user_data_replace_on_change = true setting ensures the instance is recreated if the script changes, keeping infrastructure immutable.
metadata_options — enforces IMDSv2 (Instance Metadata Service v2) by setting http_tokens = "required". This prevents SSRF attacks from accessing instance credentials via the metadata endpoint.
monitoring = true — enables detailed CloudWatch monitoring (1-minute intervals instead of the default 5-minute).
ebs_optimized = true — provides dedicated throughput between the instance and its EBS volumes.

Backend EC2 instance configuration
The user data script (templates/backend-userdata.sh) runs at instance boot and performs the following:
1. Disables the ECS agent (pre-installed on Amazon Linux 2023 but unused here)
2. Installs nginx and the CloudWatch agent via dnf
3. Creates a custom index.html identifying each backend by its instance index (Backend 1, Backend 2) — this makes round-robin verification visual
4. Creates a /health endpoint that returns “OK” for HAProxy health checks
5. Starts and validates nginx
6. Configures the CloudWatch agent to collect cloud-init logs, system messages, and nginx access/error logs
7. Starts and validates the CloudWatch agent

Step 6: Create the HAProxy Amazon EC2 instance in a public subnet
Unlike the backend instances, the HAProxy EC2 instance is placed in a public subnet with associate_public_ip_address = true so that clients can reach it directly from the internet. The depends_on = [aws_instance.backend] ensures that Terraform creates the backend instances first — this is necessary because the HAProxy user data script references the backend private IPs to generate the haproxy.cfg configuration at boot time. Without this dependency, the IPs would not be available when the template is rendered.
HAProxy EC2 instance configuration
The user data script (templates/haproxy-userdata.sh) runs at boot and performs the following:
1. Disables the ECS agent (unused)
2. Installs HAProxy, rsyslog, and the CloudWatch agent
3. Configures rsyslog to write HAProxy logs to /var/log/haproxy.log (HAProxy logs via syslog, not directly to a file)
4. Generates /etc/haproxy/haproxy.cfg with a frontend listening on port 80, a backend section using the roundrobin algorithm, and health checks via GET /health on each backend
5. Enables the stats page on port 8404 for monitoring backend health and traffic distribution
6. Starts and validates HAProxy
7. Configures the CloudWatch agent to collect cloud-init logs, system messages, and HAProxy logs
8. Starts and validates the CloudWatch agent

Deployment

The infrastructure is deployed via GitHub Actions using the workflow defined in .github/workflows/terraform.yml. The workflow runs terraform apply only when changes are merged into the main branch, using OIDC authentication to obtain secure, temporary AWS credentials. This ensures that all infrastructure modifications are reviewed via pull requests before deployment.

The workflow also requires a GitHub Actions secret named ALLOWED_IP containing the CIDR block allowed to access the HAProxy instance (e.g., your public IP in /32 format). This value is passed to Terraform via the -var flag during the plan and apply commands.

This repository also includes a code-scanning pipeline (.github/workflows/code-scan.yml) that uses Checkov to scan Terraform configurations for security best practices before deployment. For detailed implementation of Checkov with GitHub Actions, see: automate-terraform-configuration-scan-with-checkov-and-github-actions.

Verification

After deploying the infrastructure, the pipeline output displayed the haproxy_stats_url along with other values defined in outputs.tf.
Terraform outputs showing HAProxy stats URL
I navigated to the HAProxy public IP on port 80 and refreshed the page multiple times. Each refresh routed me to a different backend, confirming that the round-robin algorithm is distributing traffic evenly between the two instances.
Round-robin routing to backend instances
To view the HAProxy stats dashboard, I navigated to http://<haproxy-ip>:8404/stats. The dashboard displayed both backend servers (backend1 and backend2) with real-time metrics including session rate, active sessions, bytes in/out, and health check status.
HAProxy stats dashboard
This dashboard provides visibility into how HAProxy distributes traffic and the health of each backend — exactly the kind of control that managed load balancers abstract away. Beyond round-robin, HAProxy supports routing decisions based on real-time server capacity, custom health checks, and request attributes like headers or URL paths. I will explore some of these in future articles.

Conclusion

In this article, I set up an HAProxy load balancer on Amazon EC2 to distribute traffic across backend instances using the round-robin algorithm. The setup demonstrates how HAProxy provides full visibility into routing decisions through its stats dashboard and configuration file, whereas managed load balancers hide these details behind an abstraction layer.

One limitation of this approach is that the HAProxy configuration is static — backend IPs are baked into the config at instance creation time via user data. If backends are replaced or new ones are added, HAProxy has no way to discover them automatically. Addressing that limitation with dynamic backend discovery is a topic for a future article.

If you have any questions or suggestions, feel free to comment or get in touch.

Leave a Reply