DEV Community

Cover image for Deploying a Fully Functional Multi-AZ WordPress App on AWS ECS + RDS with Terraform & Spacelift.
Akingbade Omosebi
Akingbade Omosebi

Posted on

Deploying a Fully Functional Multi-AZ WordPress App on AWS ECS + RDS with Terraform & Spacelift.

Hey everyone! I’m Akingbade Omosebi, and I like turning ideas into real, production-grade level infrastructure.

This post breaks down exactly how I deployed a WordPress app on AWS ECS, using RDS for storage, an ALB, a multi-AZ VPC, and full CI/CD via Spacelift.

It’s practical, minimal fluff, and everything here was built, tested, and verified, you’ll see my real console screenshots to prove it.

What you’ll see here

  • How I split my VPC into Public & Private Subnets across multiple AZs.
  • How ECS, ALB, and RDS fit together.
  • Why security groups matter, and how I designed them.
  • How the Terraform files are split, no monolith .tf mess.
  • How I ran it first locally, then automated it on Spacelift with secrets.
  • Architecture diagram + real deployment screenshots.

What’s my goal?

A WordPress app that:

  • Runs in multiple Availability Zones.
  • Gets traffic through an Application Load Balancer.
  • Stores all posts/users in a MySQL RDS database in Private Subnets.
  • Fully version-controlled and deployed through Spacelift.

Why multi-AZ?
If one AZ goes down, ECS and RDS keep the site alive

Overall Architecture.

Here's my high level arhitecctural diagram which i created on draw.io

Key parts:

  • Public Subnets hold the ALB + ECS Tasks.
  • Private Subnets hold the RDS DB.
  • IGW lets Public Subnets connect out.
  • Security Groups lock down who talks to who.

VPC, Subnets & Internet Gateway

  • VPC: 10.0.0.0/16

  • Public SUbnets:

    • 10.0.1.0/24 in eu-central-1a
    • 10.0.2.0/24- in -eu-central-1b
  • Private Subnets:

    • 10.0.3.0/24 in eu-central-1a
    • 10.0.4.0/24 in eu-central-1b

*Why seperate? *
Public Subnets have IGW for inbound HTTP. Private Subnets stay internals, RDS has no direct Internet pathwayy.

Here is my terraform resource block code for my VPC, i liken it to my whole network block or network playground:

# -------- VPC --------
resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16" # This means: our entire VPC has a lot of IPs, approx 65k or slightly moore
  tags = {
    Name = "${var.project_name}-vpc"
  }
}

Enter fullscreen mode Exit fullscreen mode

Here is my resource block code for one of my ssubnets, which are like little fenced yards within my vpc:

# -------- Public Subnets --------

# Subnet 2 in eu-central-1b
resource "aws_subnet" "public_2" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.2.0/24" # Next block, 256 IPs
  availability_zone       = "eu-central-1b"
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.project_name}-public-2"
  }
}

Enter fullscreen mode Exit fullscreen mode

Here is for my Internet Gateway, that lets traffic in and out.

# -------- Internet Gateway --------
resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.main.id
  tags = {
    Name = "${var.project_name}-igw"
  }
}

Enter fullscreen mode Exit fullscreen mode

To view the rest of the VPC, check out my GitHub Repo Link WordPress GitHub Repo

Security Groups

  • ALB SG: Inbound 80 from anywhere.
  • ECS SG: Inbound only from ALB SG.
  • RDS SG: Inbound on 3306 only from ECS SG.

This means the following, just as i illustrated in the diagram:

  • Traffic comes in via ALB.
  • ALB talks to only ECS.
  • Only ECS talks to RDS!!.
  • Nothing else has direct DB access, especially as DB ought to be private always.

ECS Fargate Cluster & Service

One Cluster, multi-AZ.

One Service, desired count = 2 Tasks. (i wanto two tasks like replicas in k8s to be up)

Task Definition: is typically used within the ECS to define or run the official WordPress image, and is mapped to port 80 from its image (wrodpress image). You can get your images from ECR public imagess or from Dockerhub images!.

Cluster

Clustre Dashboard

Task Definition

Task Definition Dashboard

Here is my Task Definition code blocks from my ECS:

# -------- ECS Task Definition --------
# This is the 'recipe' for your WordPress container
resource "aws_ecs_task_definition" "wordpress" {
  family                   = "${var.project_name}-task"
  network_mode             = "awsvpc"    # Needed for Fargate
  requires_compatibilities = ["FARGATE"] # We use Fargate, no EC2 to manage
  cpu                      = 512         # 0.5 vCPU
  memory                   = 1024        # 1 GB
  execution_role_arn       = aws_iam_role.ecs_task_execution_role.arn

  container_definitions = jsonencode([
    {
      name      = "wordpress"
      image     = "wordpress:latest" # Official WordPress image from Docker Hub, you can also put ECR public WordPress image here
      essential = true

      portMappings = [
        {
          containerPort = 80
          hostPort      = 80
        }
      ]

      environment = [
        {
          name  = "WORDPRESS_DB_HOST"
          value = aws_db_instance.wordpress.address
        },
        {
          name  = "WORDPRESS_DB_USER"
          value = var.db_username
        },
        {
          name  = "WORDPRESS_DB_PASSWORD"
          value = var.db_password
        },
        {
          name  = "WORDPRESS_DB_NAME"
          value = var.db_name
        }
      ]
    }
  ])
}
Enter fullscreen mode Exit fullscreen mode

