Automate Application Deployment to AWS Elastic Beanstalk with Terraform and CodePipeline

Chinedu Orie - Oct 16 - - Dev Community

I frequently deploy Node.js applications to AWS Elastic Beanstalk (EB). This process usually involves configuring the EB's load balancer, listeners, and security groups and setting up a CodePipeline for continuous deployment of updates.

Handling the processes above manually is tedious and prone to errors that can keep you debugging for days. Another challenging task is tearing down the resources and cleaning up all related artefacts.

After days of trial and error and research, I've developed a Terraform code to handle the provisioning and de-provisioning with just two commands: terraform apply and terraform destroy.

Enough of theories; let's get to work!

Warning: The provided infrastructure configuration is intended for development purposes only. It may not meet the security standards required for production environments. Please implement additional security measures before deploying in production.

Prerequisites

  • AWS account credentials
  • Terraform CLI
  • Node.js Application on GitHub repository
  • Route 53 Hosted Zone for the domain name
  • Certificate for the domain name in AWS Certificate Manager
  • Ensure that you have buildspec.yml file in the root of the repository

AWS CodeBuild uses the buildspec.yml file to define the build process for your application. It specifies the commands and settings that CodeBuild uses to build and package your application.

Once the prerequisites above have been met, follow the steps below. Alternatively, you can clone the repository.

Step 1: Create a directory for the terraform codes

mkdir tf && cd tf
Enter fullscreen mode Exit fullscreen mode

Step 2: Initialize Terraform

terraform init
Enter fullscreen mode Exit fullscreen mode

Step 3: Create a terraform workspace

terraform workspace new dev # Replace dev with the name of your workspace
Enter fullscreen mode Exit fullscreen mode

Step 4: Create variables.tf file and paste the code below.

# variables.tf

variable "solution_stack_name" {
  type = string
  description = "The solution stack name to use for the Elastic Beanstalk environment e.g. 64bit Amazon Linux 2023 v6.0.3 running Node.js 18"
}
variable "tier" {
  type = string
  description = "The tier to use for the Elastic Beanstalk environment e.g. WebServer"
}


variable "instance_type" {
  default =  "t2.micro"
  description = "The instance type to use for the Elastic Beanstalk environment e.g. t2.micro"
}

variable "minsize" {
  default = 1
  description = "The minimum number of instances to use for the Elastic Beanstalk environment e.g. 1"
}

variable "maxsize" {
  default = 2
  description = "The maximum number of instances to use for the Elastic Beanstalk environment e.g. 2"
}

variable "certificate_arn" {
  type = string
  description = "The ARN of the certificate to use for the ELB e.g. arn:aws:acm:region:account-id:certificate/certificate-id"
}

variable "elb_policy_name" {
  default = "ELBSecurityPolicy-2016-08"
  description = "The name of the ELB policy to use e.g. ELBSecurityPolicy-2016-08"

}

variable "hosted_zone" {
  type = string
  description = "The hosted zone for the project e.g. example.com"
}

variable "project_name" {
  type = string
  description = "The name of the project e.g. project-name"
}

variable "elastic_beanstalk_env" {
  type = map(string)
  description = "The environment variables for the Elastic Beanstalk environment e.g. { \"key\" = \"value\" }"
}

variable "codebuild_env" {
  type = map(string)
  description = "The environment variables to use for the build e.g. { \"key\" = \"value\" }"
}

variable "build_image" {
  type = string
  default = "aws/codebuild/standard:7.0"
}

variable "repository_id" {
  type = string
  description = "The ID of the repository to use for the build e.g nedssoft/repository-name. nedssoft is the Github user name"
}

variable "branch_name" {
  type = string
  description = "The branch name to use for the build e.g. main"
}

variable "repository_url" {
  type = string
  description = "The URL of the repository to use for the build e.g. https://github.com/nedssoft/repository-name.git"
}
Enter fullscreen mode Exit fullscreen mode

The variables.tf file defines and exposes variables for use by other files.

Step 5: Create terraform.tfvars file and paste the code below.

# terraform.tfvars
project_name = "project-name"
hosted_zone = "example.com"
instance_type       = "t2.medium"
minsize             = 1
maxsize             = 4
tier = "WebServer"
solution_stack_name= "64bit Amazon Linux 2023 v6.0.3 running Node.js 18"
certificate_arn = "arn:aws:acm:region:account-id:certificate/certificate-id"

elastic_beanstalk_env = {
  "KEY" = "VALUE"
}

codebuild_env = {
  "KEY" = "VALUE"
}

repository_id = "nedssoft/repository-name"
branch_name = "branch-name"
repository_url = "https://github.com/nedssoft/repository-name.git"
Enter fullscreen mode Exit fullscreen mode

