For years, our team relied on Ansible to manage our core database fleet—primarily PostgreSQL on RDS and MySQL on Aurora—along with the supporting infrastructure like security groups and IAM roles. While Ansible was excellent for configuration management and ensuring the state *within* the VMs was correct, we found it fundamentally lacking in managing the cloud control plane itself. The declarative model was there in the playbooks, but the execution was imperative. This led to significant configuration drift over time, especially when engineers ran playbooks from different workstations or when manual hotfixes were applied directly in the AWS console during incidents.
The primary pain points were:
* **Non-Idempotency in Practice:** Ansible modules like `ec2_instance` are designed to be idempotent, but they often rely on complex, nested lookups (e.g., filtering by tags). A slight change in tag structure or a transient AWS API issue could cause a module to mis-identify an existing resource and attempt to create a duplicate.
* **No State File:** There was no single source of truth for the *entire* infrastructure's intended state. Ansible only knew the desired end-state defined in YAML, not the state it achieved the last time it ran. Reconciling "what is" versus "what should be" required custom scripts and manual audits.
* **Orchestration Complexity:** Managing dependencies (e.g., subnet must exist before RDS instance) was handled by explicit task ordering and `when:` conditions, which became a brittle, sprawling logic tree.
The breaking point came during an audit of our production Aurora PostgreSQL clusters. We discovered that over 30% had manual modifications not reflected in the Ansible codebase: parameter group associations, security group rules, and even minor instance type changes. The risk of a playbook run inadvertently overwriting these "tribal knowledge" configurations was high.
Our migration to Terraform was methodical. We started with a non-critical but complex environment: a replication-heavy MySQL setup with read replicas across regions. The Terraform state file (`terraform.tfstate`) immediately became the cornerstone. Here is a simplified contrast:
**Ansible (fragment):**
```yaml
- name: Ensure Aurora MySQL Cluster exists
community.aws.rds_cluster:
engine: aurora-mysql
engine_version: "5.7.mysql_aurora.2.07.2"
db_cluster_identifier: "my-app-cluster"
master_username: admin
master_user_password: "{{ vaulted_password }}"
vpc_security_group_ids:
- "sg-123abc"
db_subnet_group_name: "my-db-subnet-group"
tags:
Environment: Production
register: rds_cluster_info
```
**Terraform (HCL fragment):**
```hcl
resource "aws_rds_cluster" "main" {
cluster_identifier = "my-app-cluster"
engine = "aurora-mysql"
engine_version = "5.7.mysql_aurora.2.07.2"
master_username = "admin"
master_password = random_password.master.result
vpc_security_group_ids = [aws_security_group.db.id]
db_subnet_group_name = aws_db_subnet_group.main.name
tags = {
Environment = "Production"
ManagedBy = "Terraform"
}
}
```
The key difference is the explicit, declarative dependency chaining (`aws_security_group.db.id`, `aws_db_subnet_group.main.name`). Terraform builds a resource graph, ensuring proper order. The `terraform plan` command became our single source of truth for *what will change* before any execution, eliminating surprise modifications.
We imported existing resources one by one using `terraform import`. This was the most labor-intensive phase, requiring careful mapping of AWS ARNs to Terraform resource addresses. However, this process *forced* us to document the actual state and codify it. Post-migration, our configuration drift—measured by the delta between `terraform plan` output and actual live resources on a weekly scan—dropped by approximately 80%. The remaining 20% largely consists of legitimate, automated changes made by AWS itself (like minor version auto-updates in defined maintenance windows) and resources we've intentionally kept out of scope (e.g., some CloudWatch alarms managed by a separate team).
The trade-off is a loss of some procedural flexibility. Tasks like database schema management or user privilege rotations are still better handled by Ansible or dedicated migration tools. We've settled on a hybrid approach: Terraform owns the immutable cloud fabric (VPC, subnets, RDS/Aurora instances, IAM roles), while Ansible, invoked via Terraform provisioners or a separate pipeline, manages the mutable configuration *within* those resources. This clear separation of concerns has been worth the initial refactoring pain.
SQL is not dead.
Senior infrastructure lead at a midsize fintech, handling ~200 services across AWS and on-prem. We run Postgres on RDS, a sprawling EKS cluster for the microservices crowd, and a fat monolith on EC2 that does 80% of our actual revenue. I've used Ansible since the pre-1.0 days and Terraform for about five years now. Here's the breakdown.
1. **State Management**: Ansible has none, Terraform locks it in. This is the 80% win. Ansible's idempotency breaks on complex cloud objects where identification isn't trivial (like an AWS security group rule defined by a unique hash). Terraform's state file is a single source of truth, period. The hidden cost is you now have to secure and manage that state file, usually via S3 + DynamoDB, which adds about $15 a month and operational overhead.
2. **Execution Model**: Ansible is imperative, Terraform is declarative with a plan phase. With Ansible, a playbook run is a series of API calls; if you interrupt it, you're left with a half-applied state. Terraform's `plan` shows you the diff, and `apply` is atomic for the whole graph. In practice, this cut our "oops I ran that twice" incidents to near zero. For database fleets, this means no more accidental replica promotion because a tag filter failed.
3. **Cloud Coverage and Pace**: Terraform's provider ecosystem is wider and updates faster. Need to manage an Aurora global database with a specific backtrack window? The AWS provider will have it months before the `community.aws` Ansible collection. The trade-off is complexity: Terraform's resource definitions are more verbose. A simple security group that took 20 lines in Ansible YAML can balloon to 50 lines of HCL, mostly because you're explicitly defining every attribute the API expects.
4. **Configuration Drift and Remediation**: Ansible wins for *correcting* drift inside a resource, Terraform wins for *preventing* drift on the resource itself. If your Postgres parameter group needs a tuning change, Ansible's `postgresql_query` module is simpler. But if someone manually adds an ingress rule in the AWS console, your next Terraform apply will silently remove it, while Ansible might not even see it unless your playbook specifically audits every rule. Our drift dropped because we stopped letting people fix things in the console; Terraform forced the issue.
I'd pick Terraform for managing the cloud control plane (VPC, RDS instances, IAM) every time. If you're still running any on-prem VMs or need to manage the config *inside* those RDS instances (like database users or schema), keep a simple Ansible playbook for that and run it from a CI pipeline. Tell us if your team is already bought into a CI/CD model and how comfortable they are with HCL, that's the real decider.
monoliths are not evil
That's interesting. Your point about the lack of a single source of truth for the entire infrastructure's intended state is the core issue I've seen too. Even with idempotent modules, if two engineers run slightly different playbooks from their laptops, you get drift.
I'm curious about the transition cost. Did you rewrite all your Ansible roles as Terraform modules, or did you keep Ansible for configuration inside the instances and just use Terraform for the cloud plane? I'm managing user provisioning across several SaaS apps and the state drift problem sounds very familiar.
> The declarative model was there in the playbooks, but the execution was imperative.
That's a perfect way to articulate the problem I've been trying to pin down. I've seen this exact pattern in my own work. You can write beautifully declarative Ansible code, but its execution model always feels like you're giving it a list of instructions, not a blueprint. It's checking things off a list, not comparing a current state to a unified desired state.
Your point about the **No State File** is the kicker. We tried to solve drift by storing playbook output logs and using strict tagging conventions, but it was just a band-aid. If someone tweaked a variable file locally and ran a playbook, that 'state' was instantly outdated everywhere else. It felt like we were building a single source of truth out of Post-It notes.
A question for you: given that Ansible excels at config *within* instances, did you adopt a hybrid approach post-migration? Like using Terraform for the RDS instance itself, but a Packer image with Ansible for the initial database config, or maybe Terraform provisioning to handle user setup? I'm curious how you drew that boundary, especially with something stateful like a database.