Photo by Adrian Trinkaus / Unsplash

How To Deploy an Application On AWS ECS - The Wordpress Example

Create a WordPress stack with AWS ECS and RDS database using Terraform

Guillaume Vincent
Guillaume Vincent

Table of Contents

This article is a continuation of two previous articles. In the first one, we created an Ansible role including tests with the Molecule framework. From this base, we saw how to build a Docker image with Packer and Ansible in the second article. Now we have the WordPress image present in the AWS Elastic Container Registry (ECR).

Here we are going to write the Terraform code to deploy WordPress with Elastic Container Service (ECS). ECS is a fully managed container orchestration service. We will use AWS Fargate for containers for serverless compute engine.  In addition to ECS, we will use Relational Database Service (RDS) for the MySQL database.

The presented Terraform code here uses an AWS S3 bucket to store remote states. Each component like RDS is a module included in a dedicated layer. The dependencies between modules are solved through the remote states in S3. Therefore, the presentation order on this page matters. You can use the AWS Free Tier Program to deploy code for free. The presented architecture is not fully high-available to avoid costs and to be reproducible. The full code project is here.

Deploy AWS VPC & Subnets

For the Virtual Private Network (VPC) resources, we are going to use the default one to avoid extra cost. We use a single availability zone and one NAT gateway to avoid network transfer costs.

Create the aws-vpc layer in terraform/environments/dev/main.tf:

terraform {
  required_version = "> 0.15.0"
  backend "s3" {
	bucket  = "YOUR_BUCKET"
	key     = "dev/ecs-ansible-packer-terraform-wordpress/aws-vpc.tf"
	encrypt = true
	region  = "us-east-1"
  }
}

module "aws_vpc" {
  source            = "../../../modules/aws-vpc"
  region            = "us-east-1"
  availability_zone = "us-east-1a"
  tags              = {
	environment = "dev"
	project     = "ecs-wordpress"
	terraform   = true
  }
}

output "vpc_id" {
  value = module.aws_vpc.vpc_id
}

output "availability_zone" {
  value = module.aws_vpc.availability_zone
}

output "subnet_id" {
  value = module.aws_vpc.subnet_id
}

output "sg_id" {
  value = module.aws_vpc.sg_id
}
terraform/environments/dev/main.tf

Create the aws-vpc module in terraform/modules/aws-vpc:

resource "aws_default_vpc" "default" {
  tags = merge({
	name = local.name
  }, var.tags)
}

resource "aws_default_subnet" "default" {
  availability_zone = var.availability_zone
  tags              = merge({
	name = local.name
  }, var.tags)
}

resource "aws_default_security_group" "default" {
  vpc_id = aws_default_vpc.default.id

  egress {
	from_port   = 0
	to_port     = 0
	protocol    = "-1"
	cidr_blocks = [
	  "0.0.0.0/0"]
  }
  tags = merge({
	name = local.name
  }, var.tags)
}
terraform/modules/aws-vpc/main.tf
variable "region" {
  type        = string
  description = "(Required) AWS region"
}

variable "tags" {
  type        = map(string)
  description = "(Required) Tags for resources"
}

variable "availability_zone" {
  type = string
  description = "(Required) The subnet of the availability zone to use"
}
terraform/modules/aws-vpc/variables.tf
locals {
  name = "${var.tags["environment"]}-${var.tags["project"]}"
}
terraform/modules/aws-vpc/locals.tf
terraform {
  required_providers {
	aws = {
	  source  = "hashicorp/aws"
	  version = "~> 3.45.0"
	}
  }
}

provider "aws" {
  region = var.region
}
terraform/modules/aws-vpc/providers.tf
output "vpc_id" {
  description = "The default VPC ID"
  value = aws_default_vpc.default.id
}

output "subnet_id" {
  description = "The default subnet ID"
  value = aws_default_subnet.default.id
}

output "sg_id" {
  description = "The default security group ID"
  value = aws_default_security_group.default.id
}

