<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Farouq Mousa</title>
    <description>The latest articles on DEV Community by Farouq Mousa (@farouqmousa).</description>
    <link>https://dev.to/farouqmousa</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1101549%2F10ec869c-44bf-416b-b44e-32c2a14cad7b.jpeg</url>
      <title>DEV Community: Farouq Mousa</title>
      <link>https://dev.to/farouqmousa</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/farouqmousa"/>
    <language>en</language>
    <item>
      <title>Production-Ready Terraform - Part 3: The Finale — Automating Terraform with CI/CD</title>
      <dc:creator>Farouq Mousa</dc:creator>
      <pubDate>Tue, 16 Dec 2025 09:32:30 +0000</pubDate>
      <link>https://dev.to/aws-builders/production-ready-terraform-part-3-the-finale-automating-terraform-with-cicd-1mf7</link>
      <guid>https://dev.to/aws-builders/production-ready-terraform-part-3-the-finale-automating-terraform-with-cicd-1mf7</guid>
      <description>&lt;p&gt;Hello again,&lt;br&gt;
In [Part 2], we moved our state to the cloud using S3 and secured it with locking (via DynamoDB or S3 Native). But we still have a problem: &lt;strong&gt;we are running Terraform from our laptops.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Running &lt;code&gt;terraform apply&lt;/code&gt; locally is risky. It relies on local credentials, lacks audit trails, and "works on my machine" doesn't cut it for production. In this final chapter, we will build a fully automated &lt;strong&gt;CI/CD Pipeline&lt;/strong&gt; that moves Terraform execution to a controlled environment.&lt;/p&gt;

&lt;p&gt;We will cover:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Automating the Plan:&lt;/strong&gt; Running &lt;code&gt;terraform plan&lt;/code&gt; automatically on every Pull Request.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;The "Human in the Loop":&lt;/strong&gt; using GitHub Environments or Atlantis to approve changes safely.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;The "PR-to-Prod" Workflow:&lt;/strong&gt; A complete end-to-end lifecycle for your infrastructure.&lt;/li&gt;
&lt;/ol&gt;


&lt;h3&gt;
  
  
  1. Automating the Plan (GitHub Actions)
&lt;/h3&gt;

&lt;p&gt;The first rule of CI/CD for infrastructure is &lt;strong&gt;visibility&lt;/strong&gt;. When a developer opens a Pull Request (PR), we want to immediately see what &lt;em&gt;would&lt;/em&gt; happen if we merged that code.&lt;/p&gt;

&lt;p&gt;We will use &lt;strong&gt;GitHub Actions&lt;/strong&gt; to automatically run &lt;code&gt;terraform plan&lt;/code&gt; and post the results as a comment on the PR.&lt;/p&gt;
&lt;h4&gt;
  
  
  Prerequisites: OIDC (Stop using Keys!)
&lt;/h4&gt;

&lt;p&gt;&lt;em&gt;Security Note:&lt;/em&gt; Do not store long-lived AWS Access Keys in your GitHub Secrets. Instead, use &lt;strong&gt;OpenID Connect (OIDC)&lt;/strong&gt;. It allows GitHub Actions to assume an IAM Role in your AWS account temporarily.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Create an IAM Role&lt;/strong&gt; in AWS with a trust policy allowing your GitHub repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grant this role&lt;/strong&gt; permissions to manage your infrastructure and access the S3 backend.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;
  
  
  The "Plan" Workflow
&lt;/h4&gt;