And here is my ECS Service code block as well:

# -------- ECS Service --------
# Keeps tasks alive & hooks them to ALB
resource "aws_ecs_service" "wordpress" {
  name            = "${var.project_name}-service"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.wordpress.arn
  launch_type     = "FARGATE"
  desired_count   = 2 # 2 containers for high availability, 1 is for just one only, but for production you should have at least 2 or more

  network_configuration {
    subnets = [
      aws_subnet.public_1.id,
      aws_subnet.public_2.id
    ]
    security_groups  = [aws_security_group.ecs_sg.id]
    assign_public_ip = true # Needed since we’re in public subnets
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.main.arn
    container_name   = "wordpress"
    container_port   = 80
  }

  depends_on = [aws_lb_listener.http] # Make sure ALB listener exists first
}

Enter fullscreen mode Exit fullscreen mode

To view the rest of the VPC, check out my GitHub Repo Link WordPress GitHub Repo

My ALB & Target Group

  • My ALB covers across both Public Subnets.
  • My target group uses ip type (not instance needed for awsvpc mode).
  • Listener forwards HTTP requests to the ECS Tasks.

Mapped out Target group resources, showing its relevant connections are in place and active.

Resource Map

Dashboard shows everything is healthy and good, based upon the success codes, both replicas are in great shape.

Target Group

Health Checks by the ALB, it sends requests every 30 seconds to get responses, its acceptable response code is 200-399 Max, else it assumes it is unhealthy and switches to the next replica which is provisioned.

Here are some code blocks taken from my ALB.tf file

# -------- ALB --------
resource "aws_lb" "main" {
  name               = "${var.project_name}-alb"
  internal           = false # false = internet-facing
  load_balancer_type = "application"

  security_groups = [aws_security_group.alb_sg.id]
  subnets = [
    aws_subnet.public_1.id,
    aws_subnet.public_2.id
  ]

  tags = {
    Name = "${var.project_name}-alb"
  }
}

Enter fullscreen mode Exit fullscreen mode

My Target Group code block:

 # -------- ALB Target Group --------
# This is like the guest list — who can receive traffic from the ALB
resource "aws_lb_target_group" "main" {
  name     = "${var.project_name}-tg"
  port     = 80
  protocol = "HTTP"
  vpc_id   = aws_vpc.main.id
  target_type = "ip"


  health_check {
    path                = "/"
    protocol            = "HTTP"
    matcher             = "200-399"
    interval            = 30
    healthy_threshold   = 2
    unhealthy_threshold = 2
  }

  tags = {
    Name = "${var.project_name}-tg"
  }
}

Enter fullscreen mode Exit fullscreen mode

And from my listener code block:

# -------- ALB Listener --------
# This listens on port 80 and forwards traffic to our target group (ECS tasks)
resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.main.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.main.arn
  }
}


Enter fullscreen mode Exit fullscreen mode

To view the rest of the files, check out my GitHub Repo Link WordPress GitHub Repo

My RDS: The DB Layer

-MySQL DB.
-Multi-AZ standby.
-Lives in Private Subnets only.
-ECS connects using private DNS endpoint.

RDS Dashboard Overview

RDS Dashboard Configuration Tab Overview

Here is some code blocks from my rds.tf file:

# -------- RDS MySQL Instance --------
resource "aws_db_instance" "wordpress" {
  identifier             = "${var.project_name}-db" # Unique name for my db
  allocated_storage    = 20              # 20 GB storage for DB
  storage_type         = "gp2"           # General Purpose SSD
  engine               = "mysql"         # DB engine
  engine_version       = "8.0"           # MySQL version
  instance_class       = "db.t3.micro"   # Smallest cheap instance for demo
  db_name              = var.db_name     # DB name from variables.tf
  username             = var.db_username # Master user
  password             = var.db_password # Master password
  db_subnet_group_name = aws_db_subnet_group.main.name

  # Attach RDS SG to control who can connect (only ECS)
  vpc_security_group_ids = [aws_security_group.rds_sg.id]

  # Don't keep final snapshot when destroying, i'll only do this for dev stages
  skip_final_snapshot = true

  backup_retention_period = 7  # Keep daily backups for 7 days, you can increase or decrease as needed (similar to what you are insturcted on Console)

  # tag for clarity or just incase moments of confusion
  tags = {
    Name = "${var.project_name}-rds"
  }
}
Enter fullscreen mode Exit fullscreen mode

To view the rest of the files, check out my GitHub Repo Link WordPress GitHub Repo

