DEV Community

Tanay Jain
Tanay Jain

Posted on

A Month of Building Real Infrastructure as Code — Terraform, Packer, and Ansible

A Month of Building Real Infrastructure as Code — Terraform, Packer, and Ansible, No Shortcuts

At the start of this month, Infrastructure as Code meant something fairly simple to me:

Write Terraform instead of clicking around the AWS Console.

By the end of the month, that definition had completely changed.

It became less about writing .tf files and more about building infrastructure that I could:

  • provision repeatedly
  • inspect and validate
  • manage without SSH
  • deploy applications onto
  • authenticate to AWS from CI without long-lived credentials
  • replicate across environments
  • import when infrastructure already existed
  • and, most importantly, prove actually worked

This is what I built during August 2026.


Architecture

Architecture diagram of the AWS Terraform platform

At a glance: GitHub Actions authenticates to AWS via OIDC, runs Terraform, which provisions the VPC, ALB, ASG (running a Packer-built AMI with Docker), and RDS. Ansible configures the running instances over SSM — no SSH — pulling database credentials from Secrets Manager at deploy time.


The Starting Point

In June, I had already deployed a real application on AWS. It worked. There was an EC2 instance, an RDS database, and an Application Load Balancer.

But much of the infrastructure had been assembled manually through the AWS Console. That created an uncomfortable problem:

What happens when I want another environment?

A staging environment. A production environment. Another region. A disaster-recovery copy.

The answer at that point was basically: repeat the process and hope I didn't miss a setting.

That works for a learning exercise. It does not scale very well as an engineering approach.

So August became an experiment:

What would happen if I rebuilt the whole thing around Infrastructure as Code and automation?


What I Actually Built

1. Terraform — the infrastructure foundation

I started by turning the AWS architecture into reusable Terraform modules. The core structure became:

modules/
├── vpc/
├── alb/
├── asg/
├── rds/
└── github-oidc/
Enter fullscreen mode Exit fullscreen mode

The infrastructure includes:

  • VPC networking with public and private subnets
  • Internet Gateway
  • NAT Gateway
  • Application Load Balancer, target groups, and listener rules
  • Auto Scaling Group + Launch Template
  • RDS PostgreSQL
  • Security groups
  • IAM roles and policies
  • CloudWatch resources
  • GitHub OIDC federation
  • Supporting S3 infrastructure

Instead of one giant Terraform file, the root configuration composes reusable modules. That was one of the first big changes in how I thought about Terraform:

Terraform isn't just a resource-definition language. It's also a way to structure infrastructure like software.

2. Remote state — because local state doesn't scale

The project uses an S3 backend for Terraform state with DynamoDB-based locking:

Terraform
   │
   ├── S3 → remote state
   │
   └── DynamoDB → state locking
Enter fullscreen mode Exit fullscreen mode

This gave me shared state, locking against concurrent operations, encrypted remote storage, and a backend suitable for CI/CD.

It also gave me firsthand experience with something tutorials often skip: state is infrastructure too. I had to deal with real state-lock failures during the project, including stale CI locks. That experience was frustrating at the time. It was also extremely useful.

3. Packer — immutable EC2 images

The next step was separating image creation from infrastructure provisioning. Instead of relying entirely on first-boot installation, I used Packer to build an AMI with Docker already available:

Packer
   │
   ▼
Custom AMI
   │
   ▼
Launch Template
   │
   ▼
Auto Scaling Group
Enter fullscreen mode Exit fullscreen mode

That gave the compute layer a known image baseline. The biggest conceptual change for me was realizing that an EC2 instance doesn't have to be "configured from scratch" every time it boots — the machine image itself can be part of the engineering artifact.

The Packer-built AMI was then wired into the ASG launch path and used in an instance-refresh workflow.

4. Ansible — but without SSH

This was one of the most important parts of the month. The application instances were designed to live in private networking rather than being directly administered through public SSH. Ansible uses AWS Systems Manager Session Manager instead:

Ansible
   │
   ▼
Dynamic AWS inventory
   │
   ▼
AWS Systems Manager
   │
   ▼
Private EC2
Enter fullscreen mode Exit fullscreen mode

No static IP inventory. No SSH dependency. No requirement for a public SSH administration endpoint.

The instance discovery is dynamic, so Ansible queries AWS rather than depending on a stale hand-maintained inventory file. That was a much more interesting exercise than simply writing an Ansible playbook.