The terraform.tfvars is where you set the values for variables declared in variables.tf.

Step 6: Create main.tf file and paste the code below.

# main.tf

terraform {

# uncomment this if you want to use a backend
# backend "s3" {
#     bucket = "project-name-terraform-state-bucket"
#     key    = "terraform.tfstate"
#     region = "eu-west-2"
#     dynamodb_table =  "project-name-terraform-state-lock"
#     encrypt = true
#   }

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}

provider "aws" {
  region = "eu-west-2" # change this to your region

  # use this if you want to use a profile
  # profile = "profile-name"

  # use this if you are not using a profile
  access_key = "AWS_ACCESS_KEY"
  secret_key = "AWS_SECRET_KEY"
}

data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

locals {
  env = terraform.workspace
  base_name = format("%s-%s",var.project_name, terraform.workspace)
  account_id = data.aws_caller_identity.current.account_id
  region = data.aws_region.current.name
}
Enter fullscreen mode Exit fullscreen mode

In the provider directive, set the access_key and secret_key or use AWS credentials profile name as explained in the comments in main.tf.

Step 7: Create vpc.tf and paste the code below:


# vpc.tf

 data "aws_vpc" "default_vpc" {
  default = true
}

data "aws_subnet_ids" "default_subnet" {
  vpc_id = data.aws_vpc.default_vpc.id
}
Enter fullscreen mode Exit fullscreen mode

We are simply reading information from the default VPC; you can replace it if you use a custom VPC.

Step 8: Elastic Beanstalk - Create eb.tf and paste the code below:


# eb.tf

# Create elastic beanstalk application

resource "aws_elastic_beanstalk_application" "elasticapp" {
  name = "${local.base_name}-app"
}

resource "aws_security_group" "instances" {
  name = "${local.base_name}-sg"
}

resource "aws_security_group_rule" "allow_http_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.instances.id

  from_port   = 80
  to_port     = 80
  protocol    = "tcp"
  source_security_group_id = aws_security_group.alb.id
}
resource "aws_security_group_rule" "allow_https_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.instances.id
  from_port   = 443
  to_port     = 443
  protocol    = "tcp"
  cidr_blocks = ["0.0.0.0/0"]
}


resource "aws_security_group" "alb" {
  name = "${local.base_name}-alb-sg"
}

resource "aws_security_group_rule" "allow_alb_http_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.alb.id

  from_port   = 80
  to_port     = 80
  protocol    = "tcp"
  cidr_blocks = ["0.0.0.0/0"]

}
resource "aws_security_group_rule" "allow_alb_https_inbound" {
  type              = "ingress"
  security_group_id = aws_security_group.alb.id

  from_port   = 443
  to_port     = 443
  protocol    = "tcp"
  cidr_blocks = ["0.0.0.0/0"]

}

resource "aws_security_group_rule" "allow_alb_all_outbound" {
  type              = "egress"
  security_group_id = aws_security_group.alb.id

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

}

# Create elastic beanstalk Environment

resource "aws_elastic_beanstalk_environment" "beanstalkappenv" {
  name                = "${local.base_name}-env"
  application         = aws_elastic_beanstalk_application.elasticapp.name
  solution_stack_name = var.solution_stack_name
  tier                = var.tier

  setting {
    namespace = "aws:ec2:vpc"
    name      = "VPCId"
    value     = data.aws_vpc.default_vpc.id
  }

  setting {
    namespace = "aws:autoscaling:launchconfiguration"
    name      = "IamInstanceProfile"
    value     =  "aws-elasticbeanstalk-ec2-role"
  }
  setting {
    namespace = "aws:autoscaling:launchconfiguration"
    name      = "SecurityGroups"
    value     = aws_security_group.instances.id
  }
  setting {
    namespace = "aws:ec2:vpc"
    name      = "AssociatePublicIpAddress"
    value     =  "True"
  }

  setting {
    namespace = "aws:ec2:vpc"
    name      = "Subnets"
    value     = join(",", data.aws_subnet_ids.default_subnet.ids)
  }
  setting {
    namespace = "aws:elasticbeanstalk:environment:process:default"
    name      = "MatcherHTTPCode"
    value     = "200"
  }
  setting {
    namespace = "aws:elasticbeanstalk:environment"
    name      = "LoadBalancerType"
    value     = "application"
  }
  setting {
    namespace = "aws:autoscaling:launchconfiguration"
    name      = "InstanceType"
    value     = var.instance_type
  }
  setting {
    namespace = "aws:ec2:vpc"
    name      = "ELBScheme"
    value     = "internet facing"
  }
  setting {
    namespace = "aws:autoscaling:asg"
    name      = "MinSize"
    value     = var.minsize
  }
  setting {
    namespace = "aws:autoscaling:asg"
    name      = "MaxSize"
    value     = var.maxsize
  }
  setting {
    namespace = "aws:elasticbeanstalk:healthreporting:system"
    name      = "SystemType"
    value     = "enhanced"
  }

  setting {
    namespace = "aws:elbv2:loadbalancer"
    name      = "SecurityGroups"
    value     = aws_security_group.alb.id
  }

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

   setting {
    namespace = "aws:elbv2:listener:443"
    name      = "SSLPolicy"
    value     = var.elb_policy_name
  }

  dynamic "setting" {
    for_each = var.elastic_beanstalk_env
    content {
      namespace = "aws:elasticbeanstalk:application:environment"
      name      = setting.key
      value     = setting.value
    }
  }

  setting {
    namespace = "aws:elasticbeanstalk:application:environment"
    name      = "API_ENV"
    value     = local.env == "prod" ? "production": "staging"
  }

}

