Why This Project?

I wanted to see cloud for real. Not a tutorial, not a sandbox — a real service, used in companies, deployed on real infrastructure with real costs.

The goal: deploy a containerized application on AWS, fully automated, using Terraform for provisioning and Ansible for configuration. All while staying within the AWS Free Tier to avoid surprise bills.

It's a modest project. There are dozens of ways to set up this kind of infra — ALB, HTTPS with ACM, managed RDS, ECS for orchestration. I deliberately ignored all of that. The point wasn't to build a production-ready architecture — it was to understand the fundamentals: declarative programming, provisioning, automated configuration, and how these building blocks fit together.

Two Tools, Two Roles

One thing that took me a while to fully grasp: Terraform and Ansible don't do the same thing.

Terraform creates infrastructure. It declares the desired state — two EC2 instances, a security group, a region — and AWS makes it real. It's declarative programming: you describe the "what", not the "how". If a resource already exists and matches the declaration, Terraform won't recreate it. If it differs, it modifies or replaces it.

Ansible configures what runs on it. Once the machines are created and reachable via SSH, Ansible connects and runs playbooks: install Docker, deploy containers, configure Nginx. It's configuration management. Playbooks are idempotent — you can replay them without breaking what's already in place.

The full flow looks like this:

terraform apply → EC2 created → Ansible inventory generated → SSH ready → Ansible installs Docker → deploys the application

One command, and everything chains together. In practice, the main.tf looks like this:

resource "aws_instance" "wordpress_server" {
  count         = 2
  ami           = data.aws_ami.ubuntu.id
  instance_type = var.instance_type
  key_name      = var.key_name

  vpc_security_group_ids = [aws_security_group.wordpress_sg.id]

  root_block_device {
    volume_size = 8
    volume_type = "gp3"
  }

  tags = {
    Name    = "WordPress-Server-${count.index + 1}"
    Project = var.project_name
  }
}

resource "null_resource" "wait_ssh" {
  count      = 2
  depends_on = [aws_instance.wordpress_server, local_file.ansible_inventory]

  provisioner "local-exec" {
    command = <<-EOT
      until ssh -i ~/.ssh/aws -o ConnectTimeout=2 \
        -o StrictHostKeyChecking=no \
        ubuntu@${aws_instance.wordpress_server[count.index].public_ip} exit; do
        sleep 5
      done
    EOT
  }
}

resource "null_resource" "run_ansible" {
  depends_on = [null_resource.wait_ssh]

  provisioner "local-exec" {
    command = <<-EOT
      cd ${path.module}/../ansible
      ansible-playbook -i inventory/aws.yml playbooks/01-docker-installation.yml
      ansible-playbook -i inventory/aws.yml playbooks/02-deploy-wordpress.yml
    EOT
  }
}

Terraform creates the instances, waits for SSH to be ready, then launches Ansible automatically.

The Architecture

Two EC2 instances (Ubuntu 24.04) in the default VPC, eu-west-3 region. A security group allowing SSH (port 22) and HTTP (port 80).

No HTTPS: that requires either an ALB (Application Load Balancer) with an ACM certificate, or a fixed Elastic IP to point a domain — both are outside the Free Tier. The project stays HTTP only.

On each instance, four Docker containers orchestrated with Docker Compose:

  • Nginx — reverse proxy at the front, listens on port 80 and routes traffic
  • WordPress — the application, behind Nginx
  • MariaDB — the database, isolated in a private Docker network
  • PHPMyAdmin — DB interface, accessible via /phpmyadmin/ through Nginx
Internet → Nginx (:80)
             ├─ /           → WordPress → MariaDB (private network)
             └─ /phpmyadmin → PHPMyAdmin → MariaDB (private network)

Nginx acts as a reverse proxy: it receives all HTTP requests and redirects them to the right container based on the path. MariaDB is never directly exposed — it lives in an internal Docker network, accessible only by WordPress and PHPMyAdmin.

The two instances are independent. No data synchronization between them. That's an accepted limitation of the Free Tier scope.

