Automated deployment of Wordpress related services using Terraform and Ansible
All commands are compatible with Ubuntu
# Ensure local bin (or .local/bin) directory exists
mkdir -p $HOME/bin
# add taskfile
sh -c "$(curl -sSL https://taskfile.dev/install.sh)" -- -d -b $HOME/bin
# add terraform
TERRAFORM_VERSION="1.11.0"
curl -sSL "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip" -o /tmp/terraform.zip
unzip -q /tmp/terraform.zip -d $HOME/bin/
rm /tmp/terraform.zip
# add ansible
python3 -m venv $HOME/.ansible_venv
source $HOME/.ansible_venv/bin/activate
pip install --upgrade pip
pip install ansible-core
ln -sf $HOME/.ansible_venv/bin/ansible $HOME/bin/ansible
ln -sf $HOME/.ansible_venv/bin/ansible-playbook $HOME/bin/ansible-playbook
deactivate
# generate ansible vault password
openssl rand -base64 32 > .vault_pass_cloudone
# add other local analysis and linting dependencies
pip install checkov argcomplete
# add AWS CLI
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
./aws/install -i $HOME/.local/aws-cli -b $HOME/bin
# GET key details for AWS IAM profile
# AWS Management Console > IAM > Users > Create access key
# Configure AWS CLI
aws configure
# fill details from IAM
task deployGo on duckdns.org, choose an available custom subdomain (ex: cloud1).
Encrypt your duckdns token :
ansible-vault encrypt_string \
--vault-id=cloudone \
--name 'global_duckdns_token' <DUCKDNS_TOKEN>and replace the corresponding value in ansible/group_vars/all/all.yml
# create terraform/terraform.tfvars
task tf:tfvarsfill required inputs
# download providers
task tf:init
# check terraform project correctness
task tf:plan
# deploy infrastructure
task tf:apply
# deploy configuration
task ansible:playAccess the app on https://cloud1.duckdns.org (supposing you chose cloud1)
Ansible report should reflect changed steps
# ex: after changing a post content, run the wp-content block
task ansible:tag TAG=wp_content# get public IP (or check in ansible inventory)
LB_IP=$(terraform output -raw angie_ip)
# scan network for mariadb port
nc -zv -w 3 $LB_IP 3306
# check if mariadb can execute with this IP (check credentials from ansible variables)
docker run --rm -it namichel/mariadb:12.3.2-amd64 mariadb \
-h $LB_IP \
-u wp_user \
-p
# get public IP (or check in ansible inventory)
LB_IP=$(terraform output -raw angie_ip)
# connect to LB instance
ssh ubuntu@$LB_IP
# get private backend IP (or check ansible inventory)
BK_IP=$(terraform output -json instances_ips | jq -r '. | to_entries | map(select(.key != "'$(terraform json | jq -r '.lb_instance_name' 2>/dev/null || echo "node-1")'")) | .[0].value.private')
# scan network
nc -zv $BK_IP 3306# connect to the instance
ssh -i ~/.ssh/id_ed25519 -o "ProxyJump=ubuntu@<LB IP>" ubuntu@<instance IP>
# check that website is accessible -> should be 200
curl -I https://<subdomain>.duckdns.org
# restart remote host machine
sudo reboot
# wait for 45 sec
# check again that website is accessible -> should be 200
curl -I https://<subdomain>.duckdns.org
- DEB822 (from RFC 822) is the new APT norm to declare package repositories on Debian and Ubuntu. Successor to
.list.- it relies on key: value pairs for suites, components, architecture
- every repo has its gpg key
Task runner and build tool : an alternative to Makefile
- uses YAML
- handles cache more efficiently than Makefile : fingerprinting vs date of last modification
- multiplatform
tfvars.sh to check variables and generate terraform.tfvars
A secure Linux distribution
- used as a base layer for all services
- quickly updated in case of CVE
- compatible with packages compiled for glibc
Declarative APK builder : compiles packages from source code
- each service has its own
melange.yamlbuild config - packages are signed with a shared RSA key
- wolfi pipelines are fetched from
wolfi-dev/osfor test support
Image assembler : assembles packages into a distroless image
- secure : images don't have shell, reducing attack surface
- idempotent : images are identical given the same inputs
- lightweight : single layer OCI image
Apko generates:
- SPDX SBOM with all components, licences, and upstream source commits for each package
- SBOM (software bill of materials) which can be used to audit supply chain
UID and GID are 65532 : conventional ID for non-root
External library of statically compiled Go binaries for distroless images
- provides healthcheck binaries (
healthcheck-http,healthcheck-sql,healthcheck-fcgi) embedded in each image - replaces shell-based healthchecks (
curl,wget,mariadb-admin) which are unavailable in distroless images healthcheck-sqlusesmlockto prevent password from being swapped to disk- passwords are read from files (tmpfs in prod) rather than environment variables
- source: Kazibuya/melange-forge
| Service | Base | Tag |
|---|---|---|
| Angie | Wolfi | namichel/angie:1.11.6-amd64 |
| WordPress | Wolfi | namichel/wordpress:7.0-amd64 |
| MariaDB | Wolfi | namichel/mariadb:12.2.2-amd64 |
| phpMyAdmin | Wolfi | namichel/phpmyadmin:5.2.3-amd64 |
An infrastructure as code tool that defines cloud resources
Key benefits :
- scaling : write one config file, run it for as many instances as needed
- idempotent : only applies steps that change the state
- configuration files can be versioned
Terraform state is the source of truth for the deployment. It maps a declarative configuration (written in HCL) to real provider resources. It can detect configuration drifts when the actual state of the cloud differs from the declared one.
building blocks
terraform {}: define CLI version and external providers (herehashicorp/awsandhashicorp/local).provider: configure provider and instanciate regionresourceblock : define various types of AWS components :aws_instance,aws_vpc, ..datablock : fetch external information
advanced logic
for_each: meta-argument that accept a map of a set of strings to generate multiple resourcesdepends_on: force execution order between resources. Ex : ensure that ansible inventory is only generated once EC2 and EIP are created.
variables
Terraform uses variables stored in terraform.tfvars. Their type is defined in variables.tf. They are accessed with var.myvar syntax within the configuration.
outputs.tf: define which info is displayed after deployment, and can be used by other tools (such as Ansible)
# ternary expresssions
vpc_security_group_ids = each.value == var.lb_instance_name ? [aws_security_group.angie_lb.id] : [aws_security_group.backends.id]
# for loops and list or map comprehensions
[for name, instance in aws_instance.cloudone : "${name} ansible_host=${instance.private_ip}"]builtin functions
toset(): convert a list of promitive values to a setfile(): read and inject a local file content : used to load public ssh keyjoin(): turn a string array into single stringconcat(): turn multiple lists into a single oneelement(): retrieve an intem at specified positionindex(): retrieve the position of specified item valuetrimspace(): clean strings
Automation and configuration management tool
Key benefits
- agentless : contrary to Puppet, no need to deploy an agent. Ansible only requires a ssh connection and root access. It temporarily copies Python modules to run its operations
- idempotency : only applies tasks causing changes
- inventory : script defining target hosts, ip addresses and groups. In this project, we use a dynamic inventory generated using Terraform. It defines two groups : public node
[angie_lb]and private instances `[backends] - playbook :
site.ymlis the top-level orchestration file. Here, we reuse some roles and ensure thatangie_lbis configured before the backend nodes. - roles allow to break down configuration into consistent and reusable components. Ex :
dockerrole installs and launches a docker daemon.
- idempotence : running the same configuration multiple times must result in the same system. i.e., contrary to running a script having
mkdirwhich would create a new directory on every run, the same step in Ansible would only run if it is not present in the target state. - declarative code focuses on what is the desired state. As opposed to imperative code (i.e. a script focusing on how to attain it)
variables are resolved using a strict hierarchy with 22 priority levels (cf details)
| Category | Precedence | Usage |
|---|---|---|
| role defaults | 1 | variables that could be overriden by the user |
| group_vars/all.yml | 5 | global values shared accross all host layers |
--extra-vars |
22 | max priority |
Used for secrets, such as Duck DNS token or MariaDB passwords
Special tasks that are triggered by using notify. Ex : reload Angie configuration
We didn't have a definite idea of the target architecture when starting the project. It served as a sandbox to experiment with different approaches.
The architecture went through different phases:
[Internet] ---> [Elastic IP] ---> [EC2 Instance: Angie + WP + MariaDB]- instances : each computing instance is directly accessible. Security is ensured by rootless containerization, and by splitting docker networks between a web-accessible one, and another. We went on adding an elastic IP for each instance and mapping it to a DuckDNS domain. It seemed a good choice, but didn't allow to scale easily if we wanted to maintain the DuckDNS mapping, as there is a max limit of 5 subdomains per account.
[Internet] ---> [AWS ALB] ---> [Private Subnet: EC2 Instances (WP)]
|---> [AWS KMS / CloudWatch Logs]-
instances with Amazon load balancer : we added an instance of Amazon Load Balancer, which was aimed at being the only public access point, while instances IP would have been private. Yet, atop of being even-more provider-dependent and costly (billed by the hour), it increased the size and complexity of terraform state declaration, as we had to declare subnets, security groups, targets groups. Meanwhile, we had also added new AWS resources (IAM policies, KMS, logging) to patch potential security flaws highlighted by Checkov.
-
instances with custom made load balancer : this implied having a separate instance with Angie as a load balancer. Network security is ensured at OS level by iptable configuration. Contrary to free version of Nginx, Angie handles ACME challenges, which also enabled us to get a LetsEncrypt certificate for the chosen subdomain. On the minus side, availability level is not the same as ALB, and scaling would require configuration modification through Ansible.
Would we have had more time to explore more in-depth cloud architecture, it could have been relevant to have
- fully multitier instances with separate DBs (Amazon RDS)
- shared storage for WordPress uploads (Amazon EFS)
- duplication across availability zones
- isolate PhPMyAdmin on its own subnet and instance
- other services such as caching to improve performance.
- ...
| name | description | terraform | AWS |
|---|---|---|---|
| AMI | Pre-configured Amazon Machine Image providing the base Ubuntu OS template. | data.aws_ami |
AWS AMI Docs |
| EC2 Instance | Amazon Elastic Compute Cloud is a virtual compute server hosting the application, containers, and services. | aws_instance |
AWS EC2 Docs |
| KMS Key | Centralized key managenent | aws_kms_key |
AWS KMS |
| EBS Block Store | Persistent cloud storage volume attached to the instance acting as its root hard drive (gp3). |
root_block_device |
AWS EBS Docs |
| Elastic IP (EIP) | Static, persistent public IPv4 address assigned to ensure a fixed endpoint. | aws_eip |
AWS EIP Docs |
| VPC | Virtual Private Cloud : Isolated virtual private network space providing network boundary control. | aws_vpc |
AWS VPC Docs |
| Subnet | A segmented logical partition inside the VPC network to group resources. | aws_subnet |
AWS Subnet Docs |
| Internet Gateway | VPC component enabling bidirectional communication between the network and the public internet. | aws_internet_gateway |
AWS IGW Docs |
| Route Table | Set of routing rules determining where network traffic from the subnets is directed. | aws_route_table |
AWS Route Table Docs |
| Security Group | Virtual stateful firewall controlling permitted inbound and outbound traffic. | aws_security_group |
AWS SG Docs |
| VPC Flow Logs | Feature that captures IP traffic information flowing to and from network interfaces. | aws_flow_log |
AWS Flow Logs Docs |
| CloudWatch Log Group | Centralized log management and storage service repository for system monitoring data. | aws_cloudwatch_log_group |
AWS CloudWatch Logs Docs |
| IAM Role | Identity with specific permission policies determining what AWS resources can do. | aws_iam_role |
AWS IAM Roles Docs |
All service images are built with apko from Wolfi packages: no shell, no package manager, minimal attack surface. Healthchecks use statically compiled Go binaries from melange-forge instead of shell utilities.
| Service | Official image | CVEs (C/H/M/L) | Custom image | CVEs (C/H/M/L) | Packages |
|---|---|---|---|---|---|
| Angie | docker.angie.software/angie:1.11.6 |
256 (32/111/109/4) | namichel/angie:1.11.6-amd64 |
0 | 366 → 15 |
| WordPress | wordpress:6.9.4 |
923 (27/150/268/9) | namichel/wordpress:6.9.4-amd64 |
0 | 273 → 36 |
| MariaDB | mariadb:12.2.2 |
248 (3/37/171/34) | namichel/mariadb:12.2.2-amd64 |
1* | 154 → 41 |
| phpMyAdmin | phpmyadmin:5.2-apache |
748 (23/131/218/11) | namichel/phpmyadmin:5.2.3-amd64 |
0 | 284 → 118 |
*
CVE-2026-8376inperl(Critical): no fix available upstream, low EPSS (< 0.1%). Perl is a transitive dependency of MariaDB and cannot be removed.
Each image build produces a signed SPDX SBOM via apko, tracking every installed package with its upstream source commit. SBOMs are committed alongside apko.yaml files under melange/.
A GitHub Actions workflow runs on every push to main:
- syft catalogs all packages including Composer/Go dependencies not visible to apko
- grype scans the syft inventory against its CVE database
- issue-reporter (from melange-forge) formats findings as structured GitHub Issues with severity labels (
severity:critical,severity:high, etc.)
Two entries are intentionally ignored in .grype.yaml :
phpmyadmin npm package: grype matches a known malicious npm package named phpmyadmin against our PHP application. These are unrelated — one is a malicious npm package, the other is the legitimate PHP web interface. Ignored by package name + type.
GO-2026-5024 in gosu: this CVE affects NewNTUnicodeString, a Windows NT API. gosu runs exclusively on Linux where this code path is unreachable. Ignored by vulnerability ID + binary location (/usr/bin/gosu).
In local development, passwords are stored in compose/secrets/ (gitignored) and mounted read-only into containers. In production (via Ansible), secrets are injected into a tmpfs mount, never written to disk. Both docker-entrypoint.sh scripts support _FILE environment variables natively for this purpose.
Static analysis for Infra As Code
- compares code against security policies
Reverse proxy. Fork of nginx with extended features
- serves WordPress via FastCGI pass to php-fpm
- serves phpMyAdmin at
/phpmyadmin/ - exposes status API at
/status/ - exposes Prometheus metrics at
/metrics/(in local deploy)
An open source CMS
NB : There is no official Ansible module for Wordpress management. Partly because cli evolves too fast and should be compatible with many php versions.
An open source fork of MySQL
An administration tool for DB
A DNS provider
- we should reestabish mapping every time the infrastructure is redeployed (AWS generates a new public Elastic IP)
| Url | Kind | Notes |
|---|---|---|
| Terraform doc | 📔 | |
| Ansible doc | 📔 | |
| Taskfile doc | 📔 | |
| Chainguard doc | 📔 | for Melange and Apko |
| melange-forge | 🌐 | Go binaries for distroless images |
| Wolfi OS | 🌐 | Package repository |
| Syft | 🌐 | SBOM and package cataloging |
| Grype | 🌐 | CVE scanner |
| Automating IT with Ansible | 📘 | |
| Infra as Code using Terraform | 📘 | |
| Stephane Robert | 📘 | Excellent tutorials |
| Installing WP with Ansible | 📘 | Tutorial using MySQL setup, PHP-FPM, Nginx, wp-cli. |
Resource type
- 📔 official doc
- 📘 course
- 🗒️ cheatsheet, synthesis
- 🌐 web, article
- 📽️ video
| Goal | Tool |
|---|---|
| Senior guidance : we submit an approach, the AI suggests alternative implementations with their tradeoffs. | LLM Chatbot (Gemini, Claude) |
| Documentation / Crash course : we ask for a recap over a tools or part of its features. | LLM Chatbot (Gemini, Claude) |
Boilerplate code : we asked to generate parts of the code, that were not in the immediate scope of the project and/or once we reckoned we would have been able to do it by ourselves : useed for tfvars.sh script |
LLM Chatbot (Gemini, Claude) |
| Debugging help : sometimes direct questions (why does this occur + console log as a context). Many times. But as we got a better mastery of the concepts and tools, we tried to prompt the AI to provide methods and heuristics instead | LLM Chatbot / CLI (Gemini) |
| PR Review : early check of potential bugs | Github Copilot |
We didn't take time to provide a recurrent context for this project, although the quality and rapidity of AI inputs could have largely benefitted from it.
We used a browser extension (PiiBlocker) to prevent leaking personal information.
The subject provided by 42 holds within 1 page. The goal is simple : at first glance, we merely have to automate the deployment of the Inception project, which is part of common core. Yet it is easy to turn it into something bigger than expected:
Rather than pulling standard and potentially vulnerable official Docker image, we chose, driven by namichel appetence for those tools, to dive into supply-chain security. We included a series of tools from Chainguard / Wolfi OS ecosystem to generate ad hoc images for the project. This also implied rewriting healthchecks (as we could not rely any more on bundled shells), defining new packages, ...
The 22 priority levels, combined with the necessity to override some of the variables for molecule tests and some design good practices (prefixing variables with the role name, a good way to ensure roles are independant), led to a certain level of complexity, which has not been entirely flattened yet.
Maintaining pure idempotence and relying on ansible modules (although there are more than 3000 of them) proved difficult, especially for wordpress configuration tasks. As no official wordpress CLI module is available, we could only run ansible.builtin.shell tasks, manually determining the change condition
We went on with many adjustments with the cloud architecture (cf. supra). We realized that operating a real cloud environment meant finding best tradeoffs between security best practices (mostly highlighted by Checkov), operational costs and performance.
Not exhaustive..
- playbook modularization
- variable naming and inheritance consistency
- modern ansible modules : identify tasks that could make use of idiomatic modules like
ansible.builtin.slurp
- integrate Prometheus / Grafana in the deployed configuration. Drawback : might require running bigger instances.
- advanced IaC testing : use true integration testing frameworks such as Terratest, which could spare the project of the complexity introduced by attempting to check docker deployments within molecule docker instances.
