Tutorial: Create a Three-Tier WordPress Application in AWS with Terraform — Part Three

Dan Phillips
Version 1
Published in
9 min readNov 3, 2023

Welcome to part three of this tutorial on launching a WordPress application in AWS. So far, we have created our VPC and associated public and private subnets, Internet Gateways, route tables and Nat Gateways (part one). We have also created all our Security Group rules, specified an AWS Relational Database Service (RDS) placed it within a private subnet across two Availability Zones, and introduced AWS Secrets Manager for generating and storing sensitive values (part two).

Photo by Sigmund on Unsplash

In this section, we are going to create our launch configuration resource which will define the blueprint for our WordPress instances and will be used by AWS to horizontally scale our application based on demand and/or health metrics, which we can define.

To begin, cd into the application directory of your project folder. The folder structure should currently look like this:

wordpress_demo
network
application
backend.tf
data.tf
provider.tf
rds.tf
secrets.tf
security_groups.tf

Before we begin building our launch configuration, we need to add an “aws_ami” data resource to our data.tf file:

# data.tf

data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "wordpress-tutorial-state-store"
key = "network/terraform.tfstate"
region = "eu-west-1"
}
}

data "aws_ami" "amazon-linux-2" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm*"]
}
}

Here, we are getting the most up-to-date version of an Amazon Linux 2 Amazon Machine Image (AMI) from AWS, to use in our launch configuration. This ensures that whenever a new EC2 instance is created by the Auto Scaling Group (ASG) we will build, we have the most up-to-date EC2 instance version which is supported.

Now, we’re ready to create the resource that AWS will use to spin up our WordPress applications on demand, the “aws_launch_configuration” resource. This acts as a blueprint for new instances added to our ASG, ensuring that each instance is created with the specified configuration, enabling automatic scaling of compute resources to meet demand while maintaining consistency and reliability in an AWS environment.

It specifies the AMI, instance type, security groups, block device mappings, and other parameters necessary for creating EC2 instances:

touch ec2.tf
# ec2.tf

resource "aws_launch_configuration" "wordpress_ec2" {
depends_on = [aws_db_instance.default]
name_prefix = "wordpress_ec2config"
image_id = data.aws_ami.amazon-linux-2.id
instance_type = "t2.micro"
security_groups = [
aws_security_group.EC2_SG.id
]

user_data = <<-EOF
#!/bin/bash
yum install -y httpd
service httpd start
wget https://wordpress.org/latest.tar.gz
tar -xzf latest.tar.gz
echo "
<?php
# define( 'DB_NAME', 'wordpress_db' );
define( 'DB_NAME', '${aws_db_instance.default.db_name}' );
define( 'DB_USER', 'admin' );
define( 'DB_PASSWORD', '${aws_secretsmanager_secret_version.db_pass.secret_string}' );
define( 'DB_HOST', '${aws_db_instance.default.address}' );
define( 'DB_CHARSET', 'utf8' );
define( 'DB_COLLATE', '' );

define('AUTH_KEY', '${aws_secretsmanager_secret_version.auth_key.secret_string}');
define('SECURE_AUTH_KEY', '${aws_secretsmanager_secret_version.secure_auth_key.secret_string}');
define('LOGGED_IN_KEY', '${aws_secretsmanager_secret_version.logged_in_key.secret_string}');
define('NONCE_KEY', '${aws_secretsmanager_secret_version.nonce_key.secret_string}');
define('AUTH_SALT', '${aws_secretsmanager_secret_version.auth_salt.secret_string}');
define('SECURE_AUTH_SALT', '${aws_secretsmanager_secret_version.secure_auth_salt.secret_string}');
define('LOGGED_IN_SALT', '${aws_secretsmanager_secret_version.logged_in_salt.secret_string}');
define('NONCE_SALT', '${aws_secretsmanager_secret_version.nonce_salt.secret_string}');

\$table_prefix = 'wp_';
define( 'WP_DEBUG', false );

if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}

require_once ABSPATH . 'wp-settings.php';

" > wordpress/wp-config.php

