How To Deploy an Application On AWS ECS - The Wordpress Example
Create a WordPress stack with AWS ECS and RDS database using Terraform
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
}
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)
}
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"
}
locals {
name = "${var.tags["environment"]}-${var.tags["project"]}"
}
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.45.0"
}
}
}
provider "aws" {
region = var.region
}
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
}
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-rds
layer 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
}
Create the aws-rds
module 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)
}
The password is generated using Terraform resources to not expose sensitive information in the code.
locals {
name = "${var.tags["environment"]}-${var.tags["project"]}-rds"
}
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 {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.45.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.1.0"
}
}
}
provider "aws" {
region = var.region
}
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"
}
In terraform/environments/dev/aws-rds
initialize and apply the layer:
$ terraform init
$ terraform apply

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 :

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:

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

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
Getbetterdevops Newsletter
Join the newsletter to receive the latest updates in your inbox.