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
-
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_namewhenever you touch this configuration; a stack that was current when the post was written may already be retired by the time you read it
- Beanstalk deprecates old solution stacks on a schedule - enable managed platform updates (
-
Deployment Safety
- Use
Immutabledeployment 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
Immutableisn’t enough
- Use
-
Networking
- Put instances in private subnets with the ALB in public subnets (
ELBSubnetsvsSubnetsabove) rather than giving instances public IPs directly
- Put instances in private subnets with the ALB in public subnets (
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