output "availability_zone" {
  description = "The availability zone to use"
  value = aws_default_subnet.default.availability_zone
}
terraform/modules/aws-vpc/outputs.tf

In terraform/environments/dev/aws-vpc initialize and apply the layer:

$ terraform init
$ terraform apply

Deploy The MySQL Database With AWS RDS

For the database, we are going to configure and deploy a single RDS (Relational Database Service) instance running MySQL.

Create the aws-rdslayer in terraform/environments/dev/aws-rds:

terraform {
  required_version = "> 0.15.0"
  backend "s3" {
	bucket  = "guivin-terraform-states"
	key     = "dev/ecs-ansible-packer-terraform-wordpress/aws-rds.tf"
	encrypt = true
	region  = "us-east-1"
  }
}

data "terraform_remote_state" "aws_vpc" {
  backend = "s3"
  config  = {
	bucket = "guivin-terraform-states"
	key    = "dev/ecs-ansible-packer-terraform-wordpress/aws-vpc.tf"
	region = "us-east-1"
  }
}

locals {
  vpc_id    = data.terraform_remote_state.aws_vpc.outputs.vpc_id
  subnet_id = data.terraform_remote_state.aws_vpc.outputs.subnet_id
  sg_id     = data.terraform_remote_state.aws_vpc.outputs.sg_id
}

module "aws_rds" {
  source                  = "../../../modules/aws-rds"
  vpc_id                  = local.vpc_id
  region                  = "us-east-1"
  availability_zone       = "us-east-1a"
  allowed_security_groups = [
	local.sg_id]
  db_port                 = 3306
  db_name                 = "wordpress"
  db_allocated_storage    = 5
  db_instance_class       = "db.t2.micro"
  db_storage_type         = "gp2"
  db_username             = "wordpress"
  db_engine               = "mariadb"
  db_engine_version       = "10.5"
  db_parameter_group_name = "default.mysql10.5"
  db_skip_final_snapshot  = true
  tags                    = {
	environment = "dev"
	project     = "ecs-wordpress"
	terraform   = true
  }
}

output "db_endpoint" {
  value = module.aws_rds.db_endpoint
}

output "db_address" {
  value = module.aws_rds.db_address
}

output "db_port" {
  value = module.aws_rds.db_port
}

output "db_username" {
  value = module.aws_rds.db_username
}

output "db_password" {
  value     = module.aws_rds.db_password
  sensitive = true
}
terraform/environments/dev/aws-rds

Create the aws-rdsmodule in terraform/modules/aws-rds:

resource "aws_security_group" "default" {
  name        = local.name
  description = "Allow inbound access in port 3306 only"
  vpc_id      = var.vpc_id

  ingress {
	protocol        = "tcp"
	from_port       = var.db_port
	to_port         = var.db_port
	security_groups = var.allowed_security_groups
  }

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

  tags = merge({
	name = local.name
  }, var.tags)
}

resource "random_string" "password" {
  length    = 16
}

resource "aws_db_instance" "default" {
  allocated_storage      = var.db_allocated_storage
  storage_type           = var.db_storage_type
  engine                 = var.db_engine
  engine_version         = var.db_engine_version
  instance_class         = var.db_instance_class
  name                   = var.db_name
  username               = var.db_username
  password               = random_string.password.result
  vpc_security_group_ids = [
	aws_security_group.default.id]
  skip_final_snapshot    = var.db_skip_final_snapshot
  availability_zone      = var.availability_zone
  tags                   = merge({
	name = local.name
  }, var.tags)
}
terraform/modules/aws-rds/main.tf

The password is generated using Terraform resources to not expose sensitive information in the code.

locals {
  name = "${var.tags["environment"]}-${var.tags["project"]}-rds"
}
terraform/modules/aws-rds/locals.tf
output "db_endpoint" {
  description = "The database endpoint for connection"
  value       = aws_db_instance.default.endpoint
}