data "aws_route53_zone" "selected" {
  name = var.hosted_zone
}

data "aws_elastic_beanstalk_hosted_zone" "this" {}


resource "aws_route53_record" "beanstalkappenv" {
  zone_id = data.aws_route53_zone.selected.zone_id
  name    =  local.env != "prod" ? "${local.base_name}.${var.hosted_zone}" : "${var.project_name}.${var.hosted_zone}"
  type    = "A"

  alias {
   name    = aws_elastic_beanstalk_environment.beanstalkappenv.cname
   zone_id = "${data.aws_elastic_beanstalk_hosted_zone.this.id}"
    evaluate_target_health = true
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 9: Codebuild - Create codebuild.tf file and paste the code:


# codebuild.tf

resource "aws_s3_bucket" "build_artifacts" {
  bucket = "${local.base_name}-build-artifacts"
}

# Uncomment to enable cloudwatch logging
# resource "aws_cloudwatch_log_group" "codebuild_log_group" {
#   name = "${local.base_name}-codebuild-log-group"
# }

# Uncomment to enable cloudwatch logging
# resource "aws_cloudwatch_log_stream" "codebuild_log_stream" {
#   name           = "${local.base_name}-codebuild-log-stream"
#   log_group_name = aws_cloudwatch_log_group.codebuild_log_group.name
# }

data "aws_iam_policy_document" "assume_role" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["codebuild.amazonaws.com"]
    }

    actions = ["sts:AssumeRole"]
  }
}
resource "aws_iam_role" "build_role" {
  name               = "${local.base_name}-build-role"
  assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

data "aws_iam_policy_document" "coldbuild_policy" {

  statement {
    effect  = "Allow"
    actions = ["s3:*"]
    resources = [
      aws_s3_bucket.build_artifacts.arn,
      "${aws_s3_bucket.build_artifacts.arn}/*",
      aws_s3_bucket.pipeline_artifacts.arn,
      "${aws_s3_bucket.pipeline_artifacts.arn}/*",
    ]
  }

  statement {
    effect = "Allow"

    actions = [
      "logs:CreateLogGroup",
      "logs:CreateLogStream",
      "logs:PutLogEvents",
    ]

    resources = ["*"]
  }
}

resource "aws_iam_role_policy" "coldbuild_policy" {
  role   = aws_iam_role.build_role.name
  policy = data.aws_iam_policy_document.coldbuild_policy.json
}

resource "aws_codebuild_project" "build_project" {
  name          = "${local.base_name}-build-project"
  description   = "${local.base_name} build project"
  build_timeout = 5
  service_role  = aws_iam_role.build_role.arn

  artifacts {
    type = "NO_ARTIFACTS"
  }

  cache {
    type     = "S3"
    location = aws_s3_bucket.build_artifacts.bucket
  }

  environment {
    compute_type                = "BUILD_GENERAL1_MEDIUM"
    image                       = var.build_image
    type                        = "LINUX_CONTAINER"
    image_pull_credentials_type = "CODEBUILD"

    dynamic "environment_variable" {
      for_each = var.codebuild_env
      content {
        name  = environment_variable.key
        value = environment_variable.value
      }
    }

  }

  logs_config {
    cloudwatch_logs {
      status   = "DISABLED"
      # Uncomment if cloudwatch logging is enabled
      # group_name  = aws_cloudwatch_log_group.codebuild_log_group.name
      # stream_name = aws_cloudwatch_log_stream.codebuild_log_stream.name
    }

    s3_logs {
      status   = "DISABLED"
      # Uncomment if s3 logging is enabled
      # location = "${aws_s3_bucket.build_artifacts.bucket}/build-logs"
    }
  }

  source {
    type            = "GITHUB"
    location        = var.repository_url
    git_clone_depth = 1

    git_submodules_config {
      fetch_submodules = true
    }
  }

  source_version = var.branch_name

  tags = {
    # change this to your tags
    Environment = "Test"
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 10: CodePipeline - Create codepipeline.tf file and paste

the code below:


# codepipeline.tf

resource "aws_codestarconnections_connection" "github_connection" {
  name     = "${local.base_name}-github-connection"

  # You will need to verify the connection in the AWS CodeStar Connections console.
  provider_type = "GitHub"
}

resource "aws_s3_bucket" "pipeline_artifacts" {
  bucket = "${local.base_name}-pipeline-artifacts"

  tags = {
    Name        = "${local.base_name}-pipeline-artifacts"
  }
}

data "aws_iam_policy_document" "codepipeline_assume_role" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["codepipeline.amazonaws.com"]
    }

    actions = ["sts:AssumeRole"]
  }
}