5. Real application deployment

The automation wasn't complete until it deployed an actual application. Ansible was used to:

  • pull the Docker image
  • manage the application container
  • inject database configuration
  • perform health checks
  • keep deployment behavior idempotent

The application was then verified through its health endpoint and database connectivity. That distinction mattered to me — there's a huge difference between:

  • terraform apply → success

and:

  • infrastructure works
  • application starts
  • ALB reaches it
  • application reaches RDS
  • /health returns success

The second one is the real test.

6. Secrets Manager — no hardcoded database password

The application deployment also needed real database credentials. Those credentials are managed through AWS Secrets Manager rather than being hardcoded into the Ansible deployment:

Terraform / RDS
      │
      ▼
Secrets Manager
      │
      ▼
Ansible lookup
      │
      ▼
Application container
Enter fullscreen mode Exit fullscreen mode

Credential-sensitive Ansible tasks use no_log: true so secrets aren't unnecessarily exposed in task output.

This was another important lesson: security is not a separate "security phase." It has to exist inside the deployment path itself.

7. GitHub Actions + OIDC

The CI/CD layer was the next step. Instead of storing long-lived AWS access keys in GitHub, I configured GitHub Actions to authenticate to AWS using OIDC federation. The pull-request path became roughly:

Pull Request
     │
     ▼
Terraform formatting / validation
     │
     ▼
Terraform linting
     │
     ▼
Terraform plan
     │
     ▼
Plan result posted back to PR
Enter fullscreen mode Exit fullscreen mode

After merge, the workflow continues into the apply path. That gave me practical experience with a pattern I wanted to understand much better than simply copying an OIDC example from a tutorial.

8. Environment replication — the main August challenge

This became the part of the project I cared about most. The goal was:

One Terraform codebase. Multiple environments. Same architecture. Different values.

The environment-specific configuration lives in:

environments/
├── dev.tfvars
└── prod.tfvars
Enter fullscreen mode Exit fullscreen mode

The variables control values such as environment name, VPC CIDR, ASG instance type, ASG capacity, RDS instance class, and RDS Multi-AZ configuration. The infrastructure modules remain shared:

                 SAME MODULES
                     │
          ┌──────────┴──────────┐
          │                     │
       dev.tfvars            prod.tfvars
          │                     │
          ▼                     ▼
         DEV                   PROD
      smaller                 larger
Enter fullscreen mode Exit fullscreen mode

But I didn't want to stop at "the files look the same." I wanted to prove it.

Proving environment parity

I generated Terraform plan output for each environment, extracted the resource addresses, sorted them, and compared the results:

dev resource addresses
        │
        ├────────── compare ──────────┐
        │                              │
prod resource addresses               │
        │                              │
        └──────────────► empty diff ◄─┘
Enter fullscreen mode Exit fullscreen mode

An empty diff means the environments have the same Terraform resource structure and resource counts. The values are intentionally allowed to differ. That became my definition of same architecture, different scale — much more meaningful to me than simply having two .tfvars files.

9. cidrsubnet() — turning environment variation into code

The VPC configuration also uses Terraform's cidrsubnet() function to derive subnet ranges from the environment's base CIDR, instead of manually maintaining a different set of subnet CIDRs for every environment:

base CIDR
   │
   ▼
cidrsubnet()
   │
   ├── public subnets
   └── private subnets
Enter fullscreen mode Exit fullscreen mode

That makes the subnet calculation deterministic and reusable. It was a small Terraform function, but it solved a real repeatability problem.

10. Importing infrastructure that already existed

Later in the month, I worked on something that is easy to overlook when learning Terraform: what happens when the AWS resource already exists?

I used a previously existing S3 bucket, tanay-website-june-2026, and brought it under Terraform management:

Existing AWS resource
        │
        ▼
terraform import
        │
        ▼
Terraform state
        │
        ▼
Configuration reconciliation
        │
        ▼
terraform plan
        │
        ▼
No changes
Enter fullscreen mode Exit fullscreen mode

This is fundamentally different from normal Terraform creation.

Normally: configuration → terraform apply → AWS resource.

With import: AWS resource → terraform import → state → reverse-engineer configuration → no changes.

That reversal was one of the better conceptual lessons of the project. I also reviewed the imported bucket's public-access, ACL, versioning, and encryption configuration instead of blindly assuming what was there.

11. Refactoring the Terraform project itself