amazon-linux-extras install -y mariadb10.5 php8.2
cp -r wordpress/* /var/www/html/
service httpd restart
EOF
}

As with our other resources, we’ll break down what’s happening here, step by step.

resource "aws_launch_configuration" "wordpress_ec2" {
depends_on = [aws_db_instance.default]
name_prefix = "wordpress_ec2config"
image_id = data.aws_ami.amazon-linux-2.id
instance_type = "t2.micro"
security_groups = [
aws_security_group.EC2_SG.id
]
  • depends_on: here we are telling Terraform that the creation of an EC2 depends on our RDS resource being created first.
  • name_prefix: is a common practice in Terraform to help organize and identify resources within our infrastructure. It makes it easier to manage and understand our AWS resources, especially when we have multiple instances of similar resources with different configurations or environments.
  • image_id: we instruct Terraform to use the ID of the AMI that is obtained from the aws_ami data source we created above. This dynamic approach allows us to reference the latest available Amazon Linux 2 AMI ID without hardcoding it, making our infrastructure more flexible and maintainable.
  • instance_type: in the case of our tutorial, we use the “t2.micro” type as this is in the free tier of our AWS account for the first 12 months. Different types of applications will require differing levels of instance types, however, “t2.micro” is sufficient for our WordPress install.
  • security_groups: this should be somewhat familiar by now, as we are simply attaching the security group we created in part two of this tutorial to any instance that is created by the launch configuration, ensuring that each instance can accept traffic from our Application Load Balancer (ALB), can send traffic to our AWS Relational Database Service (RDS), and can download updates and patches from the internet via our Nat Gateway.

Things begin to get more interesting in the next section, our user_data.

When we create an EC2 instance using a Launch Configuration resource, the user_data attribute allows us to provide a script or set of instructions that will be executed on the instance during its initial boot. This script can perform tasks such as installing software, configuring settings, and preparing the instance for its intended purpose.

User Data is always run as the ‘root’ user on an EC2 instance, so some caution should be exercised when specifying your scripts.

#!/bin/bash
yum install -y httpd
service httpd start

To begin, the #!/bin/bash line at the start of a user data script, known as a "shebang" or "hashbang" is a special directive used in Unix-like operating systems, including Linux, and it specifies that the Bash shell (/bin/bash) should be used to interpret and execute the commands in the script.

The next two lines install the Apache web server and start it. To run WordPress, we need to run a web server on our EC2 instance. Apache Web Server is open source, free and is the most popular web server used with WordPress.

wget https://wordpress.org/latest.tar.gz
tar -xzf latest.tar.gz

The wget https://wordpress.org/latest.tar.gz command uses the wget utility to download the latest version of the WordPress content management system (CMS) in the form of a compressed archive (.tar.gz) from the official WordPress website (https://wordpress.org). After executing this command, we will have the WordPress archive file named latest.tar.gz in our current directory.

Next, the tar -xzf latest.tar.gz command uses the tar utility to extract the contents of the archive. Here's what each option does:

  • x tells tar to extract files from the archive.
  • z indicates that the archive is compressed with gzip.
  • f specifies the archive file to operate on.

So, when our instance runs this command, it will extract all the files and directories from latest.tar.gz, effectively installing WordPress on our system. A

After running this command, we will find a directory structure containing WordPress files and folders in our current directory, allowing us to set up and configure WordPress, which we do next by creating a wp-config.php file using the echo command and outputting it to the WordPress directory we just extracted.

The wp-congif.php file is a critical component of a WordPress installation and is responsible for configuring database connections, enhancing security, defining site-specific settings, and customizing the behaviour of our WordPress site. It serves as the bridge between our WordPress application and its underlying database and is essential for the proper functioning of our website.

echo "
<?php
define( 'DB_NAME', '${aws_db_instance.default.db_name}' );
define( 'DB_USER', 'admin' );
define( 'DB_PASSWORD', '${aws_secretsmanager_secret_version.db_pass.secret_string}' );
define( 'DB_HOST', '${aws_db_instance.default.address}' );
define( 'DB_CHARSET', 'utf8' );
define( 'DB_COLLATE', '' );

define('AUTH_KEY', '${aws_secretsmanager_secret_version.auth_key.secret_string}');
define('SECURE_AUTH_KEY', '${aws_secretsmanager_secret_version.secure_auth_key.secret_string}');
define('LOGGED_IN_KEY', '${aws_secretsmanager_secret_version.logged_in_key.secret_string}');
define('NONCE_KEY', '${aws_secretsmanager_secret_version.nonce_key.secret_string}');
define('AUTH_SALT', '${aws_secretsmanager_secret_version.auth_salt.secret_string}');
define('SECURE_AUTH_SALT', '${aws_secretsmanager_secret_version.secure_auth_salt.secret_string}');
define('LOGGED_IN_SALT', '${aws_secretsmanager_secret_version.logged_in_salt.secret_string}');
define('NONCE_SALT', '${aws_secretsmanager_secret_version.nonce_salt.secret_string}');

\$table_prefix = 'wp_';
define( 'WP_DEBUG', false );

if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}

require_once ABSPATH . 'wp-settings.php';

" > wordpress/wp-config.php

Many of the configuration options are standard, however, we need to pass in the name (DB_NAME) and address (DB_HOST) of our database, dynamically attained from the RDS resource we have already created. We also pass in the password (DB_PASSWORD) which we previously created using Secrets Manager.

We also need to pass in unique authentication keys and salts (also referred to as security keys or secret keys) which are cryptographic strings used to enhance the security of user authentication and stored data. They serve several important security purposes:

  • Password Hashing: One of the primary uses of authentication keys and salts is to strengthen the hashing of user passwords. When a user registers or logs in, their password is hashed (converted into a fixed-length, irreversible string) before it’s stored in the database. Authentication keys and salts are used in the hashing process to make it more secure. These keys add randomness and complexity to the hashing algorithm, making it much harder for attackers to crack hashed passwords.
  • Session Cookies: Authentication keys and salts are also used to generate secure session cookies. When a user logs in, WordPress creates a session cookie that stores information about the user’s authentication status. The presence of these keys and salts ensures that the cookies are cryptographically signed and secure, making it difficult for attackers to forge or manipulate them.
  • Protection Against Attacks: By using unique authentication keys and salts, WordPress helps protect our site against various types of attacks, including brute force attacks, where an attacker repeatedly attempts to guess user passwords. The presence of these keys adds an additional layer of complexity to password hashing and cookie generation, making it much harder for attackers to compromise user accounts.
  • Data Integrity: Authentication keys and salts also contribute to data integrity. By ensuring that session data and authentication tokens are cryptographically signed, WordPress can verify the integrity of this data. If someone tries to tamper with session data or cookies, WordPress can detect the tampering and reject the request.
  • Security Against Session Hijacking: These keys help prevent session hijacking, a type of attack where an attacker steals a user’s session cookie and impersonates them. With strong authentication keys and salts, it becomes extremely challenging for an attacker to generate valid session cookies.

We will generate our keys and salts using AWS Secrets Manager, the same way we created our secure password. Open the secrets.tf file we have already created, and add the following underneath the password configuration:

# secrets.tf 

/*
...password config
*/