&lt;p&gt;Create a file at &lt;code&gt;.github/workflows/terraform-plan.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Terraform&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Plan"&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;main&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt; &lt;span class="c1"&gt;# Required for OIDC&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt; &lt;span class="c1"&gt;# Required to post comments&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Terraform&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Plan"&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout Code&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v3&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure AWS Credentials (OIDC)&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::123456789012:role/GitHubActionsTerraformRole&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup Terraform&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hashicorp/setup-terraform@v3&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Terraform Init&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform init&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Terraform Plan&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;plan&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform plan -no-color -out=tfplan&lt;/span&gt;
        &lt;span class="na"&gt;continue-on-error&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="c1"&gt;# Don't fail the job yet, we want to see the error in the comment&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Post Plan to PR&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/github-script@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;github-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
          &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;const plan = `${{ steps.plan.outputs.stdout }}`;&lt;/span&gt;
            &lt;span class="s"&gt;const outcome = `${{ steps.plan.outcome }}`;&lt;/span&gt;

            &lt;span class="s"&gt;const output = `#### Terraform Plan 📖 \`${outcome}\`&lt;/span&gt;
            &lt;span class="s"&gt;&amp;lt;details&amp;gt;&amp;lt;summary&amp;gt;Show Plan&amp;lt;/summary&amp;gt;&lt;/span&gt;

            &lt;span class="s"&gt;\`\`\`hcl&lt;/span&gt;
            &lt;span class="s"&gt;${plan}&lt;/span&gt;
            &lt;span class="s"&gt;\`\`\`&lt;/span&gt;

            &lt;span class="s"&gt;&amp;lt;/details&amp;gt;`;&lt;/span&gt;

            &lt;span class="s"&gt;github.rest.issues.createComment({&lt;/span&gt;
              &lt;span class="s"&gt;issue_number: context.issue.number,&lt;/span&gt;
              &lt;span class="s"&gt;owner: context.repo.owner,&lt;/span&gt;
              &lt;span class="s"&gt;repo: context.repo.repo,&lt;/span&gt;
              &lt;span class="s"&gt;body: output&lt;/span&gt;
            &lt;span class="s"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; Triggers whenever a PR is opened against &lt;code&gt;main&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt; Authenticates to AWS securely via OIDC.&lt;/li&gt;
&lt;li&gt; Runs &lt;code&gt;terraform plan&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Critically:&lt;/strong&gt; It uses a script to post the plan output &lt;em&gt;directly into the PR conversation&lt;/em&gt;. Now, your team can review infrastructure changes just like they review code.&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  2. The "Human in the Loop": Approving the Apply
&lt;/h3&gt;

&lt;p&gt;We never want to automatically &lt;code&gt;apply&lt;/code&gt; changes to production without a human sanity check. Infrastructure mistakes can be catastrophic (e.g., accidentally deleting a database).&lt;/p&gt;

&lt;p&gt;We have two main strategies for this:&lt;/p&gt;

&lt;h4&gt;
  
  
  Option A: The "GitHub Native" Way (Environments)
&lt;/h4&gt;

&lt;p&gt;GitHub has a feature called &lt;strong&gt;Environments&lt;/strong&gt;. You can create an environment called &lt;code&gt;production&lt;/code&gt; and add a protection rule: &lt;strong&gt;"Required Reviewers"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When you set this up, the "Apply" workflow (triggered after merge) will pause and wait for a designated person to click "Approve" in the GitHub UI before running &lt;code&gt;terraform apply&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pros:&lt;/strong&gt; Native to GitHub, easy to set up, free for public repos (and paid plans).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; Requires maintaining two separate workflows (Plan on PR, Apply on Merge).&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Option B: The "ChatOps" Way (Atlantis)
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;Atlantis&lt;/strong&gt; is a popular open-source tool dedicated to Terraform automation. It runs as a service (e.g., in Fargate or K8s) and listens to webhooks from your repo.&lt;/p&gt;

&lt;p&gt;Instead of clicking buttons, you interact via comments:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; You comment &lt;code&gt;atlantis plan&lt;/code&gt; on the PR. Atlantis runs the plan and replies with the output.&lt;/li&gt;
&lt;li&gt; You comment &lt;code&gt;atlantis apply&lt;/code&gt;. Atlantis locks the state, runs the apply, and automatically merges the PR if successful.&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pros:&lt;/strong&gt; Incredible locking mechanism (locks the PR so no one else can edit), keeps the "Plan" and "Apply" strictly coupled (you apply exactly what you planned).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; Requires hosting and maintaining a server/container.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  3. The Full "PR-to-Prod" Automated Workflow
&lt;/h3&gt;

&lt;p&gt;Let's assume we are using &lt;strong&gt;Option A (GitHub Native)&lt;/strong&gt; for simplicity. Here is what the full lifecycle looks like:&lt;/p&gt;

&lt;h4&gt;
  
  
  1. The Trigger
&lt;/h4&gt;

&lt;p&gt;A developer creates a new branch, changes an EC2 instance type in &lt;code&gt;main.tf&lt;/code&gt;, and opens a Pull Request.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. The Automated Plan
&lt;/h4&gt;

&lt;p&gt;GitHub Actions triggers the &lt;strong&gt;Plan Workflow&lt;/strong&gt;. It runs &lt;code&gt;terraform plan&lt;/code&gt;, sees that an instance needs to change from &lt;code&gt;t2.micro&lt;/code&gt; to &lt;code&gt;t3.medium&lt;/code&gt;, and comments this diff on the PR.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. The Human Review
&lt;/h4&gt;

&lt;p&gt;The Tech Lead reviews the PR code and the Plan comment. They see that the change is intentional and safe. They approve the PR.&lt;/p&gt;

&lt;h4&gt;
  
  
  4. The Merge &amp;amp; Apply
&lt;/h4&gt;

&lt;p&gt;The developer merges the PR into &lt;code&gt;main&lt;/code&gt;. This triggers the &lt;strong&gt;Apply Workflow&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Terraform&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Apply"&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;main&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;apply&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Terraform&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Apply"&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt; &lt;span class="c1"&gt;# &amp;lt;--- This enforces the "Human Approval" rule!&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v3&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure AWS Credentials&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::123456789012:role/GitHubActionsTerraformRole&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup Terraform&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hashicorp/setup-terraform@v3&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Terraform Init&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform init&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Terraform Apply&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform apply -auto-approve&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because we added &lt;code&gt;environment: production&lt;/code&gt;, this job will enter a &lt;strong&gt;"Waiting"&lt;/strong&gt; state. GitHub notifies the required reviewers. Once approved, the job resumes, runs &lt;code&gt;terraform apply -auto-approve&lt;/code&gt;, and your infrastructure is updated.&lt;/p&gt;




&lt;h3&gt;
  
  
  Conclusion &amp;amp; Series Takeaways 🎓
&lt;/h3&gt;

&lt;p&gt;We have come a long way. We started with a fragile &lt;code&gt;terraform.tfstate&lt;/code&gt; file on a laptop and ended with a robust, automated, team-ready CI/CD pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Takeaways from the Series:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Remote State is Non-Negotiable:&lt;/strong&gt; Never keep state local. Use S3 (or compatible storage) to share state and prevent data loss.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Locking Saves Lives:&lt;/strong&gt; Always implement locking (DynamoDB or S3 Native) to prevent two people from corrupting state by applying at the same time.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Don't Apply Blindly:&lt;/strong&gt; Use CI/CD to visualize &lt;code&gt;terraform plan&lt;/code&gt; on every Pull Request. Treat infrastructure changes with the same rigor as application code.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Least Privilege:&lt;/strong&gt; Use OIDC for your pipelines. Don't scatter long-lived AWS Access Keys across developer laptops or CI systems.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By following this pattern, you transform Terraform from a "dangerous tool only the Ops lead can touch" into a safe, collaborative platform for the entire engineering team.&lt;/p&gt;

&lt;p&gt;Happy Terraforming! Feel free to leave your questions in the comments, and I will be glad to connect on &lt;a href="https://www.linkedin.com/in/farouqmousa/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; Parts of this article were drafted with the help of an AI assistant. The technical concepts, code examples, and overall structure were directed, curated, and verified by the author to ensure technical accuracy and reflect real-world experience.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>cicd</category>
      <category>tutorial</category>
      <category>terraform</category>
    </item>
    <item>
      <title>A Better Way to Write Production-Ready Terraform - Part 2 - Remote State Management</title>
      <dc:creator>Farouq Mousa</dc:creator>
      <pubDate>Wed, 12 Nov 2025 13:41:09 +0000</pubDate>
      <link>https://dev.to/aws-builders/a-better-way-to-write-production-ready-terraform-part-2-remote-state-management-1j5d</link>
      <guid>https://dev.to/aws-builders/a-better-way-to-write-production-ready-terraform-part-2-remote-state-management-1j5d</guid>
      <description>&lt;h3&gt;
  
  
  In This Article:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Why the default &lt;code&gt;terraform.tfstate&lt;/code&gt; is a production-killer.&lt;/li&gt;
&lt;li&gt;Setting up an S3 backend with DynamoDB locking.&lt;/li&gt;
&lt;li&gt;Using Terragrunt to keep your environment config DRY.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hey everyone, and welcome back! In &lt;a href="https://dev.to/aws-builders/from-messy-to-modular-a-better-way-to-write-production-ready-terraform-for-aws-part-1-39b8"&gt;Part 1 of this series&lt;/a&gt;, we tackled the first major challenge in writing professional IaC: &lt;strong&gt;modularity&lt;/strong&gt;. We took a complex EKS cluster, broke it down into a reusable module, and learned how to use &lt;code&gt;.tfvars&lt;/code&gt; and &lt;code&gt;locals&lt;/code&gt; to create clean, declarative environment configurations.&lt;/p&gt;

&lt;p&gt;Our &lt;code&gt;dev&lt;/code&gt; environment's &lt;code&gt;main.tf&lt;/code&gt; looked great. But we left off with a critical, unanswered question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What happens when you actually run &lt;code&gt;terraform apply&lt;/code&gt;?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you followed along, you now have a &lt;code&gt;terraform.tfstate&lt;/code&gt; file sitting in your &lt;code&gt;environments/dev&lt;/code&gt; directory. This single file is the "source of truth" that maps your code to your real-world AWS resources.&lt;/p&gt;

&lt;p&gt;And right now, it's a real Production-Killer!&lt;/p&gt;




&lt;h2&gt;
  
  
  The Danger of Local State Files
&lt;/h2&gt;

&lt;p&gt;If you're working alone, on a single project, a local state file is fine. The second you add a teammate or a CI/CD pipeline, that local &lt;code&gt;terraform.tfstate&lt;/code&gt; file becomes your biggest liability.&lt;/p&gt;

&lt;p&gt;Here are the scenarios that keep me up at night:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The "Who Has the Latest?" Problem:&lt;/strong&gt; You run &lt;code&gt;apply&lt;/code&gt;, then your co-worker (who doesn't have your state file) also runs &lt;code&gt;apply&lt;/code&gt;. They just created a &lt;em&gt;second&lt;/em&gt; EKS cluster, or worse, their operation failed, thinking the first one didn't exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "It's on My Laptop" Problem:&lt;/strong&gt; You go on vacation. A production-down incident happens. The only copy of the production state file is on your encrypted laptop, which is 10,000 miles away. The team is completely blocked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "Race Condition" Problem:&lt;/strong&gt; You and a colleague &lt;code&gt;apply&lt;/code&gt; at the &lt;em&gt;exact same time&lt;/em&gt;. You both read the same state file, and you both try to modify the same resource. This corrupts your state file, and now Terraform has no idea what's real and what's not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "Leaked Secrets" Problem:&lt;/strong&gt; State files often contain sensitive data in plain text. If you accidentally &lt;code&gt;git commit&lt;/code&gt; your state file, you've just pushed secrets to your repository.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The solution to all of this is &lt;strong&gt;Remote State&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Solution: Remote State with S3 and DynamoDB
&lt;/h2&gt;

&lt;p&gt;A remote state backend moves the state file off your laptop and into a shared, centralized, and secured location. For our AWS stack, the standard pattern is a combination of two services:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Amazon S3:&lt;/strong&gt; Used to store the state file itself.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Amazon DynamoDB:&lt;/strong&gt; Used for &lt;strong&gt;state locking&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Wait, locking? What's that?&lt;/p&gt;

&lt;p&gt;When you run &lt;code&gt;terraform apply&lt;/code&gt;, Terraform will first place a "lock" in the DynamoDB table. If your co-worker tries to run &lt;code&gt;apply&lt;/code&gt; at the same time, their command will fail, stating that the state is already locked by you. This simple mechanism completely prevents race conditions and state corruption.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to Implement It
&lt;/h3&gt;

&lt;p&gt;First, you need to create the S3 bucket and DynamoDB table. (You only do this &lt;em&gt;once&lt;/em&gt;).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Pro Tip:&lt;/strong&gt; Since these resources are the foundation for &lt;em&gt;all&lt;/em&gt; your Terraform projects, I recommend creating them manually or with a simple, separate Terraform setup that you run and then "forget" about.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;S3 Bucket:&lt;/strong&gt; Create an S3 bucket. Let's call it &lt;code&gt;my-awesome-app-tfstate&lt;/code&gt;. Enable bucket versioning (so you can roll back a bad state) and block all public access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DynamoDB Table:&lt;/strong&gt; Create a DynamoDB table. Let's call it &lt;code&gt;terraform-state-lock&lt;/code&gt;. It only needs one attribute: a &lt;strong&gt;Partition key&lt;/strong&gt; named &lt;code&gt;LockID&lt;/code&gt; (with a type of &lt;code&gt;String&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Configuring Your Environment
&lt;/h3&gt;

&lt;p&gt;Now, in &lt;em&gt;each&lt;/em&gt; of your environment directories (&lt;code&gt;environments/dev&lt;/code&gt;, &lt;code&gt;environments/prod&lt;/code&gt;), you add a new file. Let's call it &lt;code&gt;backend.tf&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;environments/dev/backend.tf&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"my-awesome-app-tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eks-cluster/dev/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eu-west-1"&lt;/span&gt;
    &lt;span class="nx"&gt;dynamodb_table&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-state-lock"&lt;/span&gt;
    &lt;span class="nx"&gt;encrypt&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;environments/prod/backend.tf&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"my-awesome-app-tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eks-cluster/prod/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eu-west-1"&lt;/span&gt;
    &lt;span class="nx"&gt;dynamodb_table&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-state-lock"&lt;/span&gt;
    &lt;span class="nx"&gt;encrypt&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look closely at the &lt;code&gt;key&lt;/code&gt; property. This is the magic.&lt;/p&gt;

&lt;p&gt;We are storing &lt;strong&gt;both&lt;/strong&gt; environment state files in the &lt;em&gt;same&lt;/em&gt; bucket, but we're giving them unique paths (or "keys"). This provides perfect isolation. Your &lt;code&gt;dev&lt;/code&gt; &lt;code&gt;apply&lt;/code&gt; will read/write to the &lt;code&gt;dev&lt;/code&gt; state file, and your &lt;code&gt;prod&lt;/code&gt; &lt;code&gt;apply&lt;/code&gt; will only touch the &lt;code&gt;prod&lt;/code&gt; state file.&lt;/p&gt;

&lt;p&gt;Now, when you &lt;code&gt;cd environments/dev&lt;/code&gt; and run &lt;code&gt;terraform init&lt;/code&gt;, Terraform will detect the &lt;code&gt;backend&lt;/code&gt; block. It will ask if you want to copy your existing local state to the new S3 backend. Say "yes," and you're officially running on remote state!&lt;/p&gt;




&lt;p&gt;This is a highly valuable update for your article series! The deprecation of DynamoDB-based locking in favor of the S3 native mechanism is a major shift that simplifies production workflows.&lt;/p&gt;

&lt;p&gt;I will integrate this information into a new section in Part 2, aligning with your previous structure and tone. Note that the S3 native locking was generally available (GA) starting in &lt;strong&gt;Terraform v1.11.0&lt;/strong&gt;, after being introduced as an experimental feature in v1.10.&lt;/p&gt;




&lt;h2&gt;
  
  
  Introducing S3 Native State Locking (Terraform v1.11+)
&lt;/h2&gt;

&lt;p&gt;The core of our Part 2 solution—using a dedicated DynamoDB table for state locking—is the battle-tested, standard pattern. However, the world of Terraform is constantly evolving, and a major simplification has arrived that we must address: &lt;strong&gt;S3 Native State Locking&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;While effective, relying on a DynamoDB table added &lt;strong&gt;cost and complexity&lt;/strong&gt;. It forced us to manage an extra resource and grant additional IAM permissions for every environment, violating our goal of minimal overhead.&lt;/p&gt;

&lt;p&gt;With &lt;strong&gt;Terraform v1.11.0&lt;/strong&gt; (and later), the S3 backend now includes a built-in locking mechanism that works &lt;strong&gt;without DynamoDB&lt;/strong&gt;, leveraging S3's conditional write capabilities to ensure safety.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Switch from DynamoDB?
&lt;/h3&gt;

&lt;p&gt;The motivation is simple: &lt;strong&gt;simplification and cost reduction&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fewer Resources:&lt;/strong&gt; You eliminate the need to provision and maintain a dedicated DynamoDB table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reduced Overhead:&lt;/strong&gt; Less IAM policy management and fewer resources to monitor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lower Cost:&lt;/strong&gt; Eliminates the small but constant cost associated with DynamoDB table usage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;While DynamoDB locking is robust, Terraform's long-term roadmap signals a shift towards this simplified, native locking model, with DynamoDB support slated for future deprecation.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to Enable S3 Native Locking
&lt;/h3&gt;

&lt;p&gt;The process is incredibly straightforward, requiring only the addition of the &lt;code&gt;use_lockfile&lt;/code&gt; argument to your backend configuration.&lt;/p&gt;

&lt;h4&gt;
  
  
  Before: Using DynamoDB for Locking (The Classic Pattern)
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;  
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;  
    &lt;span class="nx"&gt;bucket&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"your-terraform-state-bucket"&lt;/span&gt;  
    &lt;span class="nx"&gt;key&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"path/to/your/statefile.tfstate"&lt;/span&gt;  
    &lt;span class="nx"&gt;region&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;  
    &lt;span class="nx"&gt;dynamodb_table&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-state-lock"&lt;/span&gt;  &lt;span class="c1"&gt;# 👋 Goodbye, complexity!&lt;/span&gt;
    &lt;span class="nx"&gt;encrypt&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  
  &lt;span class="p"&gt;}&lt;/span&gt;  
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  After: Switching to S3 Native Locking
&lt;/h4&gt;

&lt;p&gt;Ensure you are on &lt;strong&gt;Terraform v1.11.0 or newer&lt;/strong&gt;. Simply remove the &lt;code&gt;dynamodb_table&lt;/code&gt; line and add the lockfile flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;  
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;  
    &lt;span class="nx"&gt;bucket&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"your-terraform-state-bucket"&lt;/span&gt;  
    &lt;span class="nx"&gt;key&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"path/to/your/statefile.tfstate"&lt;/span&gt;  
    &lt;span class="nx"&gt;region&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;  
    &lt;span class="nx"&gt;encrypt&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  
    &lt;span class="nx"&gt;use_lockfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  &lt;span class="c1"&gt;# 🎉 S3 native locking enabled&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;  
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With S3 locking enabled, Terraform creates a temporary .tflock file in the same location as the state file during any operation. You may need to update your S3 bucket policies and IAM permissions to accommodate the new lock file. You can also temporarily use both dynamodb_table and use_lockfile = true during your migration for maximum safety.&lt;/p&gt;




&lt;h2&gt;
  
  
  The New Problem: We're Not DRY
&lt;/h2&gt;

&lt;p&gt;This is a huge improvement. Our state is secure, locked, and versioned. But as a senior engineer, something about this should bother you...&lt;/p&gt;

&lt;p&gt;We're &lt;strong&gt;repeating ourselves&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;backend.tf&lt;/code&gt; block is &lt;em&gt;identical&lt;/em&gt; in &lt;code&gt;dev&lt;/code&gt; and &lt;code&gt;prod&lt;/code&gt;, except for one line: the &lt;code&gt;key&lt;/code&gt;. And what about our &lt;code&gt;provider.tf&lt;/code&gt;? We're probably copying that into every environment too.&lt;/p&gt;

&lt;p&gt;If we have 50 microservices, that's 50 (or 100, or 150) copies of the same &lt;code&gt;backend.tf&lt;/code&gt; and &lt;code&gt;provider.tf&lt;/code&gt; files. What happens when we need to update our provider version? We have to find and replace it in 150 places.&lt;/p&gt;

&lt;p&gt;This is a violation of the DRY (Don't Repeat Yourself) principle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Next Level" Solution: Terragrunt
&lt;/h2&gt;

&lt;p&gt;This is where a tool like &lt;a href="https://terragrunt.gruntwork.io/" rel="noopener noreferrer"&gt;Terragrunt&lt;/a&gt; comes in. Terragrunt is a thin wrapper for Terraform that provides extra tools to manage multiple environments.&lt;/p&gt;

&lt;p&gt;Its main superpower is keeping your environment configurations DRY.&lt;/p&gt;

&lt;p&gt;With Terragrunt, your file structure changes. You get rid of &lt;code&gt;backend.tf&lt;/code&gt;, &lt;code&gt;provider.tf&lt;/code&gt;, etc., in your environment directories. Instead, you create a &lt;code&gt;terragrunt.hcl&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;New Project Structure:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform-project/
├── modules/
│   └── aws-eks-cluster/
│       ├── main.tf
│       └── ...
├── environments/
│   ├── dev/
│   │   ├── terragrunt.hcl
│   │   └── dev.tfvars
│   ├── prod/
│   │   ├── terragrunt.hcl
│   │   └── prod.tfvars
│   └── terragrunt.hcl  &lt;span class="c"&gt;# &amp;lt;--- A NEW ROOT FILE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;1. The Root &lt;code&gt;terragrunt.hcl&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
This file defines the configuration you want to &lt;em&gt;share&lt;/em&gt; across all environments.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# environments/terragrunt.hcl&lt;/span&gt;

&lt;span class="c1"&gt;# Configure the remote state backend ONCE&lt;/span&gt;
&lt;span class="nx"&gt;remote_state&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt;
  &lt;span class="nx"&gt;generate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"backend.tf"&lt;/span&gt;
    &lt;span class="nx"&gt;if_exists&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"override"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"my-awesome-app-tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${path_relative_to_include()}/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eu-west-1"&lt;/span&gt;
    &lt;span class="nx"&gt;dynamodb_table&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-state-lock"&lt;/span&gt;
    &lt;span class="nx"&gt;encrypt&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Define the inputs we want to pass to our Terraform modules&lt;/span&gt;
&lt;span class="nx"&gt;inputs&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# We can automatically load .tfvars files&lt;/span&gt;
  &lt;span class="c1"&gt;# This finds dev.tfvars in dev, prod.tfvars in prod, etc.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;key = "${path_relative_to_include()}/terraform.tfstate"&lt;/code&gt; is the magic. Terragrunt will automatically generate a unique key for each environment based on its directory path (e.g., &lt;code&gt;dev/terraform.tfstate&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The Environment &lt;code&gt;terragrunt.hcl&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
Now, your environment-specific files become incredibly simple.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;environments/dev/terragrunt.hcl&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;include&lt;/span&gt; &lt;span class="s2"&gt;"root"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;find_in_parent_folders&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Tell Terragrunt where our actual Terraform module is&lt;/span&gt;
&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"../../modules/aws-eks-cluster"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# All inputs are automatically loaded from dev.tfvars!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. This file tells Terragrunt to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; Go find the root &lt;code&gt;terragrunt.hcl&lt;/code&gt; and inherit all its settings (like the S3 backend).&lt;/li&gt;
&lt;li&gt; Use the &lt;code&gt;aws-eks-cluster&lt;/code&gt; module as its source code.&lt;/li&gt;
&lt;li&gt; Automatically find and use all the variables defined in &lt;code&gt;dev.tfvars&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now, to deploy &lt;code&gt;dev&lt;/code&gt;, you &lt;code&gt;cd environments/dev&lt;/code&gt; and run:&lt;br&gt;
&lt;code&gt;terragrunt apply&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Terragrunt will, in the background, generate the &lt;code&gt;backend.tf&lt;/code&gt; file for you, pull down the module, and run &lt;code&gt;terraform apply&lt;/code&gt; with all your variables from &lt;code&gt;dev.tfvars&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We have achieved the ultimate goal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Modules&lt;/strong&gt; are DRY (Part 1).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State Management&lt;/strong&gt; is robust and safe (Part 2).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment Configuration&lt;/strong&gt; is DRY (Part 2).&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What's Next in Part 3?
&lt;/h2&gt;

&lt;p&gt;We've come a long way. We've defined our infrastructure as reusable modules, and we've built a scalable, DRY structure to manage state and configuration for multiple environments.&lt;/p&gt;

&lt;p&gt;But how do we &lt;em&gt;run&lt;/em&gt; this? So far, we've been running &lt;code&gt;terragrunt apply&lt;/code&gt; from our laptops. That's not a real-world workflow.&lt;/p&gt;

&lt;p&gt;In Part 3, we'll tie this all together in a &lt;strong&gt;CI/CD Pipeline&lt;/strong&gt;. We'll explore:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How to set up GitHub Actions (or your tool of choice) to run &lt;code&gt;plan&lt;/code&gt; on every pull request.&lt;/li&gt;
&lt;li&gt;The "human in the loop": Using tools like &lt;strong&gt;Atlantis&lt;/strong&gt; or GitHub Actions approval steps to safely run &lt;code&gt;apply&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A full, "PR-to-Prod" automated workflow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stay tuned, and happy building! Feel free to leave your questions in the comments, and I will be glad to connect on &lt;a href="https://www.linkedin.com/in/farouqmousa/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; Parts of this article were drafted with the help of an AI assistant. The technical concepts, code examples, and overall structure were directed, curated, and verified by the author to ensure technical accuracy and reflect real-world experience.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>devops</category>
      <category>cloud</category>
      <category>aws</category>
    </item>
    <item>
      <title>A Better Way to Write Production-Ready Terraform - Part 1 - Modularity</title>
      <dc:creator>Farouq Mousa</dc:creator>
      <pubDate>Sat, 27 Sep 2025 14:43:55 +0000</pubDate>
      <link>https://dev.to/aws-builders/from-messy-to-modular-a-better-way-to-write-production-ready-terraform-for-aws-part-1-39b8</link>
      <guid>https://dev.to/aws-builders/from-messy-to-modular-a-better-way-to-write-production-ready-terraform-for-aws-part-1-39b8</guid>
      <description>&lt;p&gt;Hey everyone! If you've been in the DevOps or Cloud Engineering space for a while, you've probably seen it all. From a single, monstrous &lt;code&gt;main.tf&lt;/code&gt; file that tries to define an entire universe, to copy-pasting code across projects until you can't tell which Kubernetes cluster belongs to &lt;code&gt;dev&lt;/code&gt; and which one is the &lt;code&gt;prod&lt;/code&gt; money-maker.&lt;/p&gt;

&lt;p&gt;We've all been there. You start with a simple project, and it works. Then business asks you to spin up a staging environment. Then a UAT environment. Soon, you're drowning in duplicated code, and a simple change (like a Kubernetes version upgrade) requires updating five different places. That's not just messy; it's a recipe for disaster.&lt;/p&gt;

&lt;p&gt;Over the years, I've learned that writing good Infrastructure as Code (IaC) is a lot like writing good application code. It's all about &lt;strong&gt;patterns&lt;/strong&gt;, &lt;strong&gt;reusability&lt;/strong&gt;, and &lt;strong&gt;modularity&lt;/strong&gt;. In this multi-part series, I'll walk you through the design patterns I use to build robust, scalable, and easy-to-manage Terraform projects for AWS.&lt;/p&gt;

&lt;p&gt;Today, we're starting with the absolute foundation for taming complexity: &lt;strong&gt;The Module Pattern&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Wrong With a Giant &lt;code&gt;main.tf&lt;/code&gt;?
&lt;/h2&gt;

&lt;p&gt;Imagine you're building a car engine. You could try to assemble every single piston, wire, and bolt in one go, right on the factory floor. It might work, but what happens when you want to build a slightly different engine for a different car model? You'd have to start from scratch or painstakingly copy your first creation.&lt;/p&gt;

&lt;p&gt;A monolithic &lt;code&gt;main.tf&lt;/code&gt; file for an EKS cluster is that chaotic assembly. It mixes the &lt;em&gt;what&lt;/em&gt; (an EKS control plane, IAM roles, node groups, security groups) with the &lt;em&gt;where&lt;/em&gt; and &lt;em&gt;why&lt;/em&gt; (this is for the &lt;code&gt;dev&lt;/code&gt; environment with &lt;code&gt;t3.medium&lt;/code&gt; nodes, this is for the &lt;code&gt;prod&lt;/code&gt; app with &lt;code&gt;m5.large&lt;/code&gt; nodes).&lt;/p&gt;

&lt;p&gt;This approach has several major flaws:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's not DRY (Don't Repeat Yourself):&lt;/strong&gt; Creating a new environment means copying hundreds of lines of complex IAM and networking code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High cognitive load:&lt;/strong&gt; Understanding the cluster setup requires deciphering a huge, interconnected block of code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High blast radius:&lt;/strong&gt; A small typo in an IAM policy could cripple your entire cluster, and if you've copy-pasted, that vulnerability exists in every environment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We can do better. Instead of assembling the engine piece by piece every time, let's build pre-fabricated components, like a complete fuel injection system or a pre-wired ignition module. In Terraform, we call these &lt;strong&gt;modules&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Module Pattern: Your IaC Building Blocks
&lt;/h2&gt;

&lt;p&gt;A Terraform module is a self-contained package of Terraform configurations that are managed as a group. Think of it as a function in a programming language. It takes some inputs (variables), performs some actions (creates resources), and provides some outputs.&lt;/p&gt;

&lt;p&gt;For something like an EKS cluster, a module is a lifesaver. It can encapsulate all the boilerplate resources needed to get a cluster up and running:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The EKS cluster control plane itself.&lt;/li&gt;
&lt;li&gt;The complex IAM roles and policies for the cluster and its nodes.&lt;/li&gt;
&lt;li&gt;The managed node groups, including their launch templates and auto-scaling configurations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You then call this single module from your environment-specific code, passing in values like the cluster name, version, and instance types.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Practical Example: A Reusable EKS Cluster Module
&lt;/h3&gt;

&lt;p&gt;Let's build a simplified version. A common best practice is to have a &lt;code&gt;modules&lt;/code&gt; directory in your repository where you store your custom, reusable modules. Our initial project structure looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform-project/
├── modules/
│   └── aws-eks-cluster/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
└── environments/
    └── dev/
        └── main.tf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside &lt;code&gt;modules/aws-eks-cluster/&lt;/code&gt;, we define the module's API (&lt;code&gt;variables.tf&lt;/code&gt;), its logic (&lt;code&gt;main.tf&lt;/code&gt;), and its return values (&lt;code&gt;outputs.tf&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(For brevity, the full code for the EKS module is omitted here but is the same as in our previous discussion. It defines resources like &lt;code&gt;aws_eks_cluster&lt;/code&gt;, &lt;code&gt;aws_eks_node_group&lt;/code&gt;, and their associated IAM roles.)&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Patterns for Environment Variables
&lt;/h2&gt;

&lt;p&gt;Okay, we have our reusable EKS module. Now for the most important part: How do we feed it the right configuration for each environment (&lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;prod&lt;/code&gt;, etc.) in a way that is clean, scalable, and easy to manage?&lt;/p&gt;

&lt;p&gt;Let's look at the evolution of passing variables, from the basic approach to the recommended best practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Method 1: In-line Arguments
&lt;/h3&gt;

&lt;p&gt;The most straightforward way is to hardcode the values directly in the &lt;code&gt;module&lt;/code&gt; block inside your environment's &lt;code&gt;main.tf&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="c1"&gt;# environments/dev/main.tf&lt;/span&gt;

&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"eks_cluster"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"../../modules/aws-eks-cluster"&lt;/span&gt;

  &lt;span class="c1"&gt;# --- In-line arguments ---&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"my-app-dev-cluster"&lt;/span&gt;
  &lt;span class="nx"&gt;node_group_instance_types&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"t3.medium"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;node_group_desired_size&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="c1"&gt;# ... other variables&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is fine for a quick test, but it doesn't scale. It mixes configuration with logic, making the file hard to read and forcing you to hunt for values when you need to make a change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Method 2: Using &lt;code&gt;.tfvars&lt;/code&gt; Files (The Standard Way)
&lt;/h3&gt;

&lt;p&gt;A much better pattern is to separate your configuration values from your resource logic. A &lt;code&gt;.tfvars&lt;/code&gt; file is a simple text file for variable assignments. You create one for each environment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Define Root Variables:&lt;/strong&gt; First, in your environment directory (&lt;code&gt;environments/dev/&lt;/code&gt;), create a &lt;code&gt;variables.tf&lt;/code&gt; file to declare the variables this environment will accept.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="c1"&gt;# environments/dev/variables.tf&lt;/span&gt;

&lt;span class="k"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"node_group_instance_types"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Instance types for the EKS node group."&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;# ... other variable definitions&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Create an Environment &lt;code&gt;.tfvars&lt;/code&gt; File:&lt;/strong&gt; Now, create a &lt;code&gt;dev.tfvars&lt;/code&gt; file in the same directory to provide the values.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="c1"&gt;# environments/dev/dev.tfvars&lt;/span&gt;

&lt;span class="nx"&gt;node_group_instance_types&lt;/span&gt;   &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"t3.medium"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nx"&gt;node_group_desired_size&lt;/span&gt;     &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your &lt;code&gt;prod&lt;/code&gt; environment would have its own &lt;code&gt;prod.tfvars&lt;/code&gt; with different values, like &lt;code&gt;["m5.large"]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Update &lt;code&gt;main.tf&lt;/code&gt; and Apply:&lt;/strong&gt; Your &lt;code&gt;main.tf&lt;/code&gt; now uses these variables, becoming generic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="c1"&gt;# environments/dev/main.tf&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"eks_cluster"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"../../modules/aws-eks-cluster"&lt;/span&gt;

  &lt;span class="c1"&gt;# Pass variables from the root module to the child module&lt;/span&gt;
  &lt;span class="nx"&gt;node_group_instance_types&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;node_group_instance_types&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You then apply the specific configuration from the command line: &lt;code&gt;terraform apply -var-file="dev.tfvars"&lt;/code&gt;. This cleanly separates the "what" from the "how."&lt;/p&gt;

&lt;h3&gt;
  
  
  Method 3: Using &lt;code&gt;locals&lt;/code&gt; for Derived Values
&lt;/h3&gt;

&lt;p&gt;What if you want to enforce a consistent naming convention? Instead of defining &lt;code&gt;cluster_name&lt;/code&gt; in every &lt;code&gt;.tfvars&lt;/code&gt; file, you can derive it using &lt;code&gt;locals&lt;/code&gt;. Locals are like named constants within your configuration.&lt;/p&gt;

&lt;p&gt;Create a &lt;code&gt;locals.tf&lt;/code&gt; file in your environment directory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="c1"&gt;# environments/dev/locals.tf&lt;/span&gt;

&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# Base variables&lt;/span&gt;
  &lt;span class="nx"&gt;environment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"dev"&lt;/span&gt;
  &lt;span class="nx"&gt;project&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"my-app"&lt;/span&gt;

  &lt;span class="c1"&gt;# Derived value to enforce naming conventions&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-cluster"&lt;/span&gt;

  &lt;span class="c1"&gt;# Centralized map of tags&lt;/span&gt;
  &lt;span class="nx"&gt;common_tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Environment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;Project&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, your &lt;code&gt;main.tf&lt;/code&gt; can use this local value, ensuring consistency: &lt;code&gt;cluster_name = local.cluster_name&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Putting It All Together: The Recommended Structure
&lt;/h3&gt;

&lt;p&gt;For a truly robust and readable project, you should &lt;strong&gt;combine Methods 2 and 3&lt;/strong&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Use &lt;code&gt;.tfvars&lt;/code&gt; files&lt;/strong&gt; for the raw inputs that change between environments (instance sizes, counts).&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Use &lt;code&gt;locals&lt;/code&gt;&lt;/strong&gt; to enforce conventions and derive values (names, tags).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is the complete, recommended file structure and workflow for your &lt;code&gt;dev&lt;/code&gt; environment:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project Structure:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform-project/
├── modules/
│   └── aws-eks-cluster/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
└── environments/
    └── dev/
        ├── main.tf           &lt;span class="c"&gt;# Orchestrates the modules&lt;/span&gt;
        ├── variables.tf      &lt;span class="c"&gt;# Defines input variables for the environment&lt;/span&gt;
        ├── locals.tf         &lt;span class="c"&gt;# Defines naming conventions and common tags&lt;/span&gt;
        └── dev.tfvars        &lt;span class="c"&gt;# Sets the actual values for dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;dev.tfvars&lt;/code&gt; (The Raw Config):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="c1"&gt;# environments/dev/dev.tfvars&lt;/span&gt;
&lt;span class="nx"&gt;instance_types&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"t3.medium"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nx"&gt;node_count&lt;/span&gt;     &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;locals.tf&lt;/code&gt; (The Conventions):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="c1"&gt;# environments/dev/locals.tf&lt;/span&gt;
&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;environment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"dev"&lt;/span&gt;
  &lt;span class="nx"&gt;project&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"my-app"&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-cluster"&lt;/span&gt;
  &lt;span class="nx"&gt;common_tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Environment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;Project&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;
    &lt;span class="nx"&gt;ManagedBy&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Terraform"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;main.tf&lt;/code&gt; (The Orchestrator):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="c1"&gt;# environments/dev/main.tf&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"eks_cluster"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"../../modules/aws-eks-cluster"&lt;/span&gt;

  &lt;span class="c1"&gt;# Values from locals&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster_name&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;common_tags&lt;/span&gt;

  &lt;span class="c1"&gt;# Values from .tfvars file&lt;/span&gt;
  &lt;span class="nx"&gt;node_group_instance_types&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instance_types&lt;/span&gt;
  &lt;span class="nx"&gt;node_group_desired_size&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;node_count&lt;/span&gt;

  &lt;span class="c1"&gt;# Other values like VPC and subnets&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_subnets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern gives you the best of all worlds: a clean separation of concerns, enforced consistency, and environment configurations that are simple and easy to audit.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next in Part 2?
&lt;/h2&gt;

&lt;p&gt;We've now built a reusable module and established a robust pattern for configuring it across multiple environments. This is a massive step towards professional-grade IaC.&lt;/p&gt;

&lt;p&gt;But there's still a critical piece of the puzzle missing: &lt;strong&gt;State Management&lt;/strong&gt;. Where does Terraform store the state file for each environment? How do we prevent developers from accidentally running &lt;code&gt;dev&lt;/code&gt; changes against the &lt;code&gt;prod&lt;/code&gt; state?&lt;/p&gt;

&lt;p&gt;In Part 2, we'll explore patterns for remote state backends (using S3, of course!) and introduce tools like &lt;strong&gt;Terragrunt&lt;/strong&gt; to keep our environment configurations even more DRY.&lt;/p&gt;

&lt;p&gt;Stay tuned, and happy building! Feel free to leave your questions in the comments, and I will be glad to connect on &lt;a href="https://www.linkedin.com/in/farouqmousa/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; Parts of this article were drafted with the help of an AI assistant. The technical concepts, code examples, and overall structure were directed, curated, and verified by the author to ensure technical accuracy and reflect real-world experience.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>terraform</category>
      <category>cicd</category>
    </item>
  </channel>
</rss>