IAM: The First Real Lesson

At first, I did everything with the AWS root account. Terraform, the console, everything. It worked, but it's exactly what you shouldn't do.

The root account has full power over the entire AWS account. Using it for day-to-day operations is like permanently working in sudo on a Linux server — it works until a bad command causes real damage.

The problem showed up concretely: caller identity errors, poorly defined permissions, IAM groups and users that didn't exist or didn't have the right policies attached.

The fix:

  • Create a dedicated IAM user (terraform-user) with CLI-only access and only the necessary permissions (EC2, VPC, Security Groups)
  • A separate user for the GUI console and manual operations or CLI when needed
  • Assign each user to a group with a clear policy
  • Never touch the root account for routine operations
  • Use a separate AWS CLI profile

IAM is critical. It's the very first thing to set up properly on an AWS account, before you even create a single resource. Everything else comes after.

The Struggles

Credentials. Correctly configuring the Terraform provider with a dedicated AWS CLI profile instead of root wasn't immediate. Understanding the authentication chain — CLI profile, access key, secret key, caller identity — took several attempts. It's an area where I'm only scratching the surface; I need more practice to truly master it.

Healthchecks and startup order. Terraform creates EC2 instances, but they're not immediately ready to accept SSH connections. If Ansible starts too early, everything fails. I had to add a wait mechanism: a loop that tests the SSH connection with a timeout and retry interval before triggering the playbooks.

Same issue on the container side. WordPress depends on MariaDB, Nginx depends on WordPress. If a service starts before its dependency is ready, it crashes or gets stuck. Docker Compose handles depends_on, but that doesn't guarantee the service is actually operational — just that the container has started. I had to add HTTP checks in Ansible to make sure WordPress was really responding before moving to the next step:

- name: Pause play until a URL is reachable from this host
  ansible.builtin.uri:
    url: "http://{{ ansible_host }}:{{ nginx_port }}"
    status_code: [200, 302]
    method: GET
  register: _result
  until: _result.status in [200, 302]
  retries: 30
  delay: 5

30 retries, 5 seconds apart. If WordPress doesn't respond within two and a half minutes, the playbook fails cleanly instead of blindly continuing.

Networking. The part I understand the least. I configured Docker networks (a public one for Nginx/WordPress, a private one for MariaDB), the AWS security group, and Nginx routing. It works. But I know there are gaps in my understanding of AWS network layers (subnets, route tables, NAT gateways) that I didn't need to touch here thanks to the default VPC. That's clearly an area to dig deeper into next.

Watch the Costs

Something to plan for from day one: AWS is real. Every terraform apply creates resources that cost money. The Free Tier offers 750 hours/month of t2.micro, 30 GB of EBS — but if you exceed that, or forget to destroy your resources, the bill comes.

A few habits I picked up:

  • Always terraform destroy when I'm not working on the project
  • Set up a budget cap and billing alarm from day one
  • Regularly check the AWS billing console
  • Use t2.micro and not t3.micro to stay within the Free Tier (a mistake I still have in my current config)
  • Never leave instances running "just to see"

What I Learned, What's Left to Explore

  • Terraform and Ansible are complementary, not interchangeable. Terraform manages the lifecycle of cloud resources. Ansible manages what runs on those resources. Mixing the two roles creates confusion.
  • Idempotency is practical. Being able to re-run terraform apply or an Ansible playbook without breaking things is what makes automation reliable.
  • Chaining Terraform → Ansible in one command is possible. null_resource and local-exec let you trigger playbooks right after provisioning, with no manual step in between.
  • The Free Tier is a good learning framework. It forces you to make choices, to understand what you're using and why.

This project helped me build the foundations of IaC and understand how Terraform and Ansible work together on a real use case. AWS networking — subnets, route tables, NAT, peering — still needs to be explored further; the default VPC hides a lot. Same goes for Kubernetes, which is the logical next step after Docker Compose for orchestration. There's still a lot to explore, and that's what makes this field interesting.