output "db_address" {
  description = "The database address for connection"
  value       = aws_db_instance.default.address
}

output "db_port" {
  description = "The database port for connection"
  value       = aws_db_instance.default.port
}

output "db_username" {
  description = "The database username for connection"
  value       = var.db_username
}

output "db_password" {
  description = "The database password for connection"
  value       = aws_db_instance.default.password
  sensitive   = true
}
terraform/modules/aws-rds/outputs.tf
terraform {
  required_providers {
	aws = {
	  source  = "hashicorp/aws"
	  version = "~> 3.45.0"
	}

	random = {
	  source  = "hashicorp/random"
	  version = "~> 3.1.0"
	}
  }
}

provider "aws" {
  region = var.region
}
terraform/modules/aws-rds/providers.tf
variable "region" {
  type        = string
  description = "(Required) AWS region to use"
}

variable "vpc_id" {
  type        = string
  description = "(Required) The VPC ID where to deploy RDS instance"
}

variable "allowed_security_groups" {
  type        = list(string)
  description = "(Optional) List of security groups authorized to connect to database port"
  default     = []
}

variable "availability_zone" {
  type        = string
  description = "(Required) Availability zone for the RDS instance"
}

variable "tags" {
  type        = map(string)
  description = "(Optional) Tags for resources"
}

variable "db_port" {
  type        = number
  description = "(Optional) TCP port for database to define for use"
  default     = 3306
}

variable "db_allocated_storage" {
  type        = number
  description = "(Required) The allocated storage in gibibytes"
}

variable "db_name" {
  type        = string
  description = "(Optional) The name of the database to create when the DB instance is created"
}

variable "db_instance_class" {
  type        = string
  description = "(Required) The instance type of the RDS instance"
}

variable "db_storage_type" {
  type        = string
  description = "(Optional) One of standard (magnetic), gp2 (general purpose SSD), or io1 (provisioned IOPS SSD)"
}

variable "db_username" {
  type        = string
  description = "(Required) Username for the master DB user"
}

variable "db_engine" {
  type        = string
  description = "(Required) The database engine to use"
}

variable "db_engine_version" {
  type        = string
  description = "(Optional) The engine version to use (https://docs.aws.amazon.com/AmazonRDS/latest/APIReference/API_CreateDBInstance.html)"
}

variable "db_parameter_group_name" {
  type        = string
  description = "(Optional) Name of the DB parameter group to associate"
}

variable "db_skip_final_snapshot" {
  type        = bool
  description = "(Optional) Determines whether a final DB snapshot is created before the DB instance is deleted"
}
terraform/modules/aws-rds/variables.tf

In terraform/environments/dev/aws-rds initialize and apply the layer:

$ terraform init
$ terraform apply
The created RDS database in the AWS web console
The created RDS database in the AWS web console

Deploy The AWS ECS Cluster

Create the aws-ecs layer in terraform/environments/dev/aws-ecs:

terraform {
  required_version = "> 0.15.0"
  backend "s3" {
	bucket  = "YOUR_BUCKET"
	key     = "dev/ecs-ansible-packer-terraform-wordpress/aws-ecs.tf"
	encrypt = true
	region  = "us-east-1"
  }
}

module "aws_ecs" {
  source = "../../../modules/aws-ecs"
  region = "us-east-1"
  tags   = {
	environment = "dev"
	project     = "ecs-wordpress"
	terraform   = true
  }
}

output "ecs_cluster_id" {
  value = module.aws_ecs.cluster_id
}

output "ecs_cloudwatch_group_name" {
  value = module.aws_ecs.cloudwatch_group_name
}

Create the aws-ecs module in terraform/modules/aws-ecs:

locals {
  name = "${var.tags["environment"]}-${var.tags["project"]}-ecs"
}

For observability, we create an AWS Cloudwatch group to watch the container's logs:

resource "aws_ecs_cluster" "default" {
  name = local.name
  tags = merge({
	name = local.name
  }, var.tags)
}

