Back to blog
AWSBest PracticesCloud SecurityCost OptimizationNetworking

Classic Load Balancer In Use: Why You Should Migrate to ALB or NLB

Learn why AWS Classic Load Balancers are a security and cost liability, and follow a step-by-step guide to migrate to ALB or NLB safely.

TL;DR

This check flags any AWS Classic Load Balancer (ELB v1) still routing traffic. Classic LBs are a deprecated, less secure, more expensive generation. Migrate to an Application Load Balancer (HTTP/HTTPS) or Network Load Balancer (TCP/UDP) to get modern TLS policies, better routing, and lower cost.

The Classic Load Balancer is the original AWS load balancer, launched back in 2009. It works, and it keeps working, which is exactly why so many of them are still quietly humming along in production accounts years after AWS introduced better options. The lb_classic check looks across your account for any load balancer of the elb (v1) type and flags it so you can plan a migration.

This is not an emergency-grade finding in most cases, but it is a debt that accrues real interest: missing security features, higher per-hour costs, and the ongoing risk that AWS eventually restricts new Classic LB creation or capabilities further.


What this check detects

Lensix queries the Elastic Load Balancing v1 API (the elb namespace, not elbv2) and reports every Classic Load Balancer it finds in each region. If the call returns one or more load balancer descriptions, the check fails for that resource.

Note: AWS has three load balancer types. The Classic Load Balancer (CLB) is v1, managed under the elb API. The Application Load Balancer (ALB) and Network Load Balancer (NLB) are both v2, managed under the elbv2 API. The Gateway Load Balancer (GWLB) is also v2. This check only fires on v1.

You can reproduce the finding yourself:

# List all Classic Load Balancers in a region
aws elb describe-load-balancers \
  --region us-east-1 \
  --query 'LoadBalancerDescriptions[].LoadBalancerName' \
  --output table

If that returns names, you have Classic LBs. Compare against your v2 inventory to see what has already been migrated:

# List ALB / NLB / GWLB (v2) load balancers
aws elbv2 describe-load-balancers \
  --region us-east-1 \
  --query 'LoadBalancers[].[LoadBalancerName,Type]' \
  --output table

Why it matters

A Classic Load Balancer is not insecure by default, but it locks you out of capabilities that have become standard practice. Here is what you give up by staying on v1.

Weaker TLS control

Classic LBs support custom SSL negotiation policies, but they lag behind the predefined security policies available on ALB and NLB. Modern policies such as ELBSecurityPolicy-TLS13-1-2-2021-06 support TLS 1.3 and let you drop weak ciphers cleanly. On a Classic LB you are more likely to be running an older negotiation policy that still permits TLS 1.0 or 1.1, which fails most compliance baselines (PCI DSS, FedRAMP, and others).

No content-based routing

ALB routes on host header, path, HTTP method, query string, and source IP. Classic LBs cannot do any of this. Teams that need path-based routing end up running extra Classic LBs or stuffing routing logic into application code, which adds cost and operational surface.

No native integration with modern targets

Classic LBs predate target groups. That means no support for routing to Lambda functions, no IP-based targets, and clunkier integration with ECS and EKS. If you are running containers, a Classic LB forces you into instance-level routing instead of the far more flexible target group model.

Higher cost

Classic LBs bill per hour plus per GB processed. ALB and NLB use the LCU (Load Balancer Capacity Unit) model, which for most real workloads ends up cheaper because you pay for actual consumption across connections, new connections, bandwidth, and rule evaluations rather than a flat data charge.

Warning: Cost comparisons are workload-dependent. A mostly-idle Classic LB and a busy ALB can flip the math. Pull your actual processed-bytes and connection metrics from CloudWatch before assuming the migration saves money. The security and feature gains are the stronger argument.

Deprecation risk

AWS has not announced an end-of-life date for the Classic Load Balancer, but it has been in maintenance mode for years. New features land on ALB and NLB only. EC2-Classic networking, which Classic LBs were originally built around, has already been retired. The longer you wait, the more likely you are migrating under pressure rather than on your own schedule.


How to fix it

The fix is a migration, not a config flag. Pick the right replacement first, then move traffic.

Step 1: Choose ALB or NLB

  • Choose an ALB if your load balancer handles HTTP or HTTPS traffic and you want host or path routing, WAF integration, or authentication at the edge.
  • Choose an NLB if you need raw TCP/UDP, static IP addresses, extreme throughput, or TLS passthrough to your backends.

Step 2: Use the migration utility for a starting point

AWS publishes a Copy Utility that generates a CloudFormation template for an equivalent ALB or NLB from an existing Classic LB. It is the fastest way to get a draft you can refine.

# Clone the AWS load balancer copy utility
git clone https://github.com/aws/elastic-load-balancing-tools.git
cd elastic-load-balancing-tools/application-load-balancer-copy-utility

# Generate a CloudFormation template from an existing Classic LB
python copy_classic_load_balancer.py \
  --name my-classic-lb \
  --region us-east-1 \
  --output-template my-alb-template.json

Review the generated template carefully. The utility makes reasonable choices, but listener security policies, health check thresholds, and target registration almost always need tuning.

Step 3: Build the new load balancer with IaC

Rather than rely on the generated JSON long term, codify the replacement. Here is a minimal Terraform ALB that mirrors a typical Classic LB serving HTTPS:

resource "aws_lb" "app" {
  name               = "app-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = var.public_subnet_ids

  drop_invalid_header_fields = true
}

