{"id":146,"date":"2025-12-21T12:00:00","date_gmt":"2025-12-21T12:00:00","guid":{"rendered":"https:\/\/rezachegini.com\/?p=146"},"modified":"2026-10-30T06:54:32","modified_gmt":"2026-10-30T06:54:32","slug":"aws-application-load-balancer-from-basic-setup-to-advanced-routing-with-terraform","status":"publish","type":"post","link":"https:\/\/rezachegini.com\/?p=146","title":{"rendered":"AWS Application Load Balancer: From Basic Setup to Advanced Routing with Terraform"},"content":{"rendered":"<h3 class=\"wp-block-heading\">Why I Built These Three ALB Projects<\/h3><p class=\"wp-block-paragraph\">When I first heard about Application Load Balancer, I thought &#8220;it&#8217;s just distributes traffic, right?&#8221; Wrong! ALB is like a smart traffic cop that can make decisions based on URL paths, domain names, HTTP headers, and more.<\/p><p class=\"wp-block-paragraph\">I built three different ALB configurations to understand how traffic routing really works in production environments. Each project taught me something new about AWS networking and load balancing.<\/p><h3 class=\"wp-block-heading\">What I Built<\/h3><h3 class=\"wp-block-heading\">Project 1: Basic ALB (Foundation)<\/h3><ul class=\"wp-block-list\"><li>Simple HTTP load balancer<\/li><li>2 EC2 instances behind ALB<\/li><li>Basic health checks<\/li><li>Single application routing<\/li><\/ul><h3 class=\"wp-block-heading\">Project 2: Path-Based Routing (Microservices)<\/h3><ul class=\"wp-block-list\"><li>\/app1\/* \u2192 Application 1 instances<\/li><li>\/app2\/* \u2192 Application 2 instances<\/li><li>Different apps on same domain<\/li><li>SSL\/TLS with ACM certificate<\/li><\/ul><h3 class=\"wp-block-heading\">Project 3: Host Header Routing (Multi-Tenant)<\/h3><ul class=\"wp-block-list\"><li>app1.rezaops.com \u2192 Application 1<\/li><li>app2.rezaops.com \u2192 Application 2<\/li><li>Separate domains for each app<\/li><li>DNS integration with Route53<\/li><\/ul><h3 class=\"wp-block-heading\">Complete Architecture<\/h3><pre class=\"wp-block-code\"><code>Internet\n    \u2193\nRoute53 DNS\n    \u251c\u2500\u2500 apps.rezaops.com (Path-based)\n    \u251c\u2500\u2500 app1.rezaops.com (Host header)\n    \u2514\u2500\u2500 app2.rezaops.com (Host header)\n    \u2193\nApplication Load Balancer (Public Subnets)\n    \u251c\u2500\u2500 Listener: HTTP:80 \u2192 Redirect to HTTPS\n    \u2514\u2500\u2500 Listener: HTTPS:443\n        \u251c\u2500\u2500 Path \/app1\/* \u2192 Target Group 1\n        \u251c\u2500\u2500 Path \/app2\/* \u2192 Target Group 2\n        \u251c\u2500\u2500 Host app1.rezaops.com \u2192 Target Group 1\n        \u2514\u2500\u2500 Host app2.rezaops.com \u2192 Target Group 2\n    \u2193\nTarget Groups (with Health Checks)\n    \u251c\u2500\u2500 TG1: App1 Instances (Private Subnets)\n    \u2514\u2500\u2500 TG2: App2 Instances (Private Subnets)\n    \u2193\nEC2 Instances\n    \u251c\u2500\u2500 App1: 2 instances across 2 AZs\n    \u2514\u2500\u2500 App2: 2 instances across 2 AZs<\/code><\/pre><h3 class=\"wp-block-heading\">Technical Deep Dive<\/h3><h3 class=\"wp-block-heading\">1. Understanding the ALB Module Configuration<\/h3><p class=\"wp-block-paragraph\">I use Terraform AWS ALB module (version 9.11.0). Here&#8217;s the complete setup:<\/p><pre class=\"wp-block-code\"><code>module \"alb\" {\n  source  = \"terraform-aws-modules\/alb\/aws\"\n  version = \"9.11.0\"\n\n  name = \"${local.name}-alb\"\n  load_balancer_type = \"application\"\n  vpc_id = module.vpc.vpc_id\n  subnets = module.vpc.public_subnets\n  security_groups = [module.loadbalancer_sg.security_group_id]\n\n  enable_deletion_protection = false\n  \n  # Listeners and Rules configuration...\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Key parameters explained:<\/p><p class=\"wp-block-paragraph\">load_balancer_type = &#8220;application&#8221;<\/p><ul class=\"wp-block-list\"><li>Layer 7 load balancer (works with HTTP\/HTTPS)<\/li><li>Can inspect request content (paths, headers, cookies)<\/li><li>Different from NLB which is Layer 4 (TCP\/UDP)<\/li><\/ul><p class=\"wp-block-paragraph\">subnets = module.vpc.public_subnets<\/p><ul class=\"wp-block-list\"><li>ALB must be in public subnets to receive internet traffic<\/li><li>Distributes across multiple AZs automatically<\/li><li>Each AZ gets one ALB node<\/li><\/ul><p class=\"wp-block-paragraph\">enable_deletion_protection = false<\/p><ul class=\"wp-block-list\"><li>Set to true in production to prevent accidental deletion<\/li><li>I set false for learning environment (easier to destroy)<\/li><\/ul><h3 class=\"wp-block-heading\">2. HTTP to HTTPS Redirect &#8211; Security Best Practice<\/h3><p class=\"wp-block-paragraph\">Every production ALB should redirect HTTP to HTTPS. Here&#8217;s my configuration:<\/p><pre class=\"wp-block-code\"><code>listeners = {\n  # Listener 1: HTTP Redirect\n  my-http-https-redirect = {\n    port     = 80\n    protocol = \"HTTP\"\n    redirect = {\n      port        = \"443\"\n      protocol    = \"HTTPS\"\n      status_code = \"HTTP_301\"\n    }\n  }<\/code><\/pre><p class=\"wp-block-paragraph\">Why HTTP 301 redirect?<\/p><ul class=\"wp-block-list\"><li>301 = &#8220;Moved Permanently&#8221;<\/li><li>Browsers cache this redirect<\/li><li>Better for SEO than 302 (temporary redirect)<\/li><li>Users always get HTTPS even if they type http:\/\/<\/li><\/ul><p class=\"wp-block-paragraph\">What actually happens:<\/p><pre class=\"wp-block-code\"><code>User types: http:\/\/apps.rezaops.com\/app1\n    \u2193\nALB receives on port 80\n    \u2193\nALB responds: HTTP 301 \u2192 https:\/\/apps.rezaops.com\/app1\n    \u2193\nBrowser automatically requests HTTPS\n    \u2193\nUser gets secure connection<\/code><\/pre><h3 class=\"wp-block-heading\">3. HTTPS Listener with TLS 1.3<\/h3><p class=\"wp-block-paragraph\">Modern security requires strong encryption:<\/p><pre class=\"wp-block-code\"><code>my-https-listener = {\n  port                        = 443\n  protocol                    = \"HTTPS\"\n  ssl_policy                  = \"ELBSecurityPolicy-TLS13-1-2-Res-2021-06\"\n  certificate_arn             = module.acm.acm_certificate_arn<\/code><\/pre><p class=\"wp-block-paragraph\">SSL Policy explained:<\/p><ul class=\"wp-block-list\"><li>TLS13-1-2-Res-2021-06 = TLS 1.3 and TLS 1.2 only<\/li><li>Drops support for old TLS 1.0 and 1.1 (insecure)<\/li><li>&#8220;Res&#8221; = Restricted, more secure cipher suites<\/li><li>2021-06 = Policy version (AWS updates these)<\/li><\/ul><p class=\"wp-block-paragraph\">Why this matters:<\/p><ul class=\"wp-block-list\"><li>TLS 1.0\/1.1 have known vulnerabilities<\/li><li>PCI-DSS compliance requires TLS 1.2+<\/li><li>Modern browsers support TLS 1.3<\/li><li>Better performance with TLS 1.3<\/li><\/ul><h3 class=\"wp-block-heading\">4. Path-Based Routing Rules<\/h3><p class=\"wp-block-paragraph\">This is where ALB becomes really powerful. I can route different URL paths to different applications:<\/p><pre class=\"wp-block-code\"><code>rules = {\n  # Rule 1: \/app1* routes to App1\n  myapp1-rule = {\n    actions = [{\n      type = \"weighted-forward\"\n      target_groups = [\n        {\n          target_group_key = \"mytg1\"\n          weight           = 1\n        }\n      ]\n      stickiness = {\n        enabled  = true\n        duration = 3600\n      }\n    }]\n    conditions = [{\n      path_pattern = {\n        values = [\"\/app1*\"]\n      }\n    }]\n  }\n  \n  # Rule 2: \/app2* routes to App2\n  myapp2-rule = {\n    actions = [{\n      type = \"weighted-forward\"\n      target_groups = [\n        {\n          target_group_key = \"mytg2\"\n          weight           = 1\n        }\n      ]\n      stickiness = {\n        enabled  = true\n        duration = 3600\n      }\n    }]\n    conditions = [{\n      path_pattern = {\n        values = [\"\/app2*\"]\n      }\n    }]\n  }\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Path pattern matching:<\/p><ul class=\"wp-block-list\"><li>\/app1* matches: \/app1 \/app1\/ \/app1\/index.html \/app1\/users\/123<\/li><li>Does NOT match: \/app11 (need \/app1\/* for this) \/application1<\/li><\/ul><p class=\"wp-block-paragraph\">The asterisk matters!<\/p><ul class=\"wp-block-list\"><li>\/app1* = greedy match (matches everything starting with \/app1)<\/li><li>\/app1\/* = path segments (more specific)<\/li><\/ul><p class=\"wp-block-paragraph\">Weighted forward:<\/p><pre class=\"wp-block-code\"><code>weight = 1<\/code><\/pre><p class=\"wp-block-paragraph\">This is for A\/B testing or blue-green deployments. I can split traffic:<\/p><pre class=\"wp-block-code\"><code># Example: 80% to new version, 20% to old version\ntarget_groups = [\n  { target_group_key = \"new_version\", weight = 80 },\n  { target_group_key = \"old_version\", weight = 20 }\n]<\/code><\/pre><p class=\"wp-block-paragraph\">Sticky sessions:<\/p><pre class=\"wp-block-code\"><code>stickiness = {\n  enabled  = true\n  duration = 3600  # 1 hour in seconds\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Why sticky sessions are important:<\/p><ul class=\"wp-block-list\"><li>User connects to server-1<\/li><li>ALB creates cookie: AWSALB=xxxxx<\/li><li>For 3600 seconds, all requests go to same server<\/li><li>Important for stateful applications (shopping carts, login sessions)<\/li><\/ul><p class=\"wp-block-paragraph\">Without sticky sessions:<\/p><pre class=\"wp-block-code\"><code>Request 1 \u2192 Server 1 (user logs in)\nRequest 2 \u2192 Server 2 (user appears logged out!)<\/code><\/pre><p class=\"wp-block-paragraph\">With sticky sessions:<\/p><pre class=\"wp-block-code\"><code>Request 1 \u2192 Server 1 (user logs in)\nRequest 2 \u2192 Server 1 (user still logged in)<\/code><\/pre><h3 class=\"wp-block-heading\">5. Host Header-Based Routing<\/h3><p class=\"wp-block-paragraph\">Different approach &#8211; route based on domain name:<\/p><pre class=\"wp-block-code\"><code>conditions = [{\n  host_header = {\n    values = [var.app1_dns_name]  # \"app1.rezaops.com\"\n  }\n}]<\/code><\/pre><p class=\"wp-block-paragraph\">How this works:<\/p><p class=\"wp-block-paragraph\">HTTP request includes Host header:<\/p><pre class=\"wp-block-code\"><code>GET \/ HTTP\/1.1\nHost: app1.rezaops.com<\/code><\/pre><p class=\"wp-block-paragraph\">ALB checks the Host header and routes accordingly:<\/p><pre class=\"wp-block-code\"><code>Host: app1.rezaops.com \u2192 Target Group 1\nHost: app2.rezaops.com \u2192 Target Group 2\nHost: unknown.com \u2192 Default action (fixed response)<\/code><\/pre><p class=\"wp-block-paragraph\">Use cases for host header routing:<\/p><ul class=\"wp-block-list\"><li>Multi-tenant SaaS: customer1.app.com, customer2.app.com<\/li><li>Regional sites: us.company.com, eu.company.com<\/li><li>Brand separation: brand1.com, brand2.com<\/li><li>Development environments: dev.app.com, staging.app.com, prod.app.com<\/li><\/ul><h3 class=\"wp-block-heading\">6. Target Groups &#8211; The Backend Configuration<\/h3><p class=\"wp-block-paragraph\">Target groups define where traffic actually goes:<\/p><pre class=\"wp-block-code\"><code>target_groups = {\n  mytg1 = {\n    create_attachment = false  # VERY IMPORTANT!\n    name_prefix       = \"mytg1-\"\n    protocol          = \"HTTP\"\n    port              = 80\n    target_type       = \"instance\"\n    deregistration_delay = 10\n    load_balancing_cross_zone_enabled = false\n    protocol_version = \"HTTP1\"\n    \n    health_check = {\n      enabled             = true\n      interval            = 30\n      path                = \"\/app1\/index.html\"\n      port                = \"traffic-port\"\n      healthy_threshold   = 3\n      unhealthy_threshold = 3\n      timeout             = 6\n      protocol            = \"HTTP\"\n      matcher             = \"200-399\"\n    }\n  }\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Why create_attachment = false?<\/p><p class=\"wp-block-paragraph\">This is a workaround for Terraform module limitation. The module cannot handle for_each instances properly, so I create attachments separately:<\/p><pre class=\"wp-block-code\"><code>resource \"aws_lb_target_group_attachment\" \"mytg1\" {\n  for_each = {for k,v in module.ec2_private_app1: k =&gt; v}\n  target_group_arn = module.alb.target_groups[\"mytg1\"].arn\n  target_id        = each.value.id\n  port             = 80\n}<\/code><\/pre><p class=\"wp-block-paragraph\">This creates one attachment per EC2 instance dynamically.<\/p><p class=\"wp-block-paragraph\">Deregistration delay:<\/p><pre class=\"wp-block-code\"><code>deregistration_delay = 10<\/code><\/pre><p class=\"wp-block-paragraph\">What happens when instance is removed:<\/p><ol class=\"wp-block-list\"><li>ALB marks instance as &#8220;draining&#8221;<\/li><li>Waits 10 seconds for existing connections to finish<\/li><li>No NEW connections sent to this instance<\/li><li>After 10 seconds, forcefully closes remaining connections<\/li><li>Instance fully deregistered<\/li><\/ol><p class=\"wp-block-paragraph\">In production, use 300 seconds (5 minutes) for long-running requests.<\/p><p class=\"wp-block-paragraph\">Load balancing cross-zone:<\/p><pre class=\"wp-block-code\"><code>load_balancing_cross_zone_enabled = false<\/code><\/pre><p class=\"wp-block-paragraph\">With cross-zone disabled (what I use):<\/p><pre class=\"wp-block-code\"><code>AZ-1: 60% traffic \u2192 instances in AZ-1\nAZ-2: 40% traffic \u2192 instances in AZ-2<\/code><\/pre><p class=\"wp-block-paragraph\">With cross-zone enabled:<\/p><pre class=\"wp-block-code\"><code>AZ-1: 50% traffic \u2192 all instances\nAZ-2: 50% traffic \u2192 all instances<\/code><\/pre><p class=\"wp-block-paragraph\">Cross-zone costs money (data transfer between AZs), so I disabled it for learning.<\/p><h3 class=\"wp-block-heading\">7. Health Checks &#8211; Keeping Traffic Away from Broken Instances<\/h3><p class=\"wp-block-paragraph\">Health checks are critical for high availability:<\/p><pre class=\"wp-block-code\"><code>health_check = {\n  enabled             = true\n  interval            = 30      # Check every 30 seconds\n  path                = \"\/app1\/index.html\"\n  port                = \"traffic-port\"\n  healthy_threshold   = 3       # 3 success \u2192 healthy\n  unhealthy_threshold = 3       # 3 failures \u2192 unhealthy\n  timeout             = 6       # Wait 6 seconds for response\n  protocol            = \"HTTP\"\n  matcher             = \"200-399\"  # Accept 200-399 status codes\n}<\/code><\/pre><p class=\"wp-block-paragraph\">How health checks work:<\/p><p class=\"wp-block-paragraph\">Every 30 seconds, ALB sends:<\/p><pre class=\"wp-block-code\"><code>GET \/app1\/index.html HTTP\/1.1\nHost: &lt;instance-private-ip&gt;<\/code><\/pre><p class=\"wp-block-paragraph\">Scenario 1: Healthy instance<\/p><pre class=\"wp-block-code\"><code>t=0:  Request \u2192 200 OK (success count: 1)\nt=30: Request \u2192 200 OK (success count: 2)\nt=60: Request \u2192 200 OK (success count: 3, mark HEALTHY)<\/code><\/pre><p class=\"wp-block-paragraph\">Scenario 2: Instance becomes unhealthy<\/p><pre class=\"wp-block-code\"><code>t=0:  Request \u2192 Timeout (failure count: 1)\nt=30: Request \u2192 500 Error (failure count: 2)\nt=60: Request \u2192 Timeout (failure count: 3, mark UNHEALTHY)<\/code><\/pre><p class=\"wp-block-paragraph\">Once unhealthy, ALB stops sending traffic immediately.<\/p><p class=\"wp-block-paragraph\">Choosing health check path:<\/p><ul class=\"wp-block-list\"><li>Good: \/health endpoint that checks database, dependencies<\/li><li>Better: \/app1\/index.html actual application page<\/li><li>Best: Custom \/health-check that verifies entire stack<\/li><\/ul><p class=\"wp-block-paragraph\">Matcher = &#8220;200-399&#8221;<\/p><p class=\"wp-block-paragraph\">This accepts:<\/p><ul class=\"wp-block-list\"><li>200 OK<\/li><li>201 Created<\/li><li>204 No Content<\/li><li>301 Redirect<\/li><li>304 Not Modified<\/li><\/ul><p class=\"wp-block-paragraph\">Does NOT accept:<\/p><ul class=\"wp-block-list\"><li>400 Bad Request<\/li><li>401 Unauthorized<\/li><li>500 Server Error<\/li><li>503 Service Unavailable<\/li><\/ul><h3 class=\"wp-block-heading\">8. ACM Certificate with DNS Validation<\/h3><p class=\"wp-block-paragraph\">For HTTPS, I need SSL certificate. AWS Certificate Manager makes this easy:<\/p><pre class=\"wp-block-code\"><code>module \"acm\" {\n  source  = \"terraform-aws-modules\/acm\/aws\"\n  version = \"5.1.0\"\n\n  domain_name  = trimsuffix(data.aws_route53_zone.mydomain.name, \".\")\n  zone_id      = data.aws_route53_zone.mydomain.zone_id\n\n  subject_alternative_names = [\n    \"*.rezaops.com\"  # Wildcard for all subdomains\n  ]\n  \n  validation_method = \"DNS\"\n  wait_for_validation = true\n  \n  tags = local.common_tags\n}<\/code><\/pre><p class=\"wp-block-paragraph\">DNS validation process:<\/p><ol class=\"wp-block-list\"><li>Terraform requests certificate for rezaops.com and *.rezaops.com<\/li><li>ACM generates CNAME records for validation:<\/li><\/ol><pre class=\"wp-block-code\"><code>_abc123.rezaops.com \u2192 _xyz789.acm-validation.aws.<\/code><\/pre><ol class=\"wp-block-list\"><li>Terraform automatically creates these CNAME records in Route53<\/li><li>ACM checks DNS and validates ownership<\/li><li>Certificate issued (usually takes 5-30 minutes)<\/li><\/ol><p class=\"wp-block-paragraph\">Wildcard certificate:<\/p><pre class=\"wp-block-code\"><code>subject_alternative_names = [\"*.rezaops.com\"]<\/code><\/pre><p class=\"wp-block-paragraph\">This covers:<\/p><ul class=\"wp-block-list\"><li>app1.rezaops.com \u2713<\/li><li>app2.rezaops.com \u2713<\/li><li>api.rezaops.com \u2713<\/li><li>www.rezaops.com \u2713<\/li><\/ul><p class=\"wp-block-paragraph\">Does NOT cover:<\/p><ul class=\"wp-block-list\"><li>rezaops.com \u2717 (need to include base domain separately)<\/li><li>sub.app1.rezaops.com \u2717 (wildcard only works for one level)<\/li><\/ul><pre class=\"wp-block-code\"><code>wait_for_validation = true<\/code><\/pre><p class=\"wp-block-paragraph\">Without this:<\/p><ul class=\"wp-block-list\"><li>Terraform completes immediately<\/li><li>Certificate still &#8220;Pending Validation&#8221;<\/li><li>ALB creation fails (no valid certificate)<\/li><\/ul><p class=\"wp-block-paragraph\">With this:<\/p><ul class=\"wp-block-list\"><li>Terraform waits for validation<\/li><li>Certificate shows &#8220;Issued&#8221;<\/li><li>ALB can use certificate immediately<\/li><\/ul><h3 class=\"wp-block-heading\">9. Route53 DNS Records<\/h3><p class=\"wp-block-paragraph\">I create DNS records that point to ALB:<\/p><pre class=\"wp-block-code\"><code># For path-based routing\nresource \"aws_route53_record\" \"apps_dns\" {\n  zone_id = data.aws_route53_zone.mydomain.zone_id\n  name = \"apps.rezaops.com\"\n  type = \"A\"\n\n  alias {\n    name = module.alb.dns_name\n    zone_id = module.alb.zone_id\n    evaluate_target_health = true\n  }\n}\n\n# For host header routing\nresource \"aws_route53_record\" \"app1_dns\" {\n  zone_id = data.aws_route53_zone.mydomain.zone_id\n  name = \"app1.rezaops.com\"\n  type = \"A\"\n\n  alias {\n    name = module.alb.dns_name\n    zone_id = module.alb.zone_id\n    evaluate_target_health = true\n  }\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Alias record vs CNAME:<\/p><p class=\"wp-block-paragraph\">CNAME (not recommended for root):<\/p><pre class=\"wp-block-code\"><code>app1.rezaops.com CNAME \u2192 myalb-123.us-east-1.elb.amazonaws.com<\/code><\/pre><ul class=\"wp-block-list\"><li>Costs money (queries charged)<\/li><li>Cannot use for root domain (rezaops.com)<\/li><li>Additional DNS lookup required<\/li><\/ul><p class=\"wp-block-paragraph\">Alias (AWS special):<\/p><pre class=\"wp-block-code\"><code>app1.rezaops.com A \u2192 alias to ALB<\/code><\/pre><ul class=\"wp-block-list\"><li>Free (no query charges)<\/li><li>Can use for root domain<\/li><li>Resolves directly to IP addresses<\/li><li>Better performance<\/li><\/ul><pre class=\"wp-block-code\"><code>evaluate_target_health = true<\/code><\/pre><p class=\"wp-block-paragraph\">Route53 checks:<\/p><ul class=\"wp-block-list\"><li>Is ALB healthy?<\/li><li>Are any targets healthy in ALB?<\/li><li>If all unhealthy, Route53 can failover to backup<\/li><\/ul><p class=\"wp-block-paragraph\">This adds DNS-level health checking on top of ALB health checks.<\/p><h3 class=\"wp-block-heading\">10. Load Balancer Security Group<\/h3><p class=\"wp-block-paragraph\">ALB needs its own security group:<\/p><pre class=\"wp-block-code\"><code>module \"loadbalancer_sg\" {\n  source  = \"terraform-aws-modules\/security-group\/aws\"\n  version = \"5.2.0\"\n\n  name = \"loadbalancer-sg\"\n  description = \"Security Group for ALB\"\n  vpc_id = module.vpc.vpc_id\n  \n  ingress_rules = [\"http-80-tcp\",\"https-443-tcp\"]\n  ingress_cidr_blocks = [\"0.0.0.0\/0\"]\n  \n  egress_rules = [\"all-all\"]\n  tags = local.common_tags\n\n  # Custom port for testing\n  ingress_with_cidr_blocks = [\n    {\n      from_port   = 81\n      to_port     = 81\n      protocol    = 6\n      description = \"Allow Port 81 from internet\"\n      cidr_blocks = \"0.0.0.0\/0\"\n    },\n  ]\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Security group flow for ALB:<\/p><p class=\"wp-block-paragraph\">Inbound:<\/p><pre class=\"wp-block-code\"><code>Internet (0.0.0.0\/0) \u2192 ALB Security Group\n  Port 80 (HTTP)     \u2192 Allowed\n  Port 443 (HTTPS)   \u2192 Allowed\n  Port 81 (custom)   \u2192 Allowed\n  Port 22 (SSH)      \u2192 Denied<\/code><\/pre><p class=\"wp-block-paragraph\">Outbound:<\/p><pre class=\"wp-block-code\"><code>ALB \u2192 Private Instances\n  All ports \u2192 Allowed (egress_rules = [\"all-all\"])<\/code><\/pre><p class=\"wp-block-paragraph\">Important: Private instance security group must allow ALB<\/p><p class=\"wp-block-paragraph\">Private instances security group:<\/p><pre class=\"wp-block-code\"><code>module \"private_sg\" {\n  ingress_rules = [\"ssh-tcp\", \"http-80-tcp\"]\n  ingress_cidr_blocks = [module.vpc.vpc_cidr_block]\n}<\/code><\/pre><p class=\"wp-block-paragraph\">This allows HTTP from entire VPC CIDR, which includes ALB.<\/p><p class=\"wp-block-paragraph\">Better approach:<\/p><pre class=\"wp-block-code\"><code>ingress_with_source_security_group_id = [\n  {\n    rule                     = \"http-80-tcp\"\n    source_security_group_id = module.loadbalancer_sg.security_group_id\n  }\n]<\/code><\/pre><p class=\"wp-block-paragraph\">This allows HTTP ONLY from ALB security group, not entire VPC.<\/p><h3 class=\"wp-block-heading\">11. Fixed Response for Unmatched Routes<\/h3><p class=\"wp-block-paragraph\">What happens when someone visits root path or unknown path?<\/p><pre class=\"wp-block-code\"><code>my-https-listener = {\n  port = 443\n  protocol = \"HTTPS\"\n  \n  # Default action when no rules match\n  fixed_response = {\n    content_type = \"text\/plain\"\n    message_body = \"Fixed Static message - for Root Context\"\n    status_code  = \"200\"\n  }<\/code><\/pre><p class=\"wp-block-paragraph\">Traffic flow:<\/p><p class=\"wp-block-paragraph\">Request: https:\/\/apps.rezaops.com\/<\/p><ul class=\"wp-block-list\"><li>Path = \/<\/li><li>Does NOT match \/app1*<\/li><li>Does NOT match \/app2*<\/li><li>Falls through to default action<\/li><li>Returns fixed response<\/li><\/ul><p class=\"wp-block-paragraph\">Request: https:\/\/apps.rezaops.com\/app1\/<\/p><ul class=\"wp-block-list\"><li>Path = \/app1\/<\/li><li>Matches \/app1* rule<\/li><li>Routes to App1 target group<\/li><\/ul><p class=\"wp-block-paragraph\">You can also return custom HTML:<\/p><pre class=\"wp-block-code\"><code>fixed_response = {\n  content_type = \"text\/html\"\n  message_body = \"&lt;html&gt;&lt;body&gt;&lt;h1&gt;Welcome!&lt;\/h1&gt;&lt;\/body&gt;&lt;\/html&gt;\"\n  status_code  = \"200\"\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Or redirect to specific URL:<\/p><pre class=\"wp-block-code\"><code>redirect = {\n  protocol    = \"HTTPS\"\n  port        = \"443\"\n  host        = \"www.example.com\"\n  path        = \"\/welcome\"\n  status_code = \"HTTP_301\"\n}<\/code><\/pre><h3 class=\"wp-block-heading\">Comparing the Three Routing Methods<\/h3><h3 class=\"wp-block-heading\">Basic ALB (Simplest)<\/h3><p class=\"wp-block-paragraph\">Use when:<\/p><ul class=\"wp-block-list\"><li>Single application<\/li><li>No need for complex routing<\/li><li>Just want high availability<\/li><\/ul><p class=\"wp-block-paragraph\">Configuration:<\/p><pre class=\"wp-block-code\"><code>listeners = {\n  my-http-listener = {\n    port = 80\n    protocol = \"HTTP\"\n    forward = {\n      target_group_key = \"mytg1\"\n    }\n  }\n}<\/code><\/pre><p class=\"wp-block-paragraph\">All traffic goes to one target group. Simple and effective.<\/p><h3 class=\"wp-block-heading\">Path-Based Routing (Microservices)<\/h3><p class=\"wp-block-paragraph\">Use when:<\/p><ul class=\"wp-block-list\"><li>Multiple applications on same domain<\/li><li>Microservices architecture<\/li><li>API versioning (v1\/, v2\/)<\/li><li>Different backends for different features<\/li><\/ul><p class=\"wp-block-paragraph\">Example use cases:<\/p><pre class=\"wp-block-code\"><code>https:\/\/api.company.com\/users\/*     \u2192 User service\nhttps:\/\/api.company.com\/products\/*  \u2192 Product service\nhttps:\/\/api.company.com\/orders\/*    \u2192 Order service\nhttps:\/\/api.company.com\/payments\/*  \u2192 Payment service<\/code><\/pre><p class=\"wp-block-paragraph\">Advantages:<\/p><ul class=\"wp-block-list\"><li>One domain, multiple backends<\/li><li>Easy SSL certificate management (single cert)<\/li><li>Cost effective (one ALB for multiple apps)<\/li><\/ul><p class=\"wp-block-paragraph\">Disadvantages:<\/p><ul class=\"wp-block-list\"><li>All apps must be on same domain<\/li><li>URL structure must be planned carefully<\/li><li>Path conflicts possible<\/li><\/ul><h3 class=\"wp-block-heading\">Host Header Routing (Multi-Tenant)<\/h3><p class=\"wp-block-paragraph\">Use when:<\/p><ul class=\"wp-block-list\"><li>Different domains for different applications<\/li><li>Multi-tenant SaaS<\/li><li>White-label applications<\/li><li>Brand separation<\/li><\/ul><p class=\"wp-block-paragraph\">Example use cases:<\/p><pre class=\"wp-block-code\"><code>customer1.saas.com \u2192 Customer 1 environment\ncustomer2.saas.com \u2192 Customer 2 environment\n\napp.company.com    \u2192 Main application\nadmin.company.com  \u2192 Admin panel\napi.company.com    \u2192 API gateway<\/code><\/pre><p class=\"wp-block-paragraph\">Advantages:<\/p><ul class=\"wp-block-list\"><li>Clean domain separation<\/li><li>Easy to add new tenants<\/li><li>Better branding (each customer has own domain)<\/li><\/ul><p class=\"wp-block-paragraph\">Disadvantages:<\/p><ul class=\"wp-block-list\"><li>Need wildcard certificate or multiple certificates<\/li><li>More DNS records to manage<\/li><li>Slightly more complex configuration<\/li><\/ul><h3 class=\"wp-block-heading\">Deployment Steps<\/h3><h3 class=\"wp-block-heading\">For Basic ALB:<\/h3><pre class=\"wp-block-code\"><code>cd AWS-ALB-Application-LoadBalancer-Basic\/terraform-manifests\nterraform init\nterraform apply<\/code><\/pre><p class=\"wp-block-paragraph\">Wait 5-10 minutes, then access via ALB DNS name:<\/p><pre class=\"wp-block-code\"><code># Get ALB DNS\nterraform output | grep dns_name\n\n# Test application\ncurl http:\/\/&lt;alb-dns&gt;\/app1\/index.html<\/code><\/pre><h3 class=\"wp-block-heading\">For Path-Based Routing:<\/h3><p class=\"wp-block-paragraph\">Prerequisites:<\/p><ol class=\"wp-block-list\"><li>Route53 hosted zone for your domain<\/li><li>Update domain in: c6-02-datasource-route53-zone.tf c11-acm-certificatemanager.tf c12-route53-dnsregistration.tf<\/li><\/ol><pre class=\"wp-block-code\"><code>cd ALB-Path-Based-Routing\/terraform-manifests\nterraform init\nterraform apply<\/code><\/pre><p class=\"wp-block-paragraph\">Wait for certificate validation (5-30 minutes), then:<\/p><pre class=\"wp-block-code\"><code>curl https:\/\/apps.rezaops.com\/app1\/\ncurl https:\/\/apps.rezaops.com\/app2\/<\/code><\/pre><h3 class=\"wp-block-heading\">For Host Header Routing:<\/h3><pre class=\"wp-block-code\"><code>cd ALB-Host-Header-Based-Routing\/terraform-manifests\n\n# Update domain configuration\nvim c6-02-datasource-route53-zone.tf\nvim loadbalancer.auto.tfvars\n\nterraform init\nterraform apply<\/code><\/pre><p class=\"wp-block-paragraph\">Access applications:<\/p><pre class=\"wp-block-code\"><code>curl https:\/\/app1.rezaops.com\ncurl https:\/\/app2.rezaops.com<\/code><\/pre><h3 class=\"wp-block-heading\">Real-World Lessons I Learned<\/h3><h3 class=\"wp-block-heading\">1. Certificate Validation Can Fail Silently<\/h3><p class=\"wp-block-paragraph\">My problem: Certificate stuck at &#8220;Pending Validation&#8221; for hours.<\/p><p class=\"wp-block-paragraph\">Cause: I had wrong hosted zone ID in ACM module.<\/p><p class=\"wp-block-paragraph\">Solution: Always use data source for Route53 zone:<\/p><pre class=\"wp-block-code\"><code>data \"aws_route53_zone\" \"mydomain\" {\n  name = \"rezaops.com\"\n  private_zone = false\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Then reference it:<\/p><pre class=\"wp-block-code\"><code>zone_id = data.aws_route53_zone.mydomain.zone_id<\/code><\/pre><p class=\"wp-block-paragraph\">How to debug:<\/p><pre class=\"wp-block-code\"><code># Check certificate status\naws acm describe-certificate --certificate-arn &lt;ARN&gt;\n\n# Verify DNS records were created\naws route53 list-resource-record-sets --hosted-zone-id &lt;ZONE_ID&gt;\n\n# Look for _acm-challenge CNAME records<\/code><\/pre><h3 class=\"wp-block-heading\">2. Target Group Attachment Timing<\/h3><p class=\"wp-block-paragraph\">The error I got:<\/p><pre class=\"wp-block-code\"><code>Error: Error attaching target to target group: InvalidTarget<\/code><\/pre><p class=\"wp-block-paragraph\">Why it happened: Terraform tried to attach instances to target group before instances were running.<\/p><p class=\"wp-block-paragraph\">The fix:<\/p><pre class=\"wp-block-code\"><code>resource \"aws_lb_target_group_attachment\" \"mytg1\" {\n  for_each = {for k,v in module.ec2_private_app1: k =&gt; v}\n  target_group_arn = module.alb.target_groups[\"mytg1\"].arn\n  target_id        = each.value.id\n  port             = 80\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Terraform automatically waits for instances to exist because we reference module.ec2_private_app1.<\/p><h3 class=\"wp-block-heading\">3. Security Group Dependency<\/h3><p class=\"wp-block-paragraph\">Problem: Health checks failing even though Apache was running.<\/p><p class=\"wp-block-paragraph\">Cause: I forgot ALB needs permission to reach private instances on port 80.<\/p><p class=\"wp-block-paragraph\">Solution: Private instance security group MUST allow HTTP from VPC CIDR:<\/p><pre class=\"wp-block-code\"><code>ingress_rules = [\"ssh-tcp\", \"http-80-tcp\"]\ningress_cidr_blocks = [module.vpc.vpc_cidr_block]<\/code><\/pre><p class=\"wp-block-paragraph\">Or better, allow only from ALB:<\/p><pre class=\"wp-block-code\"><code>ingress_with_source_security_group_id = [\n  {\n    rule                     = \"http-80-tcp\"\n    source_security_group_id = module.loadbalancer_sg.security_group_id\n  }\n]<\/code><\/pre><h3 class=\"wp-block-heading\">4. Sticky Sessions and Session State<\/h3><p class=\"wp-block-paragraph\">What I learned: Without sticky sessions, user sessions broke when ALB sent requests to different instances!<\/p><p class=\"wp-block-paragraph\">Why:<\/p><pre class=\"wp-block-code\"><code>Request 1 \u2192 Server A (add item to cart)\nRequest 2 \u2192 Server B (cart is empty, session on Server A)<\/code><\/pre><p class=\"wp-block-paragraph\">Solution: Enable sticky sessions:<\/p><pre class=\"wp-block-code\"><code>stickiness = {\n  enabled  = true\n  duration = 3600\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Now ALB remembers which server each user should go to.<\/p><p class=\"wp-block-paragraph\">Even better solution for production: Store sessions in:<\/p><ul class=\"wp-block-list\"><li>Redis or Memcached (ElastiCache)<\/li><li>DynamoDB<\/li><li>RDS database<\/li><\/ul><p class=\"wp-block-paragraph\">Then it doesn&#8217;t matter which server user hits.<\/p><h3 class=\"wp-block-heading\">5. Health Check Path Must Exist<\/h3><p class=\"wp-block-paragraph\">Mistake I made: Health check path: \/health Actual file: \/app1\/index.html<\/p><p class=\"wp-block-paragraph\">Result: All instances marked unhealthy!<\/p><p class=\"wp-block-paragraph\">Lesson: Health check path must return 200 OK:<\/p><pre class=\"wp-block-code\"><code># Test from inside instance\ncurl http:\/\/localhost\/app1\/index.html\n# Should return: 200 OK\n\n# If it returns 404, ALB marks instance unhealthy<\/code><\/pre><h3 class=\"wp-block-heading\">6. DNS Propagation Takes Time<\/h3><p class=\"wp-block-paragraph\">After running terraform apply, I couldn&#8217;t access my application at app1.rezaops.com.<\/p><p class=\"wp-block-paragraph\">Reason: DNS changes take 5-60 minutes to propagate worldwide.<\/p><p class=\"wp-block-paragraph\">How to check:<\/p><pre class=\"wp-block-code\"><code># Check DNS resolution\ndig app1.rezaops.com\n\n# Or\nnslookup app1.rezaops.com\n\n# Should show ALB address<\/code><\/pre><p class=\"wp-block-paragraph\">Workaround for testing: Edit \/etc\/hosts on Linux\/Mac or C:\\Windows\\System32\\drivers\\etc\\hosts on Windows:<\/p><pre class=\"wp-block-code\"><code>&lt;ALB-IP-ADDRESS&gt; app1.rezaops.com<\/code><\/pre><h3 class=\"wp-block-heading\">7. Deregistration Delay Too Short<\/h3><p class=\"wp-block-paragraph\">I set:<\/p><pre class=\"wp-block-code\"><code>deregistration_delay = 10<\/code><\/pre><p class=\"wp-block-paragraph\">During deployment, some user requests failed!<\/p><p class=\"wp-block-paragraph\">Why: Long-running API calls (10+ seconds) were forcefully terminated.<\/p><p class=\"wp-block-paragraph\">Better value :<\/p><pre class=\"wp-block-code\"><code>deregistration_delay = 300  # 5 minutes<\/code><\/pre><p class=\"wp-block-paragraph\">This gives enough time for:<\/p><ul class=\"wp-block-list\"><li>File uploads to complete<\/li><li>Report generation to finish<\/li><li>Long API calls to respond<\/li><\/ul><h3 class=\"wp-block-heading\">8. Listener Rule Priority<\/h3><p class=\"wp-block-paragraph\">Problem I had: App2 traffic went to App1!<\/p><p class=\"wp-block-paragraph\">Cause: Rules are evaluated in priority order. I had:<\/p><pre class=\"wp-block-code\"><code>Priority 1: \/app* \u2192 App1 (matches both \/app1 and \/app2!)\nPriority 2: \/app2* \u2192 App2 (never reached)<\/code><\/pre><p class=\"wp-block-paragraph\">Solution: More specific rules get higher priority:<\/p><pre class=\"wp-block-code\"><code># This is automatic with Terraform module\n# More specific rules get lower priority numbers\n\/app1* \u2192 Priority 1\n\/app2* \u2192 Priority 2\n\/*     \u2192 Priority 3 (default)<\/code><\/pre><h3 class=\"wp-block-heading\">Monitoring and Troubleshooting<\/h3><h3 class=\"wp-block-heading\">Check ALB Health<\/h3><pre class=\"wp-block-code\"><code># List target health\naws elbv2 describe-target-health \\\n  --target-group-arn &lt;TG_ARN&gt;\n\n# Expected output:\n# {\n#   \"TargetHealthDescriptions\": [\n#     {\n#       \"Target\": { \"Id\": \"i-xxxxx\", \"Port\": 80 },\n#       \"HealthCheckPort\": \"80\",\n#       \"TargetHealth\": { \"State\": \"healthy\" }\n#     }\n#   ]\n# }<\/code><\/pre><h3 class=\"wp-block-heading\">Common Health Check States<\/h3><p class=\"wp-block-paragraph\">healthy &#8211; Everything working \u2713<\/p><p class=\"wp-block-paragraph\">unhealthy &#8211; Failed health checks \u2717<\/p><p class=\"wp-block-paragraph\">initial &#8211; Just registered, first check pending \u23f3<\/p><p class=\"wp-block-paragraph\">draining &#8211; Deregistering, finishing existing connections \ud83d\udd04<\/p><p class=\"wp-block-paragraph\">unavailable &#8211; Target doesn&#8217;t exist \u274c<\/p><h3 class=\"wp-block-heading\">Test Load Balancer<\/h3><pre class=\"wp-block-code\"><code># Basic connectivity\ncurl -v http:\/\/&lt;ALB-DNS&gt;\/app1\/index.html\n\n# Test SSL\ncurl -v https:\/\/&lt;ALB-DNS&gt;\/app1\/index.html\n\n# Check which instance handled request\ncurl http:\/\/&lt;ALB-DNS&gt;\/app1\/metadata.html\n# Shows instance ID in response\n\n# Test sticky sessions\ncurl -c cookies.txt http:\/\/&lt;ALB-DNS&gt;\/app1\/\ncurl -b cookies.txt http:\/\/&lt;ALB-DNS&gt;\/app1\/\n# Should hit same instance (check metadata)<\/code><\/pre><h3 class=\"wp-block-heading\">Debug Path Routing<\/h3><pre class=\"wp-block-code\"><code># This should route to App1\ncurl https:\/\/apps.rezaops.com\/app1\/\n\n# This should route to App2\ncurl https:\/\/apps.rezaops.com\/app2\/\n\n# This should return fixed response\ncurl https:\/\/apps.rezaops.com\/<\/code><\/pre><h3 class=\"wp-block-heading\">Debug Host Header Routing<\/h3><pre class=\"wp-block-code\"><code># Using curl with explicit Host header\ncurl -H \"Host: app1.rezaops.com\" http:\/\/&lt;ALB-DNS&gt;\/\n\n# Should be same as\ncurl https:\/\/app1.rezaops.com\/<\/code><\/pre>","protected":false},"excerpt":{"rendered":"<p>Why I Built These Three ALB Projects When I first heard about Application Load Balancer, I thought &#8220;it&#8217;s just distributes traffic, right?&#8221;&hellip;<\/p>\n","protected":false},"author":2,"featured_media":148,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_rezaops_part":"2","_rezaops_linkedin_url":"https:\/\/www.linkedin.com\/pulse\/aws-application-load-balancer-from-basic-setup-advanced-reza-chegini-0l1hc\/","footnotes":""},"categories":[85,10],"tags":[94,60,8,93,87],"series":[86],"class_list":["post-146","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-aws","category-devops","tag-alb","tag-aws","tag-devops","tag-load-balancer","tag-terraform","series-aws-with-terraform"],"jetpack_featured_media_url":"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/10\/aws-application-load-balancer-cover.png","_links":{"self":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/146","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=146"}],"version-history":[{"count":1,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/146\/revisions"}],"predecessor-version":[{"id":147,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/146\/revisions\/147"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/media\/148"}],"wp:attachment":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=146"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=146"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=146"},{"taxonomy":"series","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fseries&post=146"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}