resource "aws_cloudwatch_log_group" "default" {
  name = local.name
  tags = merge({
	name = local.name
  }, var.tags)
}
view raw
output "cluster_id" {
  description = "The ECS cluster ID"
  value = aws_ecs_cluster.default.id
}

output "cloudwatch_group_name" {
  description = "The Cloudwatch group name to store container logs"
  value = aws_cloudwatch_log_group.default.name
}
terraform {
  required_providers {
	aws = {
	  source  = "hashicorp/aws"
	  version = "~> 3.45.0"
	}
  }
}

provider "aws" {
  region = var.region
}
variable "tags" {
  type = map(string)
  description = "(Required) Tags for resources"
}

variable "region" {
  type = string
  description = "(Required) AWS region"
}

In terraform/environments/dev/aws-ecs initialize and apply the layer :

$ terraform init
$ terraform apply

Deploy WordPress Application In AWS ECS

Create the aws-ecs-wordpress layer in terraform/environments/dev/aws-ecs-wordpress:

terraform {
  required_version = "> 0.15.0"
  backend "s3" {
	bucket  = "YOUR_BUCKET"
	key     = "dev/aws-ecs-wordpress/aws-ecs-wordpress.tf"
	encrypt = true
	region  = "us-east-1"
  }
}

data "terraform_remote_state" "aws_ecs" {
  backend = "s3"
  config  = {
	bucket = "YOUR_BUCKET"
	key    = "dev/ecs-ansible-packer-terraform-wordpress/aws-ecs.tf"
	region = "us-east-1"
  }
}

data "terraform_remote_state" "aws_ecr" {
  backend = "s3"
  config  = {
	bucket = "YOUR_BUCKET"
	key    = "dev/ecs-ansible-packer-terraform-wordpress/aws-ecr.tf"
	region = "us-east-1"
  }
}

data "terraform_remote_state" "aws_rds" {
  backend = "s3"
  config  = {
	bucket = "YOUR_BUCKET"
	key    = "dev/ecs-ansible-packer-terraform-wordpress/aws-rds.tf"
	region = "us-east-1"
  }
}

data "terraform_remote_state" "aws_vpc" {
  backend = "s3"
  config  = {
	bucket = "YOUR_BUCKET"
	key    = "dev/ecs-ansible-packer-terraform-wordpress/aws-vpc.tf"
	region = "us-east-1"
  }
}

locals {
  # Networking
  vpc_id                        = data.terraform_remote_state.aws_vpc.outputs.vpc_id
  subnet_id                     = data.terraform_remote_state.aws_vpc.outputs.subnet_id
  sg_id                         = data.terraform_remote_state.aws_vpc.outputs.sg_id
  # RDS
  db_username                   = data.terraform_remote_state.aws_rds.outputs.db_username
  db_host                       = data.terraform_remote_state.aws_rds.outputs.db_address
  db_password                   = data.terraform_remote_state.aws_rds.outputs.db_password
  db_port                       = data.terraform_remote_state.aws_rds.outputs.db_port
  # ECS
  ecs_cloudwatch_log_group_name = data.terraform_remote_state.aws_ecs.outputs.ecs_cloudwatch_group_name
  ecs_cluster_id                = data.terraform_remote_state.aws_ecs.outputs.ecs_cluster_id
  # ECR
  repository_url                = data.terraform_remote_state.aws_ecr.outputs.repository_url
}