Once the infrastructure had grown, the project structure itself needed work. Outputs had ended up scattered across module-usage files. I centralized them into outputs.tf, grouped by concern:

  • Networking
  • Compute
  • Load Balancer
  • Database
  • Supporting Infrastructure

I also documented the repository structure in PROJECT_STRUCTURE.md.

The important principle: a refactor should improve the code without accidentally changing the infrastructure. So the target state was:

terraform validate  → PASS
tflint              → PASS
terraform plan      → No changes
Enter fullscreen mode Exit fullscreen mode

That final "No changes" was the proof that the refactor was intentionally structural rather than an accidental infrastructure modification.


The Part That Actually Changed How I Think

This was probably the most valuable lesson from the month.

At one point I had a real "it succeeded, but it doesn't work" problem. Terraform had successfully applied infrastructure. Nothing obvious was red. But the application wasn't reachable.

The solution wasn't some magical Terraform command. It was debugging one layer at a time:

DNS
 │
 ▼
TCP / network connectivity
 │
 ▼
Application
 │
 ▼
Database / authentication
Enter fullscreen mode Exit fullscreen mode

That changed how I approach infrastructure problems. Instead of "what command fixes this?" I started asking "which layer is actually broken?"

That's not just a Terraform skill. It's a debugging methodology.


What Surprised Me

The biggest surprise was how many things that seem "small" when done manually become important once you try to automate the entire workflow.

SSH feels harmless. Clicking through the AWS Console feels harmless. Manually entering infrastructure values feels harmless. Hardcoding machine setup into boot scripts feels harmless.

Individually, these are all easy to justify. But when you add them together, you end up with infrastructure that is harder to reproduce, harder to review, harder to reason about, and easier to accidentally configure differently.

Removing those manual dependencies was much more valuable than learning another Terraform resource.


What I Can Now Explain

At the end of this project, I can explain the infrastructure as a system rather than as a collection of isolated tutorials:

                    GitHub
                       │
                     OIDC
                       │
                       ▼
                 GitHub Actions
                       │
                       ▼
                   Terraform
                       │
        ┌──────────────┼──────────────┐
        ▼              ▼              ▼
       VPC             ALB            RDS
        │              │
        │              ▼
        └──────────►   ASG
                       │
                  Packer AMI
                       │
                     Docker
                       │
                      SSM
                       │
                    Ansible
                       │
                 Secrets Manager
Enter fullscreen mode Exit fullscreen mode

Each layer has a purpose.


The Biggest Takeaway

At the beginning of August, my mental model was: "I know Terraform."

By the end, the better statement is: "I can reason about an infrastructure system."

I learned how infrastructure state behaves. I learned how immutable images fit into compute provisioning. I learned how configuration management can work without SSH. I learned how CI can authenticate to AWS without long-lived access keys. I learned how to replicate an environment without duplicating the infrastructure code. I learned how to import existing infrastructure instead of pretending every resource starts from zero.

And I learned that the most important part of infrastructure engineering isn't making a command return green. It's being able to explain why it's green, what it proves, and what it doesn't prove.


What's in the Repository

terraform-lab/
│
├── main.tf
├── backend.tf
├── environment-vars.tf
├── vpc-usage.tf
├── alb-usage.tf
├── asg-usage.tf
├── rds-usage.tf
├── github-oidc-usage.tf
├── ansible-ssm-bucket.tf
├── console-imports.tf
├── outputs.tf
│
├── environments/
│   ├── dev.tfvars
│   └── prod.tfvars
│
├── modules/
│   ├── vpc/
│   ├── alb/
│   ├── asg/
│   ├── rds/
│   └── github-oidc/
│
├── ansible/
├── packer/
├── tests/
├── docs/
├── PROJECT_STRUCTURE.md
├── deploy.sh
└── README.md
Enter fullscreen mode Exit fullscreen mode

More detail is available in PROJECT_STRUCTURE.md.

Repository: 🔗 github.com/tanayjdev/terraform-lab


What's Next

August was about building the infrastructure foundation. The next step is going deeper into Kubernetes, container orchestration, service discovery, scaling, observability, and platform engineering.

Terraform got me much closer to infrastructure. The next challenge is learning how to run that infrastructure at a larger scale.

Tanay Jain
BCA Student | Aspiring Cloud & DevOps Engineer

#Terraform #AWS #DevOps #InfrastructureAsCode #Cloud #Packer #Ansible

Top comments (0)