How to Set Up an AWS Application Load Balancer for a Laravel App on EC2

Your Laravel app is running on EC2. Nginx is up, the site loads, everything’s fine.

Then the other questions start creeping in. What happens when the instance reboots and the public IP changes? What if traffic spikes and you need a second box? And how do you get SSL on there without Certbot renewals?

An Application Load Balancer solves all three. Even if you’re only running one instance right now, putting an ALB in front of it early is worth the twenty minutes. Stable DNS endpoint, health checks out of the box, SSL termination at the load balancer, and room to add more instances later without redoing your setup.

Here’s the whole thing with the AWS CLI. Security groups, target group, the load balancer, the listener, and then locking down the EC2 so it stops answering the internet directly.

What We’re Building

The architecture:

Internet
   |
   v
Application Load Balancer  (internet-facing, spans 2 AZs)
   |
   v
Target Group  (HTTP health check on port 80)
   |
   v
EC2 Instance  (Nginx + PHP-FPM + Laravel)

Requests hit the ALB first. Every 30 seconds the ALB checks whether your EC2 is still alive, and it only forwards traffic to instances that pass. The EC2 security group gets tightened so it accepts HTTP from the ALB and nothing else.

Prerequisites

  • AWS CLI installed and configured (aws configure)
  • A running EC2 instance with Laravel serving HTTP on port 80
  • Your VPC ID and subnet IDs (we’ll pull these with the CLI)
  • IAM user with EC2 and ELB permissions

Before touching anything, confirm the app actually responds:

$ curl -s -o /dev/null -w "%{http_code}" http://your-ec2-public-ip
200

Good. Now let’s put a load balancer in front of it.

Step 1: Create a Security Group for the ALB

The ALB gets its own security group, separate from whatever your EC2 is using. This one takes traffic from the internet on 80 and 443. Later on, the EC2 will only accept HTTP from this security group instead of from the whole internet.

Grab your VPC ID first:

$ aws ec2 describe-vpcs     --query "Vpcs[*].{VpcId:VpcId,CIDR:CidrBlock}"     --output table --region us-east-1

Fri Jul 24 09:10:22 UTC 2024
-------------------------------------------------
|              DescribeVpcs                     |
+-----------------+----------------------------+
|  CIDR           |  VpcId                     |
+-----------------+----------------------------+
|  172.31.0.0/16  |  vpc-0ba574ee0a3a7df78     |
+-----------------+----------------------------+

Create the ALB security group:

$ aws ec2 create-security-group     --group-name "laravel-blog-alb-sg"     --description "ALB security group for Laravel Blog"     --vpc-id vpc-0ba574ee0a3a7df78     --region us-east-1

{
    "GroupId": "sg-0d4462e05fdc97746"
}

Hang on to that GroupId. Now open 80 and 443:

$ aws ec2 authorize-security-group-ingress     --group-id sg-0d4462e05fdc97746     --protocol tcp --port 80 --cidr 0.0.0.0/0     --region us-east-1

Fri Jul 24 09:11:08 UTC 2024
{
    "Return": true,
    "SecurityGroupRules": [
        {
            "SecurityGroupRuleId": "sgr-0e155576aa6537fb0",
            "GroupId": "sg-0d4462e05fdc97746",
            "IsEgress": false,
            "IpProtocol": "tcp",
            "FromPort": 80,
            "ToPort": 80,
            "CidrIpv4": "0.0.0.0/0"
        }
    ]
}
$ aws ec2 authorize-security-group-ingress     --group-id sg-0d4462e05fdc97746     --protocol tcp --port 443 --cidr 0.0.0.0/0     --region us-east-1

We’re not doing HTTPS in this post, but opening 443 now means the security group is ready the day you attach a certificate. One less thing to come back and fix.

Step 2: Create a Target Group

A target group is just a pool of servers the ALB forwards to, plus the health check config for those servers. Right now the pool has exactly one EC2 in it. Doesn’t matter. The setup is identical when you add more.

$ aws elbv2 create-target-group     --name "laravel-blog-tg"     --protocol HTTP --port 80     --vpc-id vpc-0ba574ee0a3a7df78     --health-check-path "/"     --health-check-interval-seconds 30     --healthy-threshold-count 2     --unhealthy-threshold-count 3     --health-check-timeout-seconds 5     --target-type instance     --region us-east-1