Variables & Secrets

  • DB password is sensitive = true in Terraform.
  • I passed it to Spacelift as an Enviironment Variable.
  • Make sure to never hardcode sensitibve credentials in .tf files.
  • Outputs don’t print sensitive values.
  • Always check: Always test secrets are functional before automating them.

As you can see in my repo link, there's no secret or sensitve data in my Terrafrom code there, but it works well. I'll show you later how i embeded my secrets or sensitive data to Spacelift without pushing it to repo.

Local Runs (Optional, but good practice)

Additionally, I often sometimes rerun my code locally or directly with terraform, especially if i want to test a module, so it doesnt mess up my repo.

Here are some commands i use from terraform

terraform init
terraform fmt
terraform validate
terraform plan
terraform apply /  #(-auto-approve) <---- Optional, use only if you're sure 
terraform destroy / #(-auto-approve) <---- Optional, use only if you're sure 

Enter fullscreen mode Exit fullscreen mode

CI/CD via Spacelift

Why Spacelift?

  • it automates the plan + apply with each Git commits and push successful in my repoo..
  • it stores secrets securely.
  • it keeps infra history in version control.
  • It lets me rollback if needed.
  • It builds secure pipelines for your workflow, and is collaborative

Awaiting my confirmation as instructed

Deployment complete

I use contexts or environment variables within stacks to store secrets.
Spacelift utilizes and maps out the TF_Var as a Terraform variable, and takes in its values.

Context already created and parameters/secrets passed and secured

New stack creation and application of Context

How does the my traffic flow within my work?

Its pretty easy and simple!.

  • Step 1: User hits ALB DNS (could be domain name, if domain is bought and assigned to Route 53 (R53), and is routed by the ALB to a healthy ECS Task.

  • Step 2: Task container runs Apache + PHP based on the configuration of the wordpress image, and talks to RDS over port 3306.

  • Step 3: Additionally, Lets assume, one AZ fails the health check or fails for whatever reasons, my ECS Tasks still serve traffic from the other AZ. DB failover too.

As you can see my ECS Tasks is healthy, live and running.

ECS Task

Both replicas are spun up and running, minimum is set to two, incase one faults or fails, another is there.

2/2

And since there are up and healthy, to access it, we have to use the ALB DNS to access it in this case, for production level, they use route 53 and other DNS services, but for this project, this is fine, further developments will occur on it with time.

ALB DNS

As you can see, my Wordpress site is on and live, waiting for me to setup.

Account creation and all

Login portal

Blog up and running, left to be customized by designer.

WHat did i learn?

I learned a lot, made a couple of mistakes, and ran into some errors which naturally frustrated me at first, such as, I wondered why my RDS was not Multi-AZ, until i read through the documentation on Registry.terraform site again, and made an isolated attempt, which worked, then i compared and adopted the changes between them.

Unplanned personal error

I also encountered a situation where i did not pay attention to a small detail, it went like this. I ran across some accidents such as wrongly configuring my TF_VAR, and blindly re-inputting my database instance name as actual name in my ENV_VAR, especially as they were similar with just a slight "-" hyphen difference, then crashing out 😭😭 and getting more coffee ☕☕ as it wasn't working and making no sense, until I found out my silly mistake from an inspected .json file within my RDS and my Task Definition, which showed my ENV_VAR was not the same.

This made my ECS task to not function and my ALB to detect the replicas as unhealthy, (clearly as it couldn't authenticate with the username).

It was funny but also interesting to see how the smallest and simplest but unexpected errors can throw anyone off, while we naturally focus on the larger complex tasks.😂😂

I had to research a little bit as i worked to make sense of what i was doing.

But these are normal human errors, and I learn to pay more attention and cross check little details. 😎😎

Other things i learned were;

  • Why Task Definitions are just blueprints.
  • Why awsvpc mode needs ip Target Groups.
  • Why Security Groups must stay tight.
  • Why multi-AZ is worth the extra cost.
  • Why real CI/CD beats terraform apply from a laptop.

Final thoughts.

This project is 100% built from scratch, tested, deployed, and running in my own AWS account.

If you’re learning:

  • Break big .tf files into small ones. Embrace modularity!.
  • Draw your diagram or arhitecture, it helps you explain it to anyone, and also reminds you of what you're looking at.
  • Run local first, then automate.
  • Keep your secrets separate always.

Next up!

In the future, i might probably add some more features, such as route 53, Custom domain name, and other features, for future tasks. Butr who knows?
That's why you gotta stay tuned!! 😉.

If you want to see the rest of my actual infra code block or configuration files (terraform files), then check out my WordPress GitHub Repo

I tried my best to keep it simple, calm, clear and direct, trying to figure my tones. If you enjoed this read up, dont forget to drop a comment here.

Connect on LinkedIn | GitHub Repo

Stay curious, keep building!

AWS #Terraform #ECS #RDS #CI/CD #Spacelift

Top comments (0)