resource "aws_lb_target_group" "app" {
  name        = "app-tg"
  port        = 8080
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "instance"

  health_check {
    path                = "/health"
    healthy_threshold   = 3
    unhealthy_threshold = 3
    interval            = 15
    matcher             = "200"
  }
}

resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.app.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = var.acm_certificate_arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

# Redirect plain HTTP to HTTPS
resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.app.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "redirect"
    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

Tip: Set ssl_policy to a TLS 1.3 capable policy from the start. The Classic LB you are replacing almost certainly used something older, and the migration is the cleanest moment to drop legacy protocols without a separate change window.

Step 4: Register targets and verify health

Point the target group at the same instances (or IPs, or Lambda) the Classic LB served, then confirm they pass health checks before sending any real traffic.

# Register instances to the new target group
aws elbv2 register-targets \
  --target-group-arn arn:aws:elasticloadbalancing:us-east-1:111122223333:targetgroup/app-tg/abc123 \
  --targets Id=i-0abc123 Id=i-0def456

# Confirm they are healthy
aws elbv2 describe-target-health \
  --target-group-arn arn:aws:elasticloadbalancing:us-east-1:111122223333:targetgroup/app-tg/abc123 \
  --query 'TargetHealthDescriptions[].[Target.Id,TargetHealth.State]' \
  --output table

Step 5: Cut over DNS gradually

Use a weighted Route 53 record set to shift traffic from the Classic LB to the new one in increments. Start at 5 to 10 percent, watch your error rates and latency, then ramp up.

{
  "Comment": "Shift 10% of traffic to the new ALB",
  "Changes": [
    {
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "app.example.com",
        "Type": "A",
        "SetIdentifier": "new-alb",
        "Weight": 10,
        "AliasTarget": {
          "HostedZoneId": "Z35SXDOTRQ7X7K",
          "DNSName": "dualstack.app-alb-123456.us-east-1.elb.amazonaws.com",
          "EvaluateTargetHealth": true
        }
      }
    }
  ]
}

Step 6: Decommission the Classic LB

Once 100 percent of traffic is on the new load balancer and you have watched it through a full traffic cycle, delete the Classic LB.

Danger: Deleting a load balancer is irreversible and immediately stops serving any traffic still pointed at it. Confirm via CloudWatch RequestCount that the Classic LB is receiving zero requests, and that no DNS records or hardcoded endpoints still reference it, before running the delete.

# Verify the Classic LB is idle first
aws cloudwatch get-metric-statistics \
  --namespace AWS/ELB \
  --metric-name RequestCount \
  --dimensions Name=LoadBalancerName,Value=my-classic-lb \
  --start-time "$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ)" \
  --end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --period 300 --statistics Sum

# Once confirmed idle, delete it
aws elb delete-load-balancer --load-balancer-name my-classic-lb

How to prevent it from happening again

Removing existing Classic LBs is half the job. The other half is making sure no one spins up a new one.

Block creation with a Service Control Policy

An SCP at the organization or OU level denies the v1 create action outright, which stops Classic LBs before they exist:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyClassicLoadBalancers",
      "Effect": "Deny",
      "Action": "elasticloadbalancing:CreateLoadBalancer",
      "Resource": "*"
    }
  ]
}

Note: The elasticloadbalancing:CreateLoadBalancer action covers Classic LB creation. The v2 equivalent is elasticloadbalancing:CreateLoadBalancer as well, but the request shape differs. If you want to allow v2 while blocking v1, scope the deny with a condition or rely on the fact that the v1 API call is distinct, and test in a sandbox OU before rolling out org-wide.

Gate it in CI/CD with policy-as-code

Catch Classic LBs in pull requests before they reach an account. An OPA/Conftest rule against a Terraform plan:

package terraform.loadbalancer

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_elb"
  msg := sprintf(
    "Classic Load Balancer '%s' is not allowed. Use aws_lb (ALB/NLB) instead.",
    [resource.address],
  )
}

Wire it into your pipeline so the build fails on any aws_elb resource:

terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
conftest test tfplan.json --policy policy/

Keep Lensix running continuously

SCPs and CI gates stop new resources, but they will not surface a Classic LB someone created manually in a region you forgot about, or one inherited from an acquired account. Continuous scanning with the lb_classic check catches those across every region and account so a stray Classic LB does not sit unnoticed for another two years.


Best practices

  • Standardize on ALB for web, NLB for everything else. Pick a default so teams are not re-litigating the choice on every new service.
  • Always set a modern TLS security policy. Use a TLS 1.3 capable predefined policy and review it during each migration rather than carrying old negotiation settings forward.
  • Enable access logs. Both ALB and NLB write detailed access logs to S3. Turn them on so you have request-level visibility your Classic LB never gave you cleanly.
  • Use target groups, not instance lists. Target groups decouple the load balancer from the backends and make blue/green and canary deployments far easier.
  • Put a WAF in front of internet-facing ALBs. AWS WAF integrates natively with ALB, which is something a Classic LB cannot offer at all.
  • Tag and inventory. Tag every load balancer with an owner and environment so when this check fires you know who to talk to about the migration.

Migrating off Classic Load Balancers is rarely urgent, which is precisely why it gets deferred indefinitely. Treat the lb_classic finding as a prompt to schedule the work deliberately, while you control the timeline, instead of scrambling when AWS finally sets an end-of-life date.

Migrate AWS Classic Load Balancer to ALB or NLB | Lensix