Fri Jul 24 09:14:37 UTC 2024
{
    "TargetGroups": [
        {
            "TargetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:363479758429:targetgroup/laravel-blog-tg/d921805e7e5a464a",
            "TargetGroupName": "laravel-blog-tg",
            "Protocol": "HTTP",
            "Port": 80,
            "VpcId": "vpc-0ba574ee0a3a7df78",
            "HealthCheckProtocol": "HTTP",
            "HealthCheckPath": "/",
            "HealthCheckIntervalSeconds": 30,
            "HealthCheckTimeoutSeconds": 5,
            "HealthyThresholdCount": 2,
            "UnhealthyThresholdCount": 3,
            "TargetType": "instance"
        }
    ]
}

Quick translation of those health check flags:

  • health-check-interval-seconds 30 — the ALB pings the instance every 30 seconds
  • healthy-threshold-count 2 — two passing checks in a row before it’s considered healthy
  • unhealthy-threshold-count 3 — three failures in a row before the ALB stops sending traffic

Save the TargetGroupArn. It shows up in the next few commands.

Step 3: Register Your EC2 Instance in the Target Group

Point the target group at your instance:

$ aws elbv2 register-targets     --target-group-arn "arn:aws:elasticloadbalancing:us-east-1:363479758429:targetgroup/laravel-blog-tg/d921805e7e5a464a"     --targets Id=i-06e46e1d4ff5d8dfc     --region us-east-1

Fri Jul 24 09:15:02 UTC 2024
(no output means success)

Health status will sit at “initial” for now. That’s expected. Nothing is running checks yet because the load balancer doesn’t exist.

Step 4: Create the Application Load Balancer

AWS won’t let you create an ALB in a single subnet. You need at least two, in different Availability Zones. This isn’t negotiable, and it applies even when you’re running one instance in one AZ. The reasoning is that the ALB itself has to survive an AZ going down.

See what subnets you’re working with:

$ aws ec2 describe-subnets     --query "Subnets[*].{AZ:AvailabilityZone,ID:SubnetId,CIDR:CidrBlock}"     --output table --region us-east-1

Fri Jul 24 09:16:41 UTC 2024
-------------------------------------------------------------------
|                       DescribeSubnets                           |
+--------------+-------------------+----------------------------+
|     AZ       |       CIDR        |            ID              |
+--------------+-------------------+----------------------------+
|  us-east-1a  |  172.31.0.0/20   |  subnet-04aa42b5ced0cc1e4  |
|  us-east-1b  |  172.31.80.0/20  |  subnet-05a0a98d25860f98e  |
|  us-east-1c  |  172.31.16.0/20  |  subnet-0b365982e62512b4a  |
|  us-east-1d  |  172.31.32.0/20  |  subnet-006bd748ede4234ec  |
|  us-east-1e  |  172.31.48.0/20  |  subnet-08159727b09d5a03f  |
|  us-east-1f  |  172.31.64.0/20  |  subnet-0dce59cfc09504205  |
+--------------+-------------------+----------------------------+

My instance lives in us-east-1d, so that subnet goes in. For the second one I picked us-east-1a, no particular reason beyond it being there:

$ aws elbv2 create-load-balancer     --name "laravel-blog-alb"     --subnets subnet-006bd748ede4234ec subnet-04aa42b5ced0cc1e4     --security-groups sg-0d4462e05fdc97746     --scheme internet-facing     --type application     --ip-address-type ipv4     --region us-east-1

Fri Jul 24 09:18:30 UTC 2024
{
    "LoadBalancers": [
        {
            "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:363479758429:loadbalancer/app/laravel-blog-alb/3efc9c6906f063d5",
            "DNSName": "laravel-blog-alb-1793611230.us-east-1.elb.amazonaws.com",
            "CreatedTime": "2024-07-25T09:18:30.920Z",
            "LoadBalancerName": "laravel-blog-alb",
            "Scheme": "internet-facing",
            "VpcId": "vpc-0ba574ee0a3a7df78",
            "State": {
                "Code": "provisioning"
            },
            "Type": "application",
            "AvailabilityZones": [
                {
                    "ZoneName": "us-east-1a",
                    "SubnetId": "subnet-04aa42b5ced0cc1e4"
                },
                {
                    "ZoneName": "us-east-1d",
                    "SubnetId": "subnet-006bd748ede4234ec"
                }
            ]
        }
    ]
}

“provisioning” is fine. It usually takes two or three minutes. Rather than refreshing the console, just block on it:

$ aws elbv2 wait load-balancer-available     --load-balancer-arns "arn:aws:elasticloadbalancing:us-east-1:363479758429:loadbalancer/app/laravel-blog-alb/3efc9c6906f063d5"     --region us-east-1