module "aws-ecs-wordpress" {
  source                    = "../../../modules/aws-ecs-wordpress"
  region                    = "us-east-1"
  vpc_id                    = local.vpc_id
  subnet_id                 = local.subnet_id
  sg_id                     = local.sg_id
  cloudwatch_log_group_name = local.ecs_cloudwatch_log_group_name
  wordpress_db_host         = local.db_host
  wordpress_db_name         = "wordpress"
  wordpress_db_user         = local.db_username
  wordpress_db_password     = local.db_password
  wordpress_db_port         = local.db_port
  wordpress_port            = 80
  repository_url            = local.repository_url
  image_tag                 = "latest"
  desired_count             = 1
  # https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-cpu-memory-error.html
  fargate_cpu               = 256
  fargate_memory            = 512
  ecs_cluster_id            = local.ecs_cluster_id
  tags                      = {
	environment = "dev"
	project     = "ecs-wordpress"
	terraform   = true
  }
}

output "wordpress_admin_password" {
  description = "The Wordpress admin password"
  value       = module.aws-ecs-wordpress.wordpress_admin_password
  sensitive   = true
}

To deploy WordPress containers in the ECS cluster, we need to define :

  • An ECS service that allows to run and maintain a specified number of instances of a task definition. It will monitor the tasks if instances fail or stop and schedule new instances to meet the desired number of instances.
  • An ECS task definition defining how to run the Docker containers in AWS ECS

In addition to that, we will create a dedicated security group and IAM permissions for the ECS service.

Create the aws-ecs-wordpress module in terraform/modules/aws-ecs-wordpress:

