Deploying AWS Elastic Beanstalk with Terraform

Learn how to provision an AWS Elastic Beanstalk application and environment with Terraform, including environment configuration, HTTPS, and blue/green deployments

Elastic Beanstalk is AWS’s original PaaS: you upload application code, and it provisions and wires together the EC2 instances, load balancer, Auto Scaling group, and security groups for you. It’s less commonly reached for than ECS/Fargate or Lambda today, but it’s still a legitimate fast path for teams that want a managed environment without hand-building the underlying infrastructure. This guide covers a Terraform-managed Beanstalk setup.

Prerequisites

  • AWS CLI configured with appropriate permissions
  • Terraform installed (version 1.0.0 or later)
  • An application bundle (zip of your app, or a Dockerrun.aws.json for the Docker platform)

Project Structure

aws-elastic-beanstalk-terraform/
├── main.tf
├── variables.tf
├── outputs.tf
└── terraform.tfvars

Application and Environment

# main.tf
provider "aws" {
  region = var.aws_region
}

resource "aws_elastic_beanstalk_application" "main" {
  name        = var.project_name
  description = "${var.project_name} application"
}

resource "aws_s3_bucket" "versions" {
  bucket = "${var.project_name}-eb-versions"
}

resource "aws_s3_object" "app_version" {
  bucket = aws_s3_bucket.versions.id
  key    = "app-${var.app_version_label}.zip"
  source = var.app_bundle_path
  etag   = filemd5(var.app_bundle_path)
}

resource "aws_elastic_beanstalk_application_version" "main" {
  name        = var.app_version_label
  application = aws_elastic_beanstalk_application.main.name
  bucket      = aws_s3_bucket.versions.id
  key         = aws_s3_object.app_version.key
}

resource "aws_elastic_beanstalk_environment" "main" {
  name                = "${var.project_name}-${var.environment}"
  application         = aws_elastic_beanstalk_application.main.name
  solution_stack_name = var.solution_stack_name
  version_label       = aws_elastic_beanstalk_application_version.main.name

  setting {
    namespace = "aws:autoscaling:launchconfiguration"
    name      = "IamInstanceProfile"
    value     = aws_iam_instance_profile.eb_ec2.name
  }

  setting {
    namespace = "aws:autoscaling:launchconfiguration"
    name      = "InstanceType"
    value     = var.instance_type
  }

  setting {
    namespace = "aws:autoscaling:launchconfiguration"
    name      = "SecurityGroups"
    value     = aws_security_group.eb_instances.id
  }

  setting {
    namespace = "aws:ec2:vpc"
    name      = "VPCId"
    value     = var.vpc_id
  }

  setting {
    namespace = "aws:ec2:vpc"
    name      = "Subnets"
    value     = join(",", var.private_subnet_ids)
  }

  setting {
    namespace = "aws:ec2:vpc"
    name      = "ELBSubnets"
    value     = join(",", var.public_subnet_ids)
  }

  setting {
    namespace = "aws:autoscaling:asg"
    name      = "MinSize"
    value     = var.min_instances
  }

  setting {
    namespace = "aws:autoscaling:asg"
    name      = "MaxSize"
    value     = var.max_instances
  }

  setting {
    namespace = "aws:elasticbeanstalk:environment"
    name      = "ServiceRole"
    value     = aws_iam_role.eb_service.name
  }

  setting {
    namespace = "aws:elasticbeanstalk:healthreporting:system"
    name      = "SystemType"
    value     = "enhanced"
  }

  setting {
    namespace = "aws:elasticbeanstalk:cloudwatch:logs"
    name      = "StreamLogs"
    value     = "true"
  }

  setting {
    namespace = "aws:elasticbeanstalk:cloudwatch:logs"
    name      = "RetentionInDays"
    value     = "30"
  }

  tags = {
    Environment = var.environment
  }
}

solution_stack_name pins the exact managed platform (language runtime + OS version), e.g. "64bit Amazon Linux 2023 v6.1.0 running Node.js 20". Beanstalk periodically retires old solution stacks - look up the current list with aws elasticbeanstalk list-available-solution-stacks rather than hardcoding a value from documentation, since it changes over time.

IAM Roles

Beanstalk needs two distinct roles: an instance profile the EC2 instances assume, and a service role Beanstalk itself assumes to manage resources on your behalf.

resource "aws_iam_role" "eb_ec2" {
  name = "${var.project_name}-eb-ec2-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "eb_web_tier" {
  role       = aws_iam_role.eb_ec2.name
  policy_arn = "arn:aws:iam::aws:policy/AWSElasticBeanstalkWebTier"
}

# Only attach AWSElasticBeanstalkMulticontainerDocker if you're actually on
# the Multicontainer Docker platform - the Node.js solution stack used in
# this guide doesn't need it, and least-privilege means not attaching
# platform-specific policies your environment doesn't run on.

resource "aws_iam_instance_profile" "eb_ec2" {
  name = "${var.project_name}-eb-ec2-profile"
  role = aws_iam_role.eb_ec2.name
}

resource "aws_iam_role" "eb_service" {
  name = "${var.project_name}-eb-service-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "elasticbeanstalk.amazonaws.com"
        }
        Condition = {
          StringEquals = {
            "sts:ExternalId" = "elasticbeanstalk"
          }
        }
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "eb_service_health" {
  role       = aws_iam_role.eb_service.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSElasticBeanstalkEnhancedHealth"
}