(command blocks until active, then returns)
$ aws elbv2 describe-load-balancers     --load-balancer-arns "arn:aws:elasticloadbalancing:us-east-1:363479758429:loadbalancer/app/laravel-blog-alb/3efc9c6906f063d5"     --query "LoadBalancers[0].State" --output json --region us-east-1

Fri Jul 24 09:21:05 UTC 2024
{
    "Code": "active"
}

That DNSName in the create output, laravel-blog-alb-1793611230.us-east-1.elb.amazonaws.com, is the endpoint your domain will eventually point at. Copy it somewhere you’ll find it later.

Step 5: Create the HTTP Listener

A listener is the rule that tells the ALB what to do with incoming requests. Something hits port 80, forward it to the target group. That’s the whole thing:

$ aws elbv2 create-listener     --load-balancer-arn "arn:aws:elasticloadbalancing:us-east-1:363479758429:loadbalancer/app/laravel-blog-alb/3efc9c6906f063d5"     --protocol HTTP --port 80     --default-actions Type=forward,TargetGroupArn="arn:aws:elasticloadbalancing:us-east-1:363479758429:targetgroup/laravel-blog-tg/d921805e7e5a464a"     --region us-east-1

Fri Jul 24 09:22:14 UTC 2024
{
    "Listeners": [
        {
            "ListenerArn": "arn:aws:elasticloadbalancing:us-east-1:363479758429:listener/app/laravel-blog-alb/3efc9c6906f063d5/114a482d73cb3830",
            "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:363479758429:loadbalancer/app/laravel-blog-alb/3efc9c6906f063d5",
            "Port": 80,
            "Protocol": "HTTP",
            "DefaultActions": [
                {
                    "Type": "forward",
                    "TargetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:363479758429:targetgroup/laravel-blog-tg/d921805e7e5a464a"
                }
            ]
        }
    ]
}

The ALB is now listening on 80 and forwarding to the target group. When you eventually add a domain and a certificate, you’ll create a second listener on 443 and turn this one into a redirect. Separate job for another day.

Step 6: Lock the EC2 Security Group to ALB Only

This step matters and plenty of tutorials leave it out.

As things stand, your EC2 still has port 80 open to 0.0.0.0/0. Anyone who knows the instance IP can hit it directly and skip the load balancer entirely, which makes the last five steps pointless from a security standpoint.

So: pull the open rule, and put back one that only trusts the ALB security group.

# Remove existing open port 80 rule
$ aws ec2 revoke-security-group-ingress     --group-id sg-0ea87bf2cc1cabb58     --protocol tcp --port 80 --cidr 0.0.0.0/0     --region us-east-1

Fri Jul 24 09:23:31 UTC 2024
{
    "Return": true,
    "RevokedSecurityGroupRules": [
        {
            "SecurityGroupRuleId": "sgr-0bf596a4bf51ddee3",
            "GroupId": "sg-0ea87bf2cc1cabb58",
            "IpProtocol": "tcp",
            "FromPort": 80,
            "ToPort": 80,
            "CidrIpv4": "0.0.0.0/0"
        }
    ]
}
# Allow port 80 only from ALB security group
$ aws ec2 authorize-security-group-ingress     --group-id sg-0ea87bf2cc1cabb58     --protocol tcp --port 80     --source-group sg-0d4462e05fdc97746     --region us-east-1

Fri Jul 24 09:23:52 UTC 2024
{
    "Return": true,
    "SecurityGroupRules": [
        {
            "SecurityGroupRuleId": "sgr-0c8098f72ff4c66cd",
            "GroupId": "sg-0ea87bf2cc1cabb58",
            "IpProtocol": "tcp",
            "FromPort": 80,
            "ToPort": 80,
            "ReferencedGroupInfo": {
                "GroupId": "sg-0d4462e05fdc97746"
            }
        }
    ]
}

Direct HTTP to the instance IP now goes nowhere. Everything has to come through the ALB.

Verifying the Setup

Check target health:

$ aws elbv2 describe-target-health     --target-group-arn "arn:aws:elasticloadbalancing:us-east-1:363479758429:targetgroup/laravel-blog-tg/d921805e7e5a464a"     --region us-east-1

Fri Jul 24 09:25:10 UTC 2024
{
    "TargetHealthDescriptions": [
        {
            "Target": {
                "Id": "i-06e46e1d4ff5d8dfc",
                "Port": 80
            },
            "TargetHealth": {
                "State": "healthy"
            }
        }
    ]
}

