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
Step 2: Initialize Terraform
terraform init
Step 3: Create a terraform workspace
terraform workspace new dev # Replace dev with the name of your workspace
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"
}
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"
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
}
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
}
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
}
}
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"
}
}
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
}
}
}
}
Step 11: Create outputs.tf
file and paste the code below:
# outputs.tf
output "domain_name" {
value = "https://${aws_route53_record.beanstalkappenv.name}/api"
}
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
This will preview the changes that will be made afte the resources are provisioned
terraform apply
This will provision the resources, and you are done.
To de-provision the resources, run
terraform destroy
Note: you must empty the S3 buckets used by Codebuild and CodePipeline before de-provisioning the buckets.