locals {
  name = "${var.tags["environment"]}-${var.tags["project"]}"
}
resource "aws_iam_role" "ecs_task_execution_role" {
  name = "ecs-execution-role"

  assume_role_policy = <<EOF
{
  "Version": "2008-10-17",
  "Statement": [
    {
      "Sid": "",
      "Effect": "Allow",
      "Principal": {
        "Service": "ecs-tasks.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF
}

resource "aws_iam_policy" "ecs_task_execution_policy" {
  name = "ecs-execution-policy"

  policy = <<EOF
{
    "Version": "2012-10-17",
    "Statement": [
    {
        "Effect": "Allow",
        "Action": [
            "ecr:GetAuthorizationToken",
            "ecr:BatchCheckLayerAvailability",
            "ecr:GetDownloadUrlForLayer",
            "ecr:BatchGetImage",
            "logs:CreateLogStream",
            "logs:PutLogEvents"
            ],
            "Resource": "*"
        }
    ]
}
EOF
}

# Attach execution policy to execution role
resource "aws_iam_role_policy_attachment" "ecs_role_attach" {
  role       = aws_iam_role.ecs_task_execution_role.name
  policy_arn = aws_iam_policy.ecs_task_execution_policy.arn
}


resource "aws_security_group" "ecs_tasks" {
  name        = "wordpress-ecs-sg"
  description = "Allow inbound access in port 80 only"
  vpc_id      = var.vpc_id

  ingress {
	protocol    = "tcp"
	from_port   = var.wordpress_port
	to_port     = var.wordpress_port
	cidr_blocks = [
	  "0.0.0.0/0"]
  }

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

  tags = merge({
	name = local.name
  }, var.tags)
}

resource "random_string" "wordpress_admin_password" {
  length = 16
}

resource "aws_ecs_task_definition" "task" {
  family                   = "wordpress"
  network_mode             = "awsvpc"
  requires_compatibilities = [
	"FARGATE"]
  cpu                      = var.fargate_cpu
  memory                   = var.fargate_memory
  task_role_arn            = aws_iam_role.ecs_task_execution_role.arn
  execution_role_arn       = aws_iam_role.ecs_task_execution_role.arn

  container_definitions = jsonencode([
	{
	  name             = local.name
	  essential        = true
	  image            = "${var.repository_url}:${var.image_tag}"
	  environment      = [
		{
		  name  = "WP_DB_WAIT_TIME"
		  value = "1"
		},
		{
		  name  = "WP_VERSION"
		  value = var.wordpress_version
		},
		{
		  name  = "TZ"
		  value = var.wordpress_timezone
		},
		{
		  name  = "WP_DB_HOST"
		  value = var.wordpress_db_host
		},
		{
		  name  = "WP_DB_NAME"
		  value = var.wordpress_db_name
		},
		{
		  name  = "WP_DB_USER"
		  value = var.wordpress_db_user
		},
		{
		  name  = "MYSQL_ENV_MYSQL_PASSWORD"
		  value = var.wordpress_db_password
		},
		{
		  name  = "WP_DOMAIN"
		  value = var.wordpress_domain
		},
		{
		  name  = "WP_URL"
		  value = var.wordpress_url
		},
		{
		  name  = "WP_LOCALE",
		  value = var.wordpress_locale
		},
		{
		  name  = "WP_SITE_TITLE"
		  value = var.wordpress_site_title
		},
		{
		  name  = "WP_ADMIN_USER"
		  value = var.wordpress_admin_user
		},
		{
		  name  = "WP_ADMIN_PASSWORD"
		  value = random_string.wordpress_admin_password.result
		},
		{
		  name  = "WP_ADMIN_EMAIL"
		  value = var.wordpress_admin_email
		}
	  ],
	  logConfiguration = {
		logDriver = "awslogs"
		options   = {
		  awslogs-group         = var.cloudwatch_log_group_name
		  awslogs-region        = var.region
		  awslogs-stream-prefix = "ecs"
		}
	  },
	  portMappings     = [
		{
		  hostPort      = var.wordpress_port
		  containerPort = var.wordpress_port
		  protocol      = "TCP"
		}
	  ]
	}
  ])

  tags = merge({
	name = local.name
  }, var.tags)
}

resource "aws_ecs_service" "default" {
  name            = local.name
  cluster         = var.ecs_cluster_id
  task_definition = aws_ecs_task_definition.task.arn
  desired_count   = var.desired_count
  launch_type     = "FARGATE"

  network_configuration {
	security_groups  = [
	  var.sg_id,
	  aws_security_group.ecs_tasks.id]
	subnets          = [
	  var.subnet_id]
	assign_public_ip = true
  }

  tags = merge({
	name = local.name
  }, var.tags)
}
output "wordpress_admin_password" {
  description = "The Wordpress admin password"
  value     = random_string.wordpress_admin_password.result
  sensitive = true
}
terraform {
  required_providers {
	aws = {
	  source  = "hashicorp/aws"
	  version = "~> 3.46.0"
	}

	random = {
	  source  = "hashicorp/random"
	  version = "~> 3.1.0"
	}
  }
}

provider "aws" {
  region = var.region
}

In terraform/environments/dev/aws-ecs-wordpress initialize and apply the layer:

$ terraform init
$ terraform apply

In the AWS web console, you can see the ECS cluster with the Fargate service including the WordPress running task :

The WordPress ECS service with the running task
The WordPress ECS service with the running task

When you click on the ECS service you have more information about the task. Click on the task ID :

The container logs are searchable in the created log group in Cloudwatch:

The Cloudwatch log group of the ECS WordPress service
The Cloudwatch log group of the ECS WordPress service

When you click on the log group you can watch the container's logs:

The container's logs in the Cloudwatch group

Conclusion

Through this series of articles, we have seen how to set up a framework to configure, build and deploy images in ECS :

  • Ansible manages the configuration recipes. The Ansible roles are reusable and testable using Molecule and Docker. It ensures good quality and improves development velocity.
  • Packer reuses the Ansible code to build a docker image locally or push it to the AWS Elastic Registry.
  • Terraform deals with the AWS infrastructure.

Resources

GitHub - guivin/ecs-ansible-packer-terraform-wordpress: A POC framework to create Wordpress docker image with Ansible/Packer and deploy it to AWS ECS using Terraform
A POC framework to create Wordpress docker image with Ansible/Packer and deploy it to AWS ECS using Terraform - GitHub - guivin/ecs-ansible-packer-terraform-wordpress: A POC framework to create Wor...
Infrastructure as CodeCloud

Guillaume Vincent Twitter

DevOps Engineer & AWS Certified Solution Architect. Cloud enthusiast and automation addict