# Secrets for wp-config salts
resource "aws_secretsmanager_secret" "auth_key" {
name_prefix = "auth_key"
}

resource "aws_secretsmanager_secret_version" "auth_key" {
secret_id = aws_secretsmanager_secret.auth_key.id
secret_string = data.aws_secretsmanager_random_password.auth_key.random_password
}

data "aws_secretsmanager_random_password" "auth_key" {
password_length = 55
exclude_punctuation = true
}

# secure auth key
resource "aws_secretsmanager_secret" "secure_auth_key" {
name_prefix = "secure_auth_key"
}

resource "aws_secretsmanager_secret_version" "secure_auth_key" {
secret_id = aws_secretsmanager_secret.secure_auth_key.id
secret_string = data.aws_secretsmanager_random_password.secure_auth_key.random_password
}

data "aws_secretsmanager_random_password" "secure_auth_key" {
password_length = 55
exclude_punctuation = true
}

# logged in key
resource "aws_secretsmanager_secret" "logged_in_key" {
name_prefix = "logged_in_key"
}

resource "aws_secretsmanager_secret_version" "logged_in_key" {
secret_id = aws_secretsmanager_secret.logged_in_key.id
secret_string = data.aws_secretsmanager_random_password.logged_in_key.random_password
}

data "aws_secretsmanager_random_password" "logged_in_key" {
password_length = 55
exclude_punctuation = true
}

# nonce key
resource "aws_secretsmanager_secret" "nonce_key" {
name_prefix = "nonce_key"
}

resource "aws_secretsmanager_secret_version" "nonce_key" {
secret_id = aws_secretsmanager_secret.nonce_key.id
secret_string = data.aws_secretsmanager_random_password.nonce_key.random_password
}