resource "aws_iam_role" "codepipeline_role" {
  name = "${local.base_name}-codepipeline-role"

  assume_role_policy = data.aws_iam_policy_document.codepipeline_assume_role.json
}

data "aws_iam_policy_document" "codepipeline_policy" {
  statement {
    effect = "Allow"
    actions = [
      "codebuild:BatchGetBuilds",
      "codebuild:StartBuild",
      "elasticbeanstalk:*",
      "ec2:*",
      "elasticloadbalancing:*",
      "autoscaling:*",
      "cloudwatch:*",
      "s3:*",
      "cloudformation:*",
      "codestar-connections:UseConnection",
      "codestar-connections:GetConnection",
      "codestar-connections:ListConnections",
      "codestar-connections:GetIndividualAccountSetting",
      "codestar-connections:GetHostAccountSetting",
      "codestar-connections:ListHosts",
      "codestar-connections:ListInstallationTargets"
    ]
    resources = ["*"]
  }
}

resource "aws_iam_role_policy" "codepipeline_policy" {
  name = "${local.base_name}-codepipeline-policy"
  role = aws_iam_role.codepipeline_role.id

  policy = data.aws_iam_policy_document.codepipeline_policy.json
}

resource "aws_codepipeline" "deploy_pipeline" {
  name     = "${local.base_name}-deploy-pipeline"
  role_arn = aws_iam_role.codepipeline_role.arn

  artifact_store {
    location = aws_s3_bucket.pipeline_artifacts.bucket
    type     = "S3"
  }

  stage {
    name = "Source"
    action {
      name            = "Source"
      category        = "Source"
      owner           = "AWS"
      provider        = "CodeStarSourceConnection"
      version         = "1"
      output_artifacts = ["source_output"]
      configuration = {
        ConnectionArn = aws_codestarconnections_connection.github_connection.arn
        FullRepositoryId = var.repository_id
        BranchName = var.branch_name
      }
    }
  }

  stage {
    name = "Build"
    action {
      name            = "Build"
      category        = "Build"
      owner           = "AWS"
      provider        = "CodeBuild"
      version         = "1"
      input_artifacts = ["source_output"]
      output_artifacts = ["build_output"]
      configuration = {
        ProjectName = aws_codebuild_project.build_project.name
      }
    }
  }

  stage {
    name = "Deploy"
    action {
      name            = "Deploy"
      category        = "Deploy"
      owner           = "AWS"
      provider        = "ElasticBeanstalk"
      version         = "1"
      input_artifacts = ["build_output"]
      configuration = {
        ApplicationName = aws_elastic_beanstalk_application.elasticapp.name

        EnvironmentName = aws_elastic_beanstalk_environment.beanstalkappenv.name
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 11: Create outputs.tf file and paste the code below:


# outputs.tf

output "domain_name" {
  value = "https://${aws_route53_record.beanstalkappenv.name}/api"

}
Enter fullscreen mode Exit fullscreen mode

You can add as many values you want to see in the outputs.tf, after provisioning the resources, terraform will retrieve the values and output it on the terminal.

Final Step

Run the following commands to provision the resources:

terraform plan
Enter fullscreen mode Exit fullscreen mode

This will preview the changes that will be made afte the resources are provisioned

terraform apply
Enter fullscreen mode Exit fullscreen mode

This will provision the resources, and you are done.

To de-provision the resources, run

terraform destroy
Enter fullscreen mode Exit fullscreen mode

Note: you must empty the S3 buckets used by Codebuild and CodePipeline before de-provisioning the buckets.

Bonus - Terraform code for deploying frontend to AWS S3, Cloudfront with Codepipeline

https://github.com/nedssoft/s3-codepipeline-terrafrom

. . . . . . . . . . . . . . . . . . . . .