resource "aws_iam_role_policy_attachment" "eb_service_updates" {
  role       = aws_iam_role.eb_service.name
  policy_arn = "arn:aws:iam::aws:policy/AWSElasticBeanstalkManagedUpdatesCustomerRolePolicy"
}

Security Group

resource "aws_security_group" "eb_instances" {
  name        = "${var.project_name}-eb-instances"
  description = "Elastic Beanstalk EC2 instances"
  vpc_id      = var.vpc_id

  ingress {
    description = "HTTP from the Beanstalk-managed ALB"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = [var.vpc_cidr]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-eb-instances"
  }
}

HTTPS on the Load Balancer

resource "aws_elastic_beanstalk_environment" "https" {
  name                = "${var.project_name}-${var.environment}-https"
  application         = aws_elastic_beanstalk_application.main.name
  solution_stack_name = var.solution_stack_name
  version_label       = aws_elastic_beanstalk_application_version.main.name

  setting {
    namespace = "aws:elasticbeanstalk:environment"
    name      = "LoadBalancerType"
    value     = "application"
  }

  setting {
    namespace = "aws:elbv2:listener:443"
    name      = "Protocol"
    value     = "HTTPS"
  }

  setting {
    namespace = "aws:elbv2:listener:443"
    name      = "SSLCertificateArns"
    value     = var.acm_certificate_arn
  }

  setting {
    namespace = "aws:elbv2:listener:default"
    name      = "ListenerEnabled"
    value     = "false"
  }
}

Setting the default (port 80) listener’s ListenerEnabled to false closes plain HTTP entirely rather than leaving it open alongside HTTPS - add a redirect rule instead if you want HTTP requests to be forwarded to HTTPS rather than refused.

Blue/Green Deployment via Environment Swap

Beanstalk’s built-in deployment policies (AllAtOnce, Rolling, Immutable) update in place. For true zero-downtime blue/green, stand up a second environment and swap CNAMEs:

resource "aws_elastic_beanstalk_environment" "green" {
  name                = "${var.project_name}-${var.environment}-green"
  application         = aws_elastic_beanstalk_application.main.name
  solution_stack_name = var.solution_stack_name
  version_label       = aws_elastic_beanstalk_application_version.main.name

  # ... same settings as the primary environment above ...
}
aws elasticbeanstalk swap-environment-cnames \
  --source-environment-name "${PROJECT_NAME}-${ENVIRONMENT}" \
  --destination-environment-name "${PROJECT_NAME}-${ENVIRONMENT}-green"

Validate the green environment against its own (pre-swap) URL before swapping, and keep the old environment running post-swap until you’re confident, so a rollback is just swapping the CNAMEs back.

Variables Configuration

variable "aws_region" {
  description = "AWS region"
  type        = string
  default     = "us-west-2"
}

variable "project_name" {
  description = "Project name"
  type        = string
}

variable "environment" {
  description = "Environment name"
  type        = string
  default     = "dev"
}

variable "solution_stack_name" {
  description = "Beanstalk solution stack - check `aws elasticbeanstalk list-available-solution-stacks` for current values"
  type        = string
}

variable "app_version_label" {
  description = "Version label for this deployment"
  type        = string
}

variable "app_bundle_path" {
  description = "Local path to the application zip bundle"
  type        = string
}

variable "vpc_id" {
  description = "VPC ID"
  type        = string
}

variable "vpc_cidr" {
  description = "CIDR block of the VPC"
  type        = string
}

variable "private_subnet_ids" {
  description = "Private subnet IDs for EC2 instances"
  type        = list(string)
}

variable "public_subnet_ids" {
  description = "Public subnet IDs for the load balancer"
  type        = list(string)
}

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.small"
}

variable "min_instances" {
  description = "Minimum instances in the Auto Scaling group"
  type        = number
  default     = 2
}

variable "max_instances" {
  description = "Maximum instances in the Auto Scaling group"
  type        = number
  default     = 4
}

variable "acm_certificate_arn" {
  description = "ACM certificate ARN for the HTTPS listener"
  type        = string
  default     = ""
}

Best Practices

  1. Platform Currency

    • Beanstalk deprecates old solution stacks on a schedule - enable managed platform updates (aws:elasticbeanstalk:managedactions) so minor platform patches apply automatically during a maintenance window
    • Re-verify solution_stack_name whenever you touch this configuration; a stack that was current when the post was written may already be retired by the time you read it
  2. Deployment Safety

    • Use Immutable deployment policy for anything customer-facing - it launches a full new Auto Scaling group before cutting over, so a bad deploy never touches live capacity
    • Keep a green environment on standby for genuinely zero-downtime swaps when Immutable isn’t enough
  3. Networking

    • Put instances in private subnets with the ALB in public subnets (ELBSubnets vs Subnets above) rather than giving instances public IPs directly

Conclusion

Elastic Beanstalk trades control for speed: you give up the fine-grained infrastructure control of hand-rolled ECS/EKS in exchange for a working load-balanced, auto-scaled environment from a single terraform apply. It’s a reasonable choice for internal tools and steady-state web apps where that tradeoff makes sense - less so for anything that needs custom networking topology or non-standard deployment orchestration.

Remember to:

  • Check current solution stacks before deploying, not just at write time
  • Test deployment policy choices against an actual bad deploy in a non-production environment
  • Keep the EC2 instance role scoped to what the application needs, not the broad managed policies by default