data "aws_secretsmanager_random_password" "nonce_key" {
password_length = 55
exclude_punctuation = true
}

# auth salt
resource "aws_secretsmanager_secret" "auth_salt" {
name_prefix = "auth_salt"
}

resource "aws_secretsmanager_secret_version" "auth_salt" {
secret_id = aws_secretsmanager_secret.auth_salt.id
secret_string = data.aws_secretsmanager_random_password.auth_salt.random_password
}

data "aws_secretsmanager_random_password" "auth_salt" {
password_length = 55
exclude_punctuation = true
}

# secure_auth salt
resource "aws_secretsmanager_secret" "secure_auth_salt" {
name_prefix = "secure_auth_salt"
}

resource "aws_secretsmanager_secret_version" "secure_auth_salt" {
secret_id = aws_secretsmanager_secret.secure_auth_salt.id
secret_string = data.aws_secretsmanager_random_password.secure_auth_salt.random_password
}

data "aws_secretsmanager_random_password" "secure_auth_salt" {
password_length = 55
exclude_punctuation = true
}

# logged_in_salt
resource "aws_secretsmanager_secret" "logged_in_salt" {
name_prefix = "logged_in_salt"
}

resource "aws_secretsmanager_secret_version" "logged_in_salt" {
secret_id = aws_secretsmanager_secret.logged_in_salt.id
secret_string = data.aws_secretsmanager_random_password.logged_in_salt.random_password
}

data "aws_secretsmanager_random_password" "logged_in_salt" {
password_length = 55
exclude_punctuation = true
}

# nonce_salt
resource "aws_secretsmanager_secret" "nonce_salt" {
name_prefix = "nonce_salt"
}

resource "aws_secretsmanager_secret_version" "nonce_salt" {
secret_id = aws_secretsmanager_secret.nonce_salt.id
secret_string = data.aws_secretsmanager_random_password.nonce_salt.random_password
}

data "aws_secretsmanager_random_password" "nonce_salt" {
password_length = 55
exclude_punctuation = true
}

While this looks like a lot of code, we are simply repeating the same three steps we used to create a unique and secure password, but for each salt and authentication key. We can then call these dynamically from our user data script.

define('AUTH_KEY',         '${aws_secretsmanager_secret_version.auth_key.secret_string}');
define('SECURE_AUTH_KEY', '${aws_secretsmanager_secret_version.secure_auth_key.secret_string}');
define('LOGGED_IN_KEY', '${aws_secretsmanager_secret_version.logged_in_key.secret_string}');
define('NONCE_KEY', '${aws_secretsmanager_secret_version.nonce_key.secret_string}');
define('AUTH_SALT', '${aws_secretsmanager_secret_version.auth_salt.secret_string}');
define('SECURE_AUTH_SALT', '${aws_secretsmanager_secret_version.secure_auth_salt.secret_string}');
define('LOGGED_IN_SALT', '${aws_secretsmanager_secret_version.logged_in_salt.secret_string}');
define('NONCE_SALT', '${aws_secretsmanager_secret_version.nonce_salt.secret_string}');

The final section of the wp-config.php file requires no unique values of input for us:

\$table_prefix = 'wp_';
define( 'WP_DEBUG', false );

if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}

require_once ABSPATH . 'wp-settings.php';

At the end of our echo command, we save its contents to a wp-config.php file in the WordPress directory:

> wordpress/wp-config.php

We complete our user data script with the following commands, which respectively install the drivers and languages which are required by WordPress, copy the newly configured WordPress directory to "/var/www/html/", which is Apache’s document root directory, thereby making the website accessible through a web browser, and finally, we restart our Apache server:

  amazon-linux-extras install -y mariadb10.5 php8.2
cp -r wordpress/* /var/www/html/
service httpd restart
EOF

That’s it for this section. While we have only created one new file at this stage — our launch configuration, it is a crucial component which will be used by the ASG we will create in our presentation configuration in the final part of this tutorial.

The repository of our code base at this stage is available on this branch at GitHub. Feel free to connect with me on LinkedIn and GitHub.

About the author

Dan Phillips is an Associate AWS DevOps Engineer here at Version 1.

--

--

Dan Phillips
Version 1

I'm a DevOps and software engineer based in Newcastle-Upon-Tyne, UK.