“healthy” is what you’re after. Still showing “initial”? Give it another 30 to 60 seconds and run it again. The first check cycle takes a moment to come around.

Then hit the ALB DNS name:

$ curl -s -o /dev/null -w "HTTP Status: %{http_code}
"     http://laravel-blog-alb-1793611230.us-east-1.elb.amazonaws.com

Fri Jul 24 09:25:41 UTC 2024
HTTP Status: 200

Done. Laravel’s behind the load balancer.

Troubleshooting: Target Stays “unhealthy”

If something’s going to go wrong, it’s usually this. Three things to check.

EC2 security group isn’t allowing the health checks. Health check traffic comes from the ALB, from the subnets you assigned it. No inbound rule on port 80 from the ALB SG means every check fails and the target never flips to healthy. Confirm the rule is actually there:

$ aws ec2 describe-security-group-rules     --filters Name=group-id,Values=sg-0ea87bf2cc1cabb58     --query "SecurityGroupRules[?!IsEgress].{Port:FromPort,SourceSG:ReferencedGroupInfo.GroupId,CIDR:CidrIpv4}"     --output table --region us-east-1

Fri Jul 24 09:27:33 UTC 2024
---------------------------------------------------------------
|               DescribeSecurityGroupRules                    |
+------+-------------------------+----------------------------+
| Port |        SourceSG         |           CIDR             |
+------+-------------------------+----------------------------+
|  22  |  None                   |  0.0.0.0/0                 |
|  80  |  sg-0d4462e05fdc97746   |  None                      |
+------+-------------------------+----------------------------+

Port 80 with SourceSG set to the ALB security group. That’s the one you want to see.

Health check path isn’t returning 200. If / throws a 404 or a 500, the check fails no matter how healthy the server actually is. Either fix the route, or point the check somewhere dumber. Laravel 11 ships with a /up endpoint that returns 200 and does basically nothing, which is perfect for this:

$ aws elbv2 modify-target-group     --target-group-arn "arn:aws:elasticloadbalancing:us-east-1:363479758429:targetgroup/laravel-blog-tg/d921805e7e5a464a"     --health-check-path "/up"     --region us-east-1

Nginx isn’t running. Happens more often than you’d think, especially after a reboot. SSH in and look:

$ ssh -i your-key.pem ubuntu@your-ec2-ip
$ sudo systemctl status nginx

Fri Jul 24 09:30:15 UTC 2024
● nginx.service - A high performance web server
   Loaded: loaded (/lib/systemd/system/nginx.service; enabled)
   Active: active (running) since Fri 2024-07-25 08:00:31 UTC

Not active? sudo systemctl start nginx and the next health check interval should sort it out.

Quick Reference — Resources Created

ResourceName / ID
ALB Security Grouplaravel-blog-alb-sg (sg-0d4462e05fdc97746)
Target Grouplaravel-blog-tg
Application Load Balancerlaravel-blog-alb
ALB DNS Namelaravel-blog-alb-1793611230.us-east-1.elb.amazonaws.com
ListenerHTTP:80 → forward to target group

What’s Next

Everything above is HTTP only. Once you’ve got a domain pointed at this thing, the SSL side is four steps:

  1. Request a free certificate in AWS Certificate Manager for your domain
  2. Add an HTTPS listener on 443 using that certificate
  3. Change the port 80 listener to redirect to HTTPS
  4. Point your domain at the ALB DNS name with a CNAME, or a Route 53 alias record

No Certbot, no renewal cron job, no 3am expiry alerts. ACM rotates the certificate on its own.

Conclusion

So that’s an ALB in front of a Laravel app on EC2, start to finish, all from the CLI. A dedicated security group for the load balancer, a target group with health checks, the ALB across two AZs, an HTTP listener, and an EC2 that no longer answers the internet directly.

What you get out of it: a DNS endpoint that survives instance restarts, health monitoring you didn’t have to build, and a setup where adding a second server or turning on HTTPS is a small change instead of a rebuild.

Avatar photo

Asif Khan

I have spent over 10 years working across IT systems, open source software, DevOps, Linux administration and cloud operations. Three things drive most of what I do: automation, security and resilience. Much of that work involves planning and building the platforms that sit behind services people rely on daily, which means designing for failure just as carefully as for load. Cloud computing held my attention early on, largely for its flexibility. Being able to scale up and then back down again means far less guessing about how much capacity you will need. Across projects I work with the full DevOps toolchain, from provisioning, orchestration and configuration management through to release management and microservices architecture.