<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>IT Wonder Lab</title>
<link>https://www.itwonderlab.com/</link>
<atom:link href="https://www.itwonderlab.com/feed.xml" rel="self" type="application/rss+xml"/>
<description>Step-by-step tutorials and best practices for cloud infrastructure automation: Infrastructure as Code with Terraform / OpenTofu, AWS, Kubernetes and Ansible.</description>
<language>en</language>
<lastBuildDate>Mon, 05 Oct 2026 11:18:06 GMT</lastBuildDate>
<image><url>https://www.itwonderlab.com/assets/icon-512.png</url><title>IT Wonder Lab</title><link>https://www.itwonderlab.com/</link></image>
<item>
<title>AWS with Terraform Tutorial: Terraform CI/CD (21)</title>
<link>https://www.itwonderlab.com/aws-terraform-tutorial-terraform-cicd/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/aws-terraform-tutorial-terraform-cicd/</guid>
<pubDate>Mon, 05 Oct 2026 10:50:00 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>Terraform &amp; OpenTofu Tutorials</category>
<description>Automate Terraform and OpenTofu on AWS with GitHub Actions: checks on every pull request, plan comments, approved applies and OIDC without keys.</description>
<content:encoded><![CDATA[<h2 id="how-to-automate-terraform-and-opentofu-with-cicd-on-aws">How to automate Terraform and OpenTofu with CI/CD on AWS</h2>
<p><strong>Using GitHub Actions to validate every change, show the plan in the pull request and apply it after an approval, authenticating to AWS with OpenID Connect instead of access keys.</strong></p>
<p>Welcome to our tutorial series about <a href="https://www.itwonderlab.com/tag/aws-terraform-tutorial/">Terraform or OpenTofu on AWS</a>. So far <code>tofu apply</code> has been run from a laptop. That does not work for a team: nobody knows who changed what, the result depends on the machine and long-lived AWS keys end up in many places. In a <strong>CI/CD pipeline</strong> the change goes through Git, a reviewer reads the plan, and a controlled system applies it.</p>
<figure></figure>
<h3 id="prerequisites">Prerequisites</h3>
<p>Read the previous sections of the tutorial, listed in the <a href="#series">series index</a> at the end of this page. This section needs:</p>
<ul>
<li>the code in a <strong>GitHub repository</strong> (the examples use <code>itwonderlab/aws-terraform-tutorial</code>, replace it with yours),</li>
<li>the <a href="https://www.itwonderlab.com/aws-terraform-tutorial-terraform-backends/">S3 backend</a>: a pipeline needs a shared state,</li>
<li>the checks of <a href="https://www.itwonderlab.com/aws-terraform-tutorial-terraform-tools/">Terraform Tools</a>.</li>
</ul>
<h3 id="the-workflow">The workflow</h3>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Event</th>
<th>Pipeline</th>
</tr>
</thead>
<tbody>
<tr>
<td>Pull request</td>
<td>Check the format, validate, lint and <strong>plan</strong>. The plan is posted as a comment for the reviewers</td>
</tr>
<tr>
<td>Merge to <code>main</code></td>
<td>Plan again, wait for the <strong>approval</strong> of an environment and <strong>apply</strong> exactly that plan</td>
</tr>
</tbody>
</table></div>
<p>The same code is tested and reviewed in the pull request, and nothing reaches AWS without a merge and an approval.</p>
<h3 id="step-1-remove-the-profile-from-the-code">Step 1: remove the profile from the code</h3>
<p>The code uses <code>profile = "ditwl_infradmin"</code> in the provider and in the backend. That profile does not exist in the pipeline, where the credentials come from the environment. Make it optional:</p>
<figure class="code"><figcaption>providers.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">variable</span> <span class="hljs-string">"aws_profile"</span> {
<span class="hljs-attr">  type</span>        = string
<span class="hljs-attr">  default</span>     = <span class="hljs-literal">null</span> <span class="hljs-comment"># in the pipeline the credentials come from the environment</span>
<span class="hljs-attr">  description</span> = <span class="hljs-string">"AWS CLI profile, only for local runs"</span>
}
<span class="hljs-keyword">
provider</span> <span class="hljs-string">"aws"</span> {
<span class="hljs-attr">  profile</span> = <span class="hljs-built_in">var</span>.aws_profile
}</code></pre></figure>
<p>For the backend, remove the <code>profile</code> argument from the <code>backend "s3"</code> block and use the <code>AWS_PROFILE</code> environment variable on your computer (<code>export AWS_PROFILE=ditwl_infradmin</code>), or a <code>backend.hcl</code> file as shown in the <a href="https://www.itwonderlab.com/terraform-backend/">Terraform Backends</a> section.</p>
<h3 id="step-2-let-github-authenticate-to-aws-without-keys">Step 2: let GitHub authenticate to AWS without keys</h3>
<p>Storing an access key as a GitHub secret is the easy option and a risk: it never expires and, if it leaks, it gives access to your account. <strong>OpenID Connect (OIDC)</strong> is the alternative: AWS trusts the identity token that GitHub issues for each job and returns <strong>temporary</strong> credentials for a role.</p>
<p>Create the provider and the roles with Terraform, in a small project of their own (like the backend bootstrap):</p>
<figure class="code"><figcaption>ci-bootstrap/main.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">locals</span> {
<span class="hljs-attr">  repo</span> = <span class="hljs-string">"itwonderlab/aws-terraform-tutorial"</span> <span class="hljs-comment"># organization/repository</span>
}

<span class="hljs-comment"># GitHub as an identity provider</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_iam_openid_connect_provider"</span> <span class="hljs-string">"github"</span> {
<span class="hljs-attr">  url</span>            = <span class="hljs-string">"https://token.actions.githubusercontent.com"</span>
<span class="hljs-attr">  client_id_list</span> = [<span class="hljs-string">"sts.amazonaws.com"</span>]
}

<span class="hljs-comment"># Role for the pull requests and the plan jobs: read only</span>
<span class="hljs-keyword">data</span> <span class="hljs-string">"aws_iam_policy_document"</span> <span class="hljs-string">"ditwl-gha-plan-assume"</span> {
  statement {
<span class="hljs-attr">    actions</span> = [<span class="hljs-string">"sts:AssumeRoleWithWebIdentity"</span>]

    principals {
<span class="hljs-attr">      type</span>        = <span class="hljs-string">"Federated"</span>
<span class="hljs-attr">      identifiers</span> = [aws_iam_openid_connect_provider.github.arn]
    }
    condition {
<span class="hljs-attr">      test</span>     = <span class="hljs-string">"StringEquals"</span>
<span class="hljs-keyword">      variable</span> = <span class="hljs-string">"token.actions.githubusercontent.com:aud"</span>
<span class="hljs-attr">      values</span>   = [<span class="hljs-string">"sts.amazonaws.com"</span>]
    }
    condition {
<span class="hljs-attr">      test</span>     = <span class="hljs-string">"StringLike"</span>
<span class="hljs-keyword">      variable</span> = <span class="hljs-string">"token.actions.githubusercontent.com:sub"</span>
<span class="hljs-attr">      values</span>   = [<span class="hljs-string">"repo:<span class="hljs-subst">${local.repo}</span>:pull_request"</span>, <span class="hljs-string">"repo:<span class="hljs-subst">${local.repo}</span>:ref:refs/heads/main"</span>]
    }
  }
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_iam_role"</span> <span class="hljs-string">"ditwl-role-gha-plan"</span> {
<span class="hljs-attr">  name</span>               = <span class="hljs-string">"ditwl-role-gha-plan"</span>
<span class="hljs-attr">  assume_role_policy</span> = <span class="hljs-built_in">data</span>.aws_iam_policy_document.ditwl-gha-plan-assume.json
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_iam_role_policy_attachment"</span> <span class="hljs-string">"ditwl-gha-plan-readonly"</span> {
<span class="hljs-attr">  role</span>       = aws_iam_role.ditwl-role-gha-plan.name
<span class="hljs-attr">  policy_arn</span> = <span class="hljs-string">"arn:aws:iam::aws:policy/ReadOnlyAccess"</span>
}

<span class="hljs-comment"># Role for the apply job: only the jobs of the "production" environment can use it</span>
<span class="hljs-keyword">data</span> <span class="hljs-string">"aws_iam_policy_document"</span> <span class="hljs-string">"ditwl-gha-apply-assume"</span> {
  statement {
<span class="hljs-attr">    actions</span> = [<span class="hljs-string">"sts:AssumeRoleWithWebIdentity"</span>]

    principals {
<span class="hljs-attr">      type</span>        = <span class="hljs-string">"Federated"</span>
<span class="hljs-attr">      identifiers</span> = [aws_iam_openid_connect_provider.github.arn]
    }
    condition {
<span class="hljs-attr">      test</span>     = <span class="hljs-string">"StringEquals"</span>
<span class="hljs-keyword">      variable</span> = <span class="hljs-string">"token.actions.githubusercontent.com:aud"</span>
<span class="hljs-attr">      values</span>   = [<span class="hljs-string">"sts.amazonaws.com"</span>]
    }
    condition {
<span class="hljs-attr">      test</span>     = <span class="hljs-string">"StringEquals"</span>
<span class="hljs-keyword">      variable</span> = <span class="hljs-string">"token.actions.githubusercontent.com:sub"</span>
<span class="hljs-attr">      values</span>   = [<span class="hljs-string">"repo:<span class="hljs-subst">${local.repo}</span>:environment:production"</span>]
    }
  }
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_iam_role"</span> <span class="hljs-string">"ditwl-role-gha-apply"</span> {
<span class="hljs-attr">  name</span>               = <span class="hljs-string">"ditwl-role-gha-apply"</span>
<span class="hljs-attr">  assume_role_policy</span> = <span class="hljs-built_in">data</span>.aws_iam_policy_document.ditwl-gha-apply-assume.json
}

<span class="hljs-comment"># Give the apply role only the permissions that the infrastructure needs.</span>
<span class="hljs-comment"># PowerUserAccess is used here to keep the example short, it is too broad for production.</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_iam_role_policy_attachment"</span> <span class="hljs-string">"ditwl-gha-apply-power"</span> {
<span class="hljs-attr">  role</span>       = aws_iam_role.ditwl-role-gha-apply.name
<span class="hljs-attr">  policy_arn</span> = <span class="hljs-string">"arn:aws:iam::aws:policy/PowerUserAccess"</span>
}
<span class="hljs-keyword">
output</span> <span class="hljs-string">"plan_role_arn"</span> {
<span class="hljs-attr">  value</span> = aws_iam_role.ditwl-role-gha-plan.arn
}
<span class="hljs-keyword">
output</span> <span class="hljs-string">"apply_role_arn"</span> {
<span class="hljs-attr">  value</span> = aws_iam_role.ditwl-role-gha-apply.arn
}</code></pre></figure>
<ul>
<li>The <code>sub</code> condition is the <strong>security boundary</strong>: it says which repository, and which event or environment, can assume the role. Never use <code>repo:*</code>.</li>
<li>Both roles also need access to the state bucket (the policy from the Terraform Backends section). The plan role can be read-only if the pipeline runs <code>tofu plan -lock=false</code>.</li>
<li>Recent versions of the AWS provider do not need a <code>thumbprint_list</code> for the GitHub provider. Older versions require it.</li>
<li>The <code>PowerUserAccess</code> policy does not include <a href="https://www.itwonderlab.com/aws-iam/">IAM</a>, which this very project needs for roles; use a custom least-privilege policy for your infrastructure.</li>
</ul>
<p>In GitHub, create the <strong><code>production</code> environment</strong> (Settings → Environments), add <strong>required reviewers</strong> and, as repository variables, <code>AWS_PLAN_ROLE_ARN</code> and <code>AWS_APPLY_ROLE_ARN</code> with the two outputs.</p>
<h3 id="step-3-the-github-actions-workflow">Step 3: the GitHub Actions workflow</h3>
<figure class="code"><figcaption>.github/workflows/infrastructure.yml</figcaption><pre><code class="hljs language-yaml"><span class="hljs-attr">name:</span> <span class="hljs-string">infrastructure</span>

<span class="hljs-attr">on:</span>
  <span class="hljs-attr">pull_request:</span>
    <span class="hljs-attr">paths:</span> [<span class="hljs-string">"**.tf"</span>, <span class="hljs-string">".github/workflows/infrastructure.yml"</span>]
  <span class="hljs-attr">push:</span>
    <span class="hljs-attr">branches:</span> [<span class="hljs-string">main</span>]
    <span class="hljs-attr">paths:</span> [<span class="hljs-string">"**.tf"</span>, <span class="hljs-string">".github/workflows/infrastructure.yml"</span>]

<span class="hljs-attr">permissions:</span>
  <span class="hljs-attr">contents:</span> <span class="hljs-string">read</span>
  <span class="hljs-attr">id-token:</span> <span class="hljs-string">write</span>       <span class="hljs-comment"># request the OIDC token</span>
  <span class="hljs-attr">pull-requests:</span> <span class="hljs-string">write</span>  <span class="hljs-comment"># comment the plan</span>

<span class="hljs-attr">env:</span>
  <span class="hljs-attr">TOFU_VERSION:</span> <span class="hljs-string">"1.10.0"</span> <span class="hljs-comment"># use the version of your team</span>
  <span class="hljs-attr">AWS_REGION:</span> <span class="hljs-string">us-east-1</span>

<span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">validate:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v4</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">opentofu/setup-opentofu@v2</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">tofu_version:</span> <span class="hljs-string">${{</span> <span class="hljs-string">env.TOFU_VERSION</span> <span class="hljs-string">}}</span>
          <span class="hljs-attr">tofu_wrapper:</span> <span class="hljs-literal">false</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">tofu</span> <span class="hljs-string">fmt</span> <span class="hljs-string">-recursive</span> <span class="hljs-string">-check</span> <span class="hljs-string">-diff</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">tofu</span> <span class="hljs-string">init</span> <span class="hljs-string">-backend=false</span> <span class="hljs-string">-input=false</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">tofu</span> <span class="hljs-string">validate</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">terraform-linters/setup-tflint@v4</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">tflint</span> <span class="hljs-string">--init</span> <span class="hljs-string">&amp;&amp;</span> <span class="hljs-string">tflint</span> <span class="hljs-string">--recursive</span>

  <span class="hljs-attr">plan:</span>
    <span class="hljs-attr">needs:</span> <span class="hljs-string">validate</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v4</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">opentofu/setup-opentofu@v2</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">tofu_version:</span> <span class="hljs-string">${{</span> <span class="hljs-string">env.TOFU_VERSION</span> <span class="hljs-string">}}</span>
          <span class="hljs-attr">tofu_wrapper:</span> <span class="hljs-literal">false</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">aws-actions/configure-aws-credentials@v6</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">role-to-assume:</span> <span class="hljs-string">${{</span> <span class="hljs-string">vars.AWS_PLAN_ROLE_ARN</span> <span class="hljs-string">}}</span>
          <span class="hljs-attr">aws-region:</span> <span class="hljs-string">${{</span> <span class="hljs-string">env.AWS_REGION</span> <span class="hljs-string">}}</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">tofu</span> <span class="hljs-string">init</span> <span class="hljs-string">-input=false</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">tofu</span> <span class="hljs-string">plan</span> <span class="hljs-string">-input=false</span> <span class="hljs-string">-lock=false</span> <span class="hljs-string">-no-color</span> <span class="hljs-string">-out=tfplan</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">tofu</span> <span class="hljs-string">show</span> <span class="hljs-string">-no-color</span> <span class="hljs-string">tfplan</span> <span class="hljs-string">&gt;</span> <span class="hljs-string">plan.txt</span>

      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Comment</span> <span class="hljs-string">the</span> <span class="hljs-string">plan</span> <span class="hljs-string">in</span> <span class="hljs-string">the</span> <span class="hljs-string">pull</span> <span class="hljs-string">request</span>
        <span class="hljs-attr">if:</span> <span class="hljs-string">github.event_name</span> <span class="hljs-string">==</span> <span class="hljs-string">'pull_request'</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/github-script@v7</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">script:</span> <span class="hljs-string">|
            const fs = require("fs");
            const plan = fs.readFileSync("plan.txt", "utf8").slice(0, 60000);
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: "### OpenTofu plan\n\n```\n" + plan + "\n```",
            });
</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Save</span> <span class="hljs-string">the</span> <span class="hljs-string">plan</span> <span class="hljs-string">to</span> <span class="hljs-string">apply</span> <span class="hljs-string">it</span>
        <span class="hljs-attr">if:</span> <span class="hljs-string">github.event_name</span> <span class="hljs-string">==</span> <span class="hljs-string">'push'</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/upload-artifact@v4</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">name:</span> <span class="hljs-string">tfplan</span>
          <span class="hljs-attr">path:</span> <span class="hljs-string">tfplan</span>
          <span class="hljs-attr">retention-days:</span> <span class="hljs-number">1</span>

  <span class="hljs-attr">apply:</span>
    <span class="hljs-attr">if:</span> <span class="hljs-string">github.event_name</span> <span class="hljs-string">==</span> <span class="hljs-string">'push'</span>
    <span class="hljs-attr">needs:</span> <span class="hljs-string">plan</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">environment:</span> <span class="hljs-string">production</span> <span class="hljs-comment"># waits for the approval of the required reviewers</span>
    <span class="hljs-attr">concurrency:</span> <span class="hljs-string">apply-production</span> <span class="hljs-comment"># one apply at a time</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v4</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">opentofu/setup-opentofu@v2</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">tofu_version:</span> <span class="hljs-string">${{</span> <span class="hljs-string">env.TOFU_VERSION</span> <span class="hljs-string">}}</span>
          <span class="hljs-attr">tofu_wrapper:</span> <span class="hljs-literal">false</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">aws-actions/configure-aws-credentials@v6</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">role-to-assume:</span> <span class="hljs-string">${{</span> <span class="hljs-string">vars.AWS_APPLY_ROLE_ARN</span> <span class="hljs-string">}}</span>
          <span class="hljs-attr">aws-region:</span> <span class="hljs-string">${{</span> <span class="hljs-string">env.AWS_REGION</span> <span class="hljs-string">}}</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/download-artifact@v4</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">name:</span> <span class="hljs-string">tfplan</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">tofu</span> <span class="hljs-string">init</span> <span class="hljs-string">-input=false</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">tofu</span> <span class="hljs-string">apply</span> <span class="hljs-string">-input=false</span> <span class="hljs-string">tfplan</span></code></pre></figure>
<p>How it works:</p>
<ul>
<li><code>validate</code> runs the checks of the previous section in every pull request and push.</li>
<li><code>plan</code> assumes the <strong>read-only</strong> role and posts the plan, so the reviewers see exactly what would change in AWS.</li>
<li><code>apply</code> runs only after the merge, uses the <strong>environment</strong> <code>production</code> (the approval gate and the only identity allowed to assume the apply role) and applies <strong>the saved plan</strong>, not a new one. If the state changed in the meantime, <a href="https://www.itwonderlab.com/opentofu/">OpenTofu</a> rejects the stale plan and the pipeline fails safely.</li>
<li>The plan file can contain sensitive values. It is kept for one day and only inside the repository's artifacts.</li>
</ul>
<aside class="note note-warning" role="note"><p class="note-title">Warning</p>
<p>Pull requests from forks do not get the OIDC token or the repository variables. Do not change that setting to run plans for outside contributors: a plan runs provider code from the pull request with access to your AWS account.</p>
</aside>
<h3 id="protect-the-main-branch">Protect the main branch</h3>
<p>Pipelines are only as safe as the rules around them. In GitHub, protect <code>main</code> (Settings → Branches): require a pull request, require the <code>validate</code> and <code>plan</code> checks to pass, require at least one approval, and block direct pushes.</p>
<h3 id="detect-drift">Detect drift</h3>
<p>Infrastructure changes outside of Terraform (a person editing something in the AWS console) are called <strong>drift</strong>. A scheduled job finds it by running a plan and failing when it is not empty:</p>
<figure class="code"><figcaption>.github/workflows/drift.yml</figcaption><pre><code class="hljs language-yaml"><span class="hljs-attr">name:</span> <span class="hljs-string">drift</span>
<span class="hljs-attr">on:</span>
  <span class="hljs-attr">schedule:</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">cron:</span> <span class="hljs-string">"0 6 * * 1-5"</span> <span class="hljs-comment"># every weekday at 06:00 UTC</span>
<span class="hljs-attr">permissions:</span>
  <span class="hljs-attr">contents:</span> <span class="hljs-string">read</span>
  <span class="hljs-attr">id-token:</span> <span class="hljs-string">write</span>
<span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">drift:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v4</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">opentofu/setup-opentofu@v2</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">tofu_wrapper:</span> <span class="hljs-literal">false</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">aws-actions/configure-aws-credentials@v6</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">role-to-assume:</span> <span class="hljs-string">${{</span> <span class="hljs-string">vars.AWS_PLAN_ROLE_ARN</span> <span class="hljs-string">}}</span>
          <span class="hljs-attr">aws-region:</span> <span class="hljs-string">us-east-1</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">tofu</span> <span class="hljs-string">init</span> <span class="hljs-string">-input=false</span>
      <span class="hljs-comment"># exit code 0 = no changes, 2 = changes (drift), 1 = error</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">tofu</span> <span class="hljs-string">plan</span> <span class="hljs-string">-input=false</span> <span class="hljs-string">-lock=false</span> <span class="hljs-string">-detailed-exitcode</span></code></pre></figure>
<p>The role must allow <code>main</code> (or add the <code>schedule</code> event to the subject conditions of the plan role).</p>
<h3 id="other-options">Other options</h3>
<ul>
<li><strong>GitLab CI/CD</strong>: the same steps with <code>id_tokens</code> for OIDC and the GitLab <a href="https://www.itwonderlab.com/terraform-state/">Terraform state</a> or the <a href="https://www.itwonderlab.com/aws-s3/">S3</a> backend.</li>
<li><strong>Atlantis</strong>, <strong>Spacelift</strong>, <strong>env0</strong>, <strong>HCP Terraform</strong>: tools specialized in running plans and applies from pull requests, with approval policies and a UI. They replace the workflow of this section, not the S3 backend or the checks.</li>
<li><strong>AWS CodePipeline / CodeBuild</strong>: if you prefer to run everything inside AWS.</li>
</ul>
<h3 id="cost">Cost</h3>
<p>GitHub Actions includes free minutes for public repositories and a monthly allowance for private ones. These jobs take a few minutes. OIDC, IAM roles and the S3 backend have no additional cost.</p>
<h3 id="common-questions-about-terraform-cicd">Common Questions About Terraform CI/CD</h3>
<h4 id="why-apply-the-saved-plan-instead-of-running-tofu-apply-again">Why apply the saved plan instead of running <code>tofu apply</code> again?</h4>
<p>Because it guarantees that what is applied is what was planned and approved. A new apply could include changes that appeared after the review.</p>
<h4 id="what-if-two-pipelines-run-at-the-same-time">What if two pipelines run at the same time?</h4>
<p>The <code>concurrency</code> group serializes the applies and the state lock (see <a href="https://www.itwonderlab.com/aws-terraform-tutorial-terraform-backends/">Terraform Backends</a>) prevents two processes from writing the state at once.</p>
<h4 id="where-do-i-keep-secrets-such-as-database-passwords">Where do I keep secrets such as database passwords?</h4>
<p>Not in the repository or in the plan comments. Use <code>manage_master_user_password = true</code> (as in <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-rds/">AWS RDS</a>) or AWS <a href="https://www.itwonderlab.com/aws-secrets-manager/">Secrets Manager</a>, and mark variables as <code>sensitive</code>.</p>
<h4 id="can-i-use-access-keys-instead-of-oidc">Can I use access keys instead of OIDC?</h4>
<p>You can, with repository secrets, but they are long-lived credentials that must be rotated and can leak. OIDC credentials last about an hour and are bound to the repository and the environment.</p>
<h3 id="congratulations">Congratulations</h3>
<p>You have completed <strong>AWS with Terraform: The Essential Guide</strong>: from the basics of Terraform and AWS, through a network, servers, a database, DNS, <a href="https://www.itwonderlab.com/aws-auto-scaling/">auto scaling</a> and <a href="https://www.itwonderlab.com/aws-elastic-load-balancing/">load balancers</a>, to modules, remote state, tooling and a CI/CD pipeline. Browse the <a href="#series">series index</a> to review any section, or read about <a href="https://www.itwonderlab.com/terraform-aws-ansible/">using Terraform, AWS and Ansible together</a>.</p>]]></content:encoded>
</item>
<item>
<title>AWS with Terraform Tutorial: Terraform Tools (20)</title>
<link>https://www.itwonderlab.com/aws-terraform-tutorial-terraform-tools/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/aws-terraform-tutorial-terraform-tools/</guid>
<pubDate>Mon, 05 Oct 2026 10:40:00 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>Terraform &amp; OpenTofu Tutorials</category>
<description>The essential tools for Terraform and OpenTofu: fmt, validate, TFLint, Trivy, Checkov, terraform-docs, Infracost and pre-commit hooks.</description>
<content:encoded><![CDATA[<h2 id="essential-terraform-and-opentofu-tools">Essential Terraform and OpenTofu tools</h2>
<p><strong>Format, validate, lint, scan, document and estimate the cost of your Terraform and <a href="https://www.itwonderlab.com/opentofu/">OpenTofu</a> code before it reaches AWS.</strong></p>
<p>Welcome to our tutorial series about <a href="https://www.itwonderlab.com/tag/aws-terraform-tutorial/">Terraform or OpenTofu on AWS</a>. Infrastructure code deserves the same care as application code: it should be formatted consistently, checked automatically and reviewed. The tools in this section find most problems in seconds, <strong>before</strong> <code>tofu apply</code> creates (and bills) anything. They work the same with Terraform and OpenTofu; the examples use <code>tofu</code>.</p>
<figure></figure>
<h3 id="prerequisites">Prerequisites</h3>
<p>Read the previous sections of the tutorial, listed in the <a href="#series">series index</a> at the end of this page. The examples use the project with modules from <a href="https://www.itwonderlab.com/aws-terraform-tutorial-terraform-modules/">Terraform Modules</a>.</p>
<h3 id="the-built-in-commands">The built-in commands</h3>
<p>OpenTofu and Terraform already include the first line of defense.</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Command</th>
<th>What it does</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>tofu fmt -recursive</code></td>
<td>Rewrites the files with the canonical style. Use <code>-check -diff</code> in CI to fail when a file is not formatted</td>
</tr>
<tr>
<td><code>tofu validate</code></td>
<td>Checks the syntax and the consistency of the configuration (types, references, required arguments). It does not call AWS</td>
</tr>
<tr>
<td><code>tofu plan</code></td>
<td>Shows what would change. Save it with <code>-out=tfplan</code> and read it with <code>tofu show tfplan</code></td>
</tr>
<tr>
<td><code>tofu console</code></td>
<td>An interactive prompt to try expressions and functions against the real state</td>
</tr>
<tr>
<td><code>tofu graph</code></td>
<td>Prints the dependency graph in DOT format</td>
</tr>
<tr>
<td><code>tofu state list</code>, <code>tofu state show &lt;address&gt;</code></td>
<td>Inspect what is in the state</td>
</tr>
</tbody>
</table></div>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu <span class="hljs-built_in">fmt</span> -recursive -check -diff</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu init -backend=<span class="hljs-literal">false</span></span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu validate</span>
Success! The configuration is valid.</code></pre>
<p><code>tofu init -backend=false</code> downloads providers and modules without touching the remote state, which is what a validation job needs.</p>
<p>Try an expression with <code>tofu console</code>:</p>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu console</span>
<span class="hljs-meta prompt_">&gt; </span><span class="language-bash">cidrsubnet(<span class="hljs-string">"172.21.0.0/19"</span>, 4, 1)</span>
"172.21.2.0/23"
<span class="hljs-meta prompt_">&gt; </span><span class="language-bash">module.network.subnet_ids[<span class="hljs-string">"ditwl-sn-za-pro-pub-00"</span>]</span>
"subnet-09da811e23c212363"</code></pre>
<p>To see the dependencies as an image (needs <a href="https://graphviz.org/">Graphviz</a>):</p>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu graph | dot -Tsvg &gt; graph.svg</span></code></pre>
<h3 id="tflint-finds-mistakes-that-validate-cannot-see">TFLint: finds mistakes that <code>validate</code> cannot see</h3>
<p><a href="https://github.com/terraform-linters/tflint">TFLint</a> is a linter. With the AWS ruleset it detects invalid instance types, deprecated arguments, unused variables or missing required versions.</p>
<figure class="code"><figcaption>.tflint.hcl</figcaption><pre><code class="hljs language-hcl">plugin <span class="hljs-string">"terraform"</span> {
<span class="hljs-attr">  enabled</span> = <span class="hljs-literal">true</span>
<span class="hljs-attr">  preset</span>  = <span class="hljs-string">"recommended"</span>
}

plugin <span class="hljs-string">"aws"</span> {
<span class="hljs-attr">  enabled</span> = <span class="hljs-literal">true</span>
<span class="hljs-attr">  version</span> = <span class="hljs-string">"0.40.0"</span> <span class="hljs-comment"># replace with the latest release of tflint-ruleset-aws</span>
<span class="hljs-attr">  source</span>  = <span class="hljs-string">"github.com/terraform-linters/tflint-ruleset-aws"</span>
}</code></pre></figure>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">tflint --init</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">tflint --recursive</span></code></pre>
<h3 id="security-scanners-trivy-and-checkov">Security scanners: Trivy and Checkov</h3>
<p>Misconfigurations are the most common cause of cloud incidents: an open <a href="https://www.itwonderlab.com/aws-security-groups/">security group</a>, a public bucket, an unencrypted volume. Static scanners read the code and report them before deployment.</p>
<ul>
<li><strong><a href="https://trivy.dev/">Trivy</a></strong> scans Terraform and OpenTofu code (it includes the rules of the former tfsec) and also container images and dependencies.</li>
<li><strong><a href="https://www.checkov.io/">Checkov</a></strong> has thousands of policies for AWS, Azure and GCP and supports custom policies.</li>
</ul>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">trivy config .</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">checkov -d .</span></code></pre>
<p>For example, both report the rule that the tutorial broke on purpose in <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-security-groups/">AWS Security Groups</a>: <a href="https://www.itwonderlab.com/ssh/">SSH</a> (port 22) open to <code>0.0.0.0/0</code>. When a finding is accepted, document the exception in the code, for example <code>#trivy:ignore:&lt;rule-id&gt;</code> or <code>#checkov:skip=&lt;id&gt;:reason</code>, so it is visible in the review.</p>
<h3 id="terraform-docs-documentation-that-does-not-get-old">terraform-docs: documentation that does not get old</h3>
<p><a href="https://terraform-docs.io/">terraform-docs</a> generates the inputs, outputs and requirements of a module from the code. Put these markers in the README of the module:</p>
<pre><code>&lt;!-- BEGIN_TF_DOCS --&gt;
&lt;!-- END_TF_DOCS --&gt;</code></pre>
<p>and run:</p>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform-docs markdown table --output-file README.md --output-mode inject modules/network</span></code></pre>
<p>The tables between the markers are replaced and the rest of the file is kept.</p>
<h3 id="infracost-the-price-before-the-apply">Infracost: the price before the apply</h3>
<p><a href="https://www.infracost.io/">Infracost</a> estimates the monthly cost of the infrastructure from the code and, in a pull request, shows how much each change adds or saves.</p>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">infracost breakdown --path .</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">infracost diff --path . --compare-to infracost-base.json</span></code></pre>
<p>It would have shown, for example, the difference between two and three <a href="https://www.itwonderlab.com/aws-nat-gateway/">NAT Gateways</a> or the cost of the <a href="https://www.itwonderlab.com/aws-elastic-load-balancing/">load balancer</a> from <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-load-balancers/">AWS Load Balancers</a>.</p>
<h3 id="pre-commit-run-everything-before-every-commit">Pre-commit: run everything before every commit</h3>
<p>The <a href="https://pre-commit.com/">pre-commit</a> framework runs the tools automatically when you commit. The <a href="https://github.com/antonbabenko/pre-commit-terraform">pre-commit-terraform</a> collection has a hook for each tool above. By default it looks for the <code>terraform</code> binary: the <code>--tf-path</code> argument selects OpenTofu.</p>
<figure class="code"><figcaption>.pre-commit-config.yaml</figcaption><pre><code class="hljs language-yaml"><span class="hljs-attr">repos:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">repo:</span> <span class="hljs-string">https://github.com/antonbabenko/pre-commit-terraform</span>
    <span class="hljs-attr">rev:</span> <span class="hljs-string">v1.99.0</span> <span class="hljs-comment"># replace with the latest release</span>
    <span class="hljs-attr">hooks:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">id:</span> <span class="hljs-string">terraform_fmt</span>
        <span class="hljs-attr">args:</span> [<span class="hljs-string">--hook-config=--tf-path=tofu</span>]
      <span class="hljs-bullet">-</span> <span class="hljs-attr">id:</span> <span class="hljs-string">terraform_validate</span>
        <span class="hljs-attr">args:</span> [<span class="hljs-string">--hook-config=--tf-path=tofu</span>]
      <span class="hljs-bullet">-</span> <span class="hljs-attr">id:</span> <span class="hljs-string">terraform_tflint</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">id:</span> <span class="hljs-string">terraform_trivy</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">id:</span> <span class="hljs-string">terraform_docs</span></code></pre></figure>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">pip install pre-commit</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">pre-commit install</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">pre-commit run --all-files</span></code></pre>
<p>The same checks run again in the pipeline of the next section, because a local hook can always be skipped.</p>
<h3 id="other-useful-tools">Other useful tools</h3>
<ul>
<li>
<p><strong>Version managers</strong>: <a href="https://tofuutils.github.io/tenv/">tenv</a> installs and switches between OpenTofu, Terraform and Terragrunt versions, using the version in the <code>.opentofu-version</code> or <code>.terraform-version</code> file of the project.</p>
</li>
<li>
<p><strong>Import existing resources</strong>: an <code>import</code> block adopts a resource created by hand into the state, and <code>tofu plan</code> shows the result before anything is saved:</p>
<pre><code class="hljs language-hcl"><span class="hljs-keyword">import</span> {
<span class="hljs-attr">  to</span> = aws_s3_bucket.logs
<span class="hljs-attr">  id</span> = <span class="hljs-string">"ditwl-logs-bucket"</span>
}</code></pre>
</li>
<li>
<p><strong>State surgery</strong>: <code>tofu state mv</code>, <code>tofu state rm</code> and <code>moved</code> / <code>removed</code> blocks, to reorganize the code without destroying resources (see <a href="https://www.itwonderlab.com/aws-terraform-tutorial-terraform-modules/">Terraform Modules</a>).</p>
</li>
<li>
<p><strong>Debugging</strong>: <code>TF_LOG=debug tofu plan</code> prints the API calls and the internal decisions.</p>
</li>
<li>
<p><strong><a href="https://terragrunt.gruntwork.io/">Terragrunt</a></strong>: a wrapper to keep configurations DRY when there are many environments and states.</p>
</li>
<li>
<p><strong>Editor support</strong>: the OpenTofu and Terraform language servers give completion, hover documentation and diagnostics in VS Code, IntelliJ and Neovim.</p>
</li>
</ul>
<h3 id="a-recommended-order">A recommended order</h3>
<ol>
<li><code>tofu fmt</code> and <code>tofu validate</code>: seconds, always.</li>
<li>TFLint: seconds, catches provider-specific mistakes.</li>
<li>Trivy or Checkov: seconds to a minute, security.</li>
<li><code>tofu plan</code>: needs credentials, shows the real change.</li>
<li>Infracost: cost, in the pull request.</li>
</ol>
<h3 id="common-questions-about-terraform-tools">Common Questions About Terraform Tools</h3>
<h4 id="do-these-tools-work-with-opentofu">Do these tools work with OpenTofu?</h4>
<p>Yes. They read the same <a href="https://www.itwonderlab.com/hcl/">HCL</a> language. Where a tool expects the <code>terraform</code> binary, there is an option to use <code>tofu</code> (as in the <code>--tf-path</code> argument above).</p>
<h4 id="which-security-scanner-should-i-choose-trivy-or-checkov">Which security scanner should I choose: Trivy or Checkov?</h4>
<p>Either one. Trivy is one tool for code, containers and dependencies; Checkov has more cloud-specific policies and custom rules in Python or YAML. Many teams run one of them and review the report of the other from time to time.</p>
<h4 id="should-the-checks-fail-the-build">Should the checks fail the build?</h4>
<p>Formatting, validation and linting should. For security findings start with warnings, fix the existing ones and then make new high-severity findings fail the build.</p>
<h3 id="next-steps">Next Steps</h3>
<p>The code is formatted, checked and documented on every commit. The last section runs these checks, the plan and the apply automatically in a pipeline: <a href="https://www.itwonderlab.com/aws-terraform-tutorial-terraform-cicd/">Terraform CI/CD</a>.</p>]]></content:encoded>
</item>
<item>
<title>AWS with Terraform Tutorial: Terraform Backends (19)</title>
<link>https://www.itwonderlab.com/aws-terraform-tutorial-terraform-backends/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/aws-terraform-tutorial-terraform-backends/</guid>
<pubDate>Mon, 05 Oct 2026 10:30:00 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>Terraform &amp; OpenTofu Tutorials</category>
<description>Store the Terraform or OpenTofu state in an encrypted, versioned S3 bucket with native state locking, and migrate the local state to it.</description>
<content:encoded><![CDATA[<h2 id="how-to-configure-a-terraform-backend-in-aws-s3">How to configure a Terraform backend in AWS S3</h2>
<p><strong>Using the Terraform and <a href="https://www.itwonderlab.com/opentofu/">OpenTofu</a> <code>s3</code> backend to keep the state in a versioned, encrypted bucket that a whole team (and a CI/CD pipeline) can share safely.</strong></p>
<p>Welcome to our tutorial series about <a href="https://www.itwonderlab.com/tag/aws-terraform-tutorial/">Terraform or OpenTofu on AWS</a>. Terraform remembers what it created in a <strong><a href="https://www.itwonderlab.com/terraform-state/">state file</a></strong>, <code>terraform.tfstate</code>, which by default is saved next to the code. That is fine for learning, but it becomes a problem as soon as the infrastructure is important:</p>
<figure></figure>
<ul>
<li>if the file is lost, Terraform no longer knows its resources,</li>
<li>two people (or a person and a pipeline) running <code>tofu apply</code> at the same time can corrupt it,</li>
<li>the state contains <strong>sensitive data</strong> (database passwords, keys) in plain text and must not be committed to Git.</li>
</ul>
<p>A <strong>backend</strong> defines where the state is stored. This section moves it to Amazon <a href="https://www.itwonderlab.com/aws-s3/">S3</a>.</p>
<h3 id="prerequisites">Prerequisites</h3>
<p>Read the previous sections of the tutorial, listed in the <a href="#series">series index</a> at the end of this page. You need an AWS profile (<code>ditwl_infradmin</code>, see <a href="https://www.itwonderlab.com/aws-terraform-tutorial-terraform-aws-provider/">Terraform AWS Provider</a>) with permissions to create an S3 bucket.</p>
<h3 id="terraform-backends">Terraform backends</h3>
<p>The backend is configured in the <code>terraform</code> block. The most used ones are:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Backend</th>
<th>State stored in</th>
<th>Locking</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>local</code> (default)</td>
<td>A file in the project directory</td>
<td>Local file lock</td>
</tr>
<tr>
<td><code>s3</code></td>
<td>An Amazon S3 bucket</td>
<td>S3 lock file (or <a href="https://www.itwonderlab.com/aws-dynamodb/">DynamoDB</a>)</td>
</tr>
<tr>
<td><code>gcs</code> / <code>azurerm</code></td>
<td>Google Cloud Storage / Azure Blob Storage</td>
<td>Yes</td>
</tr>
<tr>
<td><code>http</code>, <code>pg</code>, <code>kubernetes</code></td>
<td>A REST endpoint (for example GitLab), PostgreSQL, a Kubernetes secret</td>
<td>Depends on the backend</td>
</tr>
<tr>
<td><code>cloud</code></td>
<td>HCP Terraform (Terraform Cloud)</td>
<td>Yes</td>
</tr>
</tbody>
</table></div>
<p>The <strong>S3 backend</strong> is the most common choice on AWS: it is cheap, durable (99.999999999%), supports versioning and encryption, and uses the same <a href="https://www.itwonderlab.com/aws-iam/">IAM</a> permissions as the rest of the infrastructure.</p>
<h3 id="step-1-create-the-bucket">Step 1: create the bucket</h3>
<p>There is a chicken-and-egg problem: the bucket that stores the state must exist before Terraform can use it, so it is created by a <strong>separate, small project</strong> (<code>backend-bootstrap/</code>) that keeps its own state locally. It is run once.</p>
<figure class="code"><figcaption>backend-bootstrap/main.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">terraform</span> {
<span class="hljs-attr">  required_version</span> = <span class="hljs-string">"&gt; 1.5"</span>
<span class="hljs-keyword">
  required_providers</span> {
<span class="hljs-attr">    aws</span> = {
<span class="hljs-attr">      source</span>  = <span class="hljs-string">"hashicorp/aws"</span>
<span class="hljs-attr">      version</span> = <span class="hljs-string">"~&gt; 5.0"</span>
    }
  }
}
<span class="hljs-keyword">
provider</span> <span class="hljs-string">"aws"</span> {
<span class="hljs-attr">  profile</span> = <span class="hljs-string">"ditwl_infradmin"</span>
}
<span class="hljs-keyword">
data</span> <span class="hljs-string">"aws_caller_identity"</span> <span class="hljs-string">"current"</span> {}
<span class="hljs-keyword">
locals</span> {
  <span class="hljs-comment"># Bucket names are global: the account number makes it unique</span>
<span class="hljs-attr">  bucket</span> = <span class="hljs-string">"ditwl-tfstate-<span class="hljs-subst">${data.aws_caller_identity.current.account_id}</span>"</span>
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_s3_bucket"</span> <span class="hljs-string">"tfstate"</span> {
<span class="hljs-attr">  bucket</span> = <span class="hljs-built_in">local</span>.bucket

  <span class="hljs-comment"># Deleting this bucket would delete the state of all the infrastructure</span>
<span class="hljs-keyword">  lifecycle</span> {
<span class="hljs-attr">    prevent_destroy</span> = <span class="hljs-literal">true</span>
  }
<span class="hljs-attr">
  tags</span> = {
<span class="hljs-attr">    Name</span> = <span class="hljs-built_in">local</span>.bucket
  }
}

<span class="hljs-comment"># Keep every version of the state: it allows going back after a mistake</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_s3_bucket_versioning"</span> <span class="hljs-string">"tfstate"</span> {
<span class="hljs-attr">  bucket</span> = aws_s3_bucket.tfstate.id
  versioning_configuration {
<span class="hljs-attr">    status</span> = <span class="hljs-string">"Enabled"</span>
  }
}

<span class="hljs-comment"># Encrypt the state at rest</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_s3_bucket_server_side_encryption_configuration"</span> <span class="hljs-string">"tfstate"</span> {
<span class="hljs-attr">  bucket</span> = aws_s3_bucket.tfstate.id
  rule {
    apply_server_side_encryption_by_default {
<span class="hljs-attr">      sse_algorithm</span> = <span class="hljs-string">"AES256"</span>
    }
  }
}

<span class="hljs-comment"># The state is never public</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_s3_bucket_public_access_block"</span> <span class="hljs-string">"tfstate"</span> {
<span class="hljs-attr">  bucket</span>                  = aws_s3_bucket.tfstate.id
<span class="hljs-attr">  block_public_acls</span>       = <span class="hljs-literal">true</span>
<span class="hljs-attr">  block_public_policy</span>     = <span class="hljs-literal">true</span>
<span class="hljs-attr">  ignore_public_acls</span>      = <span class="hljs-literal">true</span>
<span class="hljs-attr">  restrict_public_buckets</span> = <span class="hljs-literal">true</span>
}

<span class="hljs-comment"># Delete old versions after 90 days</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_s3_bucket_lifecycle_configuration"</span> <span class="hljs-string">"tfstate"</span> {
<span class="hljs-attr">  bucket</span> = aws_s3_bucket.tfstate.id

  rule {
<span class="hljs-attr">    id</span>     = <span class="hljs-string">"expire-old-versions"</span>
<span class="hljs-attr">    status</span> = <span class="hljs-string">"Enabled"</span>
    filter {}

    noncurrent_version_expiration {
<span class="hljs-attr">      noncurrent_days</span> = <span class="hljs-number">90</span>
    }
  }
}

<span class="hljs-comment"># Only encrypted connections (HTTPS)</span>
<span class="hljs-keyword">data</span> <span class="hljs-string">"aws_iam_policy_document"</span> <span class="hljs-string">"tfstate-tls"</span> {
  statement {
<span class="hljs-attr">    sid</span>       = <span class="hljs-string">"DenyInsecureTransport"</span>
<span class="hljs-attr">    effect</span>    = <span class="hljs-string">"Deny"</span>
<span class="hljs-attr">    actions</span>   = [<span class="hljs-string">"s3:*"</span>]
<span class="hljs-attr">    resources</span> = [aws_s3_bucket.tfstate.arn, <span class="hljs-string">"<span class="hljs-subst">${aws_s3_bucket.tfstate.arn}</span>/*"</span>]

    principals {
<span class="hljs-attr">      type</span>        = <span class="hljs-string">"*"</span>
<span class="hljs-attr">      identifiers</span> = [<span class="hljs-string">"*"</span>]
    }

    condition {
<span class="hljs-attr">      test</span>     = <span class="hljs-string">"Bool"</span>
<span class="hljs-keyword">      variable</span> = <span class="hljs-string">"aws:SecureTransport"</span>
<span class="hljs-attr">      values</span>   = [<span class="hljs-string">"false"</span>]
    }
  }
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_s3_bucket_policy"</span> <span class="hljs-string">"tfstate"</span> {
<span class="hljs-attr">  bucket</span> = aws_s3_bucket.tfstate.id
<span class="hljs-attr">  policy</span> = <span class="hljs-built_in">data</span>.aws_iam_policy_document.tfstate-tls.json
<span class="hljs-keyword">
  depends_on</span> = [aws_s3_bucket_public_access_block.tfstate]
}
<span class="hljs-keyword">
output</span> <span class="hljs-string">"bucket"</span> {
<span class="hljs-attr">  value</span> = aws_s3_bucket.tfstate.bucket
}</code></pre></figure>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash"><span class="hljs-built_in">cd</span> backend-bootstrap</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu init</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu apply</span>
...
Outputs:

bucket = "ditwl-tfstate-123456789012"</code></pre>
<h3 id="step-2-configure-the-backend">Step 2: configure the backend</h3>
<p>In the main project add the <code>backend</code> block (inside the same <code>terraform</code> block as <code>required_providers</code>):</p>
<figure class="code"><figcaption>providers.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">terraform</span> {
<span class="hljs-attr">  required_version</span> = <span class="hljs-string">"&gt; 1.5"</span>
<span class="hljs-keyword">
  required_providers</span> {
<span class="hljs-attr">    aws</span> = {
<span class="hljs-attr">      source</span>  = <span class="hljs-string">"hashicorp/aws"</span>
<span class="hljs-attr">      version</span> = <span class="hljs-string">"~&gt; 5.0"</span>
    }
  }
<span class="hljs-keyword">
  backend</span> <span class="hljs-string">"s3"</span> {
<span class="hljs-attr">    bucket</span>       = <span class="hljs-string">"ditwl-tfstate-123456789012"</span>
<span class="hljs-attr">    key</span>          = <span class="hljs-string">"aws-tutorial/pro/terraform.tfstate"</span>
<span class="hljs-attr">    region</span>       = <span class="hljs-string">"us-east-1"</span>
<span class="hljs-attr">    profile</span>      = <span class="hljs-string">"ditwl_infradmin"</span>
<span class="hljs-attr">    encrypt</span>      = <span class="hljs-literal">true</span>
<span class="hljs-attr">    use_lockfile</span> = <span class="hljs-literal">true</span>
  }
}</code></pre></figure>
<ul>
<li><code>key</code> is the path of the state file inside the bucket. Use one key per project and environment (<code>aws-tutorial/pro/...</code>, <code>aws-tutorial/dev/...</code>) to keep the states small and independent.</li>
<li><code>encrypt = true</code> asks S3 to encrypt the object.</li>
<li><code>use_lockfile = true</code> enables <strong>native state locking</strong>: the backend creates a <code>terraform.tfstate.tflock</code> object next to the state while a command that changes it runs, and S3 conditional writes guarantee that only one process gets it. No other service is needed.</li>
</ul>
<aside class="note note-note" role="note"><p class="note-title">Note</p>
<p>Native S3 locking is available in recent versions of OpenTofu and Terraform. Older versions, and teams that prefer it, use a DynamoDB table instead. See "Locking with DynamoDB" below. Check the documentation of your version.</p>
</aside>
<h3 id="step-3-migrate-the-state">Step 3: migrate the state</h3>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu init -migrate-state</span>
Initializing the backend...
Do you want to copy existing state to the new backend?
  Pre-existing state was found while migrating the previous "local" backend to the
  newly configured "s3" backend. ...

  Enter a value: yes

Successfully configured the backend "s3"!</code></pre>
<p>The state is now in S3. Verify it and keep the local copy until you are sure that everything works, then delete it (and make sure that <code>*.tfstate*</code> is in <code>.gitignore</code>):</p>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">aws s3 <span class="hljs-built_in">ls</span> s3://ditwl-tfstate-123456789012/aws-tutorial/pro/ --profile ditwl_infradmin</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu plan</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu state list</span></code></pre>
<h3 id="test-the-state-locking">Test the state locking</h3>
<p>Run <code>tofu apply</code> in two terminals at the same time. The second one waits and fails with a message like this one, which shows who holds the lock:</p>
<pre><code>Error: Error acquiring the state lock

Lock Info:
  ID:        0d1b2c3d-...
  Operation: OperationTypeApply
  Who:       jruiz@laptop
  Created:   2026-10-05 10:35:12 UTC</code></pre>
<p>If a process dies and leaves a stale lock, release it with <code>tofu force-unlock &lt;ID&gt;</code>, only after being sure that nobody is running.</p>
<h3 id="who-can-access-the-state">Who can access the state</h3>
<p>The state may contain secrets, so access is controlled with IAM. This policy lets a user or pipeline use only the state of this project:</p>
<pre><code class="hljs language-json"><span class="hljs-punctuation">{</span>
  <span class="hljs-attr">"Version"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"2012-10-17"</span><span class="hljs-punctuation">,</span>
  <span class="hljs-attr">"Statement"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span>
    <span class="hljs-punctuation">{</span>
      <span class="hljs-attr">"Effect"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"Allow"</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">"Action"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"s3:ListBucket"</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">"Resource"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"arn:aws:s3:::ditwl-tfstate-123456789012"</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">"Condition"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span> <span class="hljs-attr">"StringLike"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span> <span class="hljs-attr">"s3:prefix"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><span class="hljs-string">"aws-tutorial/pro/*"</span><span class="hljs-punctuation">]</span> <span class="hljs-punctuation">}</span> <span class="hljs-punctuation">}</span>
    <span class="hljs-punctuation">}</span><span class="hljs-punctuation">,</span>
    <span class="hljs-punctuation">{</span>
      <span class="hljs-attr">"Effect"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"Allow"</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">"Action"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><span class="hljs-string">"s3:GetObject"</span><span class="hljs-punctuation">,</span> <span class="hljs-string">"s3:PutObject"</span><span class="hljs-punctuation">,</span> <span class="hljs-string">"s3:DeleteObject"</span><span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">"Resource"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"arn:aws:s3:::ditwl-tfstate-123456789012/aws-tutorial/pro/*"</span>
    <span class="hljs-punctuation">}</span>
  <span class="hljs-punctuation">]</span>
<span class="hljs-punctuation">}</span></code></pre>
<p>The lock file is stored under the same key prefix, so the policy covers it. Read-only users (for example, a pipeline that only runs <code>tofu plan -lock=false</code>) need just <code>s3:GetObject</code> and <code>s3:ListBucket</code>. To also encrypt the contents of the state <strong>before</strong> it is sent to S3, read <a href="https://www.itwonderlab.com/terraform-state-file-encryption/">How to Encrypt Terraform State with OpenTofu</a>.</p>
<h3 id="locking-with-dynamodb">Locking with DynamoDB</h3>
<p>For versions without native locking, create a table with a <code>LockID</code> key and point the backend to it:</p>
<figure class="code"><figcaption>backend-bootstrap/main.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_dynamodb_table"</span> <span class="hljs-string">"tflock"</span> {
<span class="hljs-attr">  name</span>         = <span class="hljs-string">"ditwl-tflock"</span>
<span class="hljs-attr">  billing_mode</span> = <span class="hljs-string">"PAY_PER_REQUEST"</span>
<span class="hljs-attr">  hash_key</span>     = <span class="hljs-string">"LockID"</span>

  attribute {
<span class="hljs-attr">    name</span> = <span class="hljs-string">"LockID"</span>
<span class="hljs-attr">    type</span> = <span class="hljs-string">"S"</span>
  }
}</code></pre></figure>
<figure class="code"><figcaption>providers.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">  backend</span> <span class="hljs-string">"s3"</span> {
    <span class="hljs-comment"># ... the same arguments ...</span>
<span class="hljs-attr">    dynamodb_table</span> = <span class="hljs-string">"ditwl-tflock"</span>
  }</code></pre></figure>
<p>The IAM policy of the users also needs <code>dynamodb:GetItem</code>, <code>dynamodb:PutItem</code> and <code>dynamodb:DeleteItem</code> on the table.</p>
<h3 id="partial-configuration">Partial configuration</h3>
<p>The <code>backend</code> block cannot use Terraform variables (OpenTofu allows variables and locals in recent versions). To avoid repeating values between environments, or to keep them out of the repository, leave them out of the block and pass them when initializing:</p>
<figure class="code"><figcaption>backend.hcl</figcaption><pre><code class="hljs language-hcl"><span class="hljs-attr">bucket</span> = <span class="hljs-string">"ditwl-tfstate-123456789012"</span>
<span class="hljs-attr">key</span>    = <span class="hljs-string">"aws-tutorial/dev/terraform.tfstate"</span>
<span class="hljs-attr">region</span> = <span class="hljs-string">"us-east-1"</span></code></pre></figure>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu init -backend-config=backend.hcl</span></code></pre>
<h3 id="aws-s3-cost">AWS S3 Cost</h3>
<p>A state file is a few kilobytes: storage, versions and requests cost a few cents a month at most. It is the cheapest insurance for your infrastructure.</p>
<h3 id="common-questions-about-terraform-backends">Common Questions About Terraform Backends</h3>
<h4 id="can-i-store-the-state-in-git">Can I store the state in Git?</h4>
<p>No. It contains secrets in plain text, has no locking and merging two versions of a state file is not possible.</p>
<h4 id="what-if-i-change-the-key-or-the-bucket">What if I change the <code>key</code> or the bucket?</h4>
<p>Terraform sees an empty state and would try to create everything again. Change the configuration and run <code>tofu init -migrate-state</code> so that the existing state is copied to the new location.</p>
<h4 id="should-i-use-workspaces-for-the-environments">Should I use workspaces for the environments?</h4>
<p>Workspaces keep several states for the same code and backend. Many teams prefer one directory (or one <code>key</code>) per environment because it is more explicit and avoids applying to the wrong one.</p>
<h4 id="how-can-another-project-read-the-outputs-of-this-one">How can another project read the outputs of this one?</h4>
<p>With the <code>terraform_remote_state</code> data source, which only needs read access to the state, or better with a data source of the real resource (for example <code>aws_vpc</code>), which does not depend on the other project's state.</p>
<h3 id="next-steps">Next Steps</h3>
<p>The state is shared and protected. Continue with <a href="https://www.itwonderlab.com/aws-terraform-tutorial-terraform-tools/">Terraform Tools</a> to validate, lint and document the code.</p>]]></content:encoded>
</item>
<item>
<title>AWS with Terraform Tutorial: Terraform Modules (18)</title>
<link>https://www.itwonderlab.com/aws-terraform-tutorial-terraform-modules/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/aws-terraform-tutorial-terraform-modules/</guid>
<pubDate>Mon, 05 Oct 2026 10:20:00 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>Terraform &amp; OpenTofu Tutorials</category>
<description>Refactor the AWS network of the tutorial into a reusable Terraform module with variables and outputs, without recreating any resource.</description>
<content:encoded><![CDATA[<h2 id="how-to-create-terraform-modules-for-aws-infrastructure">How to create Terraform modules for AWS infrastructure</h2>
<p><strong>Using Terraform and <a href="https://www.itwonderlab.com/opentofu/">OpenTofu</a> modules to turn the <a href="https://www.itwonderlab.com/aws-vpc/">VPC</a>, <a href="https://www.itwonderlab.com/aws-subnets/">subnets</a>, gateways and <a href="https://www.itwonderlab.com/aws-route-tables/">routing tables</a> of the tutorial into a reusable building block.</strong></p>
<p>Welcome to our tutorial series about <a href="https://www.itwonderlab.com/tag/aws-terraform-tutorial/">Terraform or OpenTofu on AWS</a>. Every section so far added resources to a single file, <code>terraform-aws-tutorial.tf</code>. That is the best way to learn, but it does not scale: the file grows, the same patterns are copied between projects and one mistake can affect everything. A <strong>module</strong> groups resources behind a small interface of inputs (variables) and outputs, so they can be reused, tested and versioned.</p>
<figure></figure>
<h3 id="prerequisites">Prerequisites</h3>
<p>Read the previous sections of the tutorial, listed in the <a href="#series">series index</a> at the end of this page. This section refactors the network created in <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-vpc/">AWS VPC</a>, <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-subnets/">AWS Subnets</a>, <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-internet-gateway/">AWS Internet Gateway</a>, <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-nat-gateway/">AWS NAT Gateway</a> and <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-routing-tables/">AWS Routing Tables</a>.</p>
<h3 id="terraform-modules">Terraform modules</h3>
<p>A module is a directory with Terraform files. Every Terraform project is already a module, the <strong>root module</strong>, and it can call other modules (<strong>child modules</strong>) with a <code>module</code> block:</p>
<ul>
<li><strong>Inputs</strong>: <code>variable</code> blocks, set as arguments of the <code>module</code> block.</li>
<li><strong>Outputs</strong>: <code>output</code> blocks, read as <code>module.&lt;name&gt;.&lt;output&gt;</code>.</li>
<li><strong>Sources</strong>: a local path (<code>./modules/network</code>), the public registry (<code>terraform-aws-modules/vpc/aws</code>), a Git repository (<code>git::https://github.com/org/repo.git//modules/network?ref=v1.2.0</code>) or an <a href="https://www.itwonderlab.com/aws-s3/">S3</a> bucket.</li>
</ul>
<p>Good modules do one thing, expose few and well-documented variables, hide implementation details and do not configure providers (the provider is inherited from the root module).</p>
<h3 id="the-new-structure-of-the-project">The new structure of the project</h3>
<pre><code>.
├── providers.tf        # terraform {} and provider "aws" {}
├── main.tf             # calls the module and the rest of the resources
├── moved.tf            # tells Terraform that the resources changed address
└── modules
    └── network
        ├── main.tf
        ├── variables.tf
        └── outputs.tf</code></pre>
<p>The goal is the same infrastructure: after the refactor, <code>tofu plan</code> must <strong>not</strong> create, change or destroy anything.</p>
<h3 id="the-network-module">The network module</h3>
<h4 id="input-variables">Input variables</h4>
<figure class="code"><figcaption>modules/network/variables.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">variable</span> <span class="hljs-string">"name_prefix"</span> {
<span class="hljs-attr">  type</span>        = string
<span class="hljs-attr">  description</span> = <span class="hljs-string">"Prefix used in the names of the resources, for example ditwl"</span>
}
<span class="hljs-keyword">
variable</span> <span class="hljs-string">"vpc_name"</span> {
<span class="hljs-attr">  type</span>        = string
<span class="hljs-attr">  description</span> = <span class="hljs-string">"Name tag of the VPC"</span>
}
<span class="hljs-keyword">
variable</span> <span class="hljs-string">"vpc_cidr_block"</span> {
<span class="hljs-attr">  type</span>        = string
<span class="hljs-attr">  description</span> = <span class="hljs-string">"IPv4 CIDR block of the VPC, for example 172.21.0.0/19"</span>

  validation {
<span class="hljs-attr">    condition</span>     = can(cidrnetmask(<span class="hljs-built_in">var</span>.vpc_cidr_block))
<span class="hljs-attr">    error_message</span> = <span class="hljs-string">"vpc_cidr_block must be a valid IPv4 CIDR block, for example 172.21.0.0/19."</span>
  }
}
<span class="hljs-keyword">
variable</span> <span class="hljs-string">"subnets"</span> {
<span class="hljs-attr">  type</span> = map(object({
<span class="hljs-attr">    cidr_block</span>        = string
<span class="hljs-attr">    availability_zone</span> = string
<span class="hljs-attr">    public</span>            = bool
  }))
<span class="hljs-attr">  description</span> = <span class="hljs-string">"Subnets by name. Private subnets need a public subnet in the same Availability Zone for the NAT Gateway."</span>

  validation {
<span class="hljs-attr">    condition</span> = alltrue([
      for s in <span class="hljs-built_in">var</span>.subnets : s.public || anytrue([for p in <span class="hljs-built_in">var</span>.subnets : p.public &amp;&amp; p.availability_zone == s.availability_zone])
    ])
<span class="hljs-attr">    error_message</span> = <span class="hljs-string">"Every Availability Zone with a private subnet needs a public subnet."</span>
  }
}</code></pre></figure>
<p>The <code>subnets</code> variable is a map: each key is the name of a subnet. A <code>for_each</code> over it creates all the subnets, so adding a subnet means adding one entry, not copying a resource block.</p>
<h4 id="resources">Resources</h4>
<figure class="code"><figcaption>modules/network/main.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">terraform</span> {
<span class="hljs-keyword">  required_providers</span> {
<span class="hljs-attr">    aws</span> = {
<span class="hljs-attr">      source</span>  = <span class="hljs-string">"hashicorp/aws"</span>
<span class="hljs-attr">      version</span> = <span class="hljs-string">"&gt;= 5.0"</span>
    }
  }
}
<span class="hljs-keyword">
locals</span> {
<span class="hljs-attr">  public_subnets</span>  = { for name, s in <span class="hljs-built_in">var</span>.subnets : name =&gt; s if s.public }
<span class="hljs-attr">  private_subnets</span> = { for name, s in <span class="hljs-built_in">var</span>.subnets : name =&gt; s if !s.public }

  <span class="hljs-comment"># One NAT Gateway per Availability Zone, in the public subnet of that zone</span>
<span class="hljs-attr">  nat_subnet_by_az</span> = { for name, s in <span class="hljs-built_in">local</span>.public_subnets : s.availability_zone =&gt; name }
<span class="hljs-attr">  private_azs</span>      = toset([for s in <span class="hljs-built_in">local</span>.private_subnets : s.availability_zone])

  <span class="hljs-comment"># us-east-1a -&gt; za</span>
<span class="hljs-attr">  zone</span> = { for az in keys(<span class="hljs-built_in">local</span>.nat_subnet_by_az) : az =&gt; <span class="hljs-string">"z<span class="hljs-subst">${substr(az, -1, 1)}</span>"</span> }
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_vpc"</span> <span class="hljs-string">"this"</span> {
<span class="hljs-attr">  cidr_block</span> = <span class="hljs-built_in">var</span>.vpc_cidr_block
<span class="hljs-attr">  tags</span> = {
<span class="hljs-attr">    Name</span> = <span class="hljs-built_in">var</span>.vpc_name
<span class="hljs-attr">    tool</span> = <span class="hljs-string">"Terraform"</span>
  }
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_subnet"</span> <span class="hljs-string">"this"</span> {
<span class="hljs-keyword">  for_each</span> = <span class="hljs-built_in">var</span>.subnets
<span class="hljs-attr">
  vpc_id</span>                  = aws_vpc.this.id
<span class="hljs-attr">  cidr_block</span>              = <span class="hljs-built_in">each</span>.value.cidr_block
<span class="hljs-attr">  availability_zone</span>       = <span class="hljs-built_in">each</span>.value.availability_zone
<span class="hljs-attr">  map_public_ip_on_launch</span> = <span class="hljs-built_in">each</span>.value.public
<span class="hljs-attr">  tags</span> = {
<span class="hljs-attr">    Name</span> = <span class="hljs-built_in">each</span>.key
  }
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_internet_gateway"</span> <span class="hljs-string">"this"</span> {
<span class="hljs-attr">  vpc_id</span> = aws_vpc.this.id
<span class="hljs-attr">  tags</span> = {
<span class="hljs-attr">    Name</span> = <span class="hljs-string">"<span class="hljs-subst">${var.name_prefix}</span>-ig"</span>
  }
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_eip"</span> <span class="hljs-string">"nat"</span> {
<span class="hljs-keyword">  for_each</span> = <span class="hljs-built_in">local</span>.nat_subnet_by_az
<span class="hljs-attr">
  domain</span> = <span class="hljs-string">"vpc"</span>
<span class="hljs-attr">  tags</span> = {
<span class="hljs-attr">    Name</span> = <span class="hljs-string">"<span class="hljs-subst">${var.name_prefix}</span>-eip-ngw-<span class="hljs-subst">${local.zone[each.key]}</span>"</span>
  }
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_nat_gateway"</span> <span class="hljs-string">"this"</span> {
<span class="hljs-keyword">  for_each</span> = <span class="hljs-built_in">local</span>.nat_subnet_by_az
<span class="hljs-attr">
  subnet_id</span>     = aws_subnet.this[<span class="hljs-built_in">each</span>.value].id
<span class="hljs-attr">  allocation_id</span> = aws_eip.nat[<span class="hljs-built_in">each</span>.key].id
<span class="hljs-attr">  tags</span> = {
<span class="hljs-attr">    Name</span> = <span class="hljs-string">"<span class="hljs-subst">${var.name_prefix}</span>-ngw-<span class="hljs-subst">${local.zone[each.key]}</span>-pub"</span>
  }
<span class="hljs-keyword">
  depends_on</span> = [aws_internet_gateway.this]
}

<span class="hljs-comment"># Public routing table, the main one of the VPC: access to the Internet through the Internet Gateway</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_route_table"</span> <span class="hljs-string">"public"</span> {
<span class="hljs-attr">  vpc_id</span> = aws_vpc.this.id

  route {
<span class="hljs-attr">    cidr_block</span> = <span class="hljs-string">"0.0.0.0/0"</span>
<span class="hljs-attr">    gateway_id</span> = aws_internet_gateway.this.id
  }
<span class="hljs-attr">
  tags</span> = {
<span class="hljs-attr">    Name</span> = <span class="hljs-string">"<span class="hljs-subst">${var.name_prefix}</span>-rt-pub-main"</span>
  }
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_main_route_table_association"</span> <span class="hljs-string">"public"</span> {
<span class="hljs-attr">  vpc_id</span>         = aws_vpc.this.id
<span class="hljs-attr">  route_table_id</span> = aws_route_table.public.id
}

<span class="hljs-comment"># One private routing table per Availability Zone: access to the Internet through the NAT Gateway of the zone</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_route_table"</span> <span class="hljs-string">"private"</span> {
<span class="hljs-keyword">  for_each</span> = <span class="hljs-built_in">local</span>.private_azs
<span class="hljs-attr">
  vpc_id</span> = aws_vpc.this.id
<span class="hljs-attr">  tags</span> = {
<span class="hljs-attr">    Name</span> = <span class="hljs-string">"<span class="hljs-subst">${var.name_prefix}</span>-rt-priv-<span class="hljs-subst">${local.zone[each.key]}</span>"</span>
  }
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_route"</span> <span class="hljs-string">"private_nat"</span> {
<span class="hljs-keyword">  for_each</span> = <span class="hljs-built_in">local</span>.private_azs
<span class="hljs-attr">
  route_table_id</span>         = aws_route_table.private[<span class="hljs-built_in">each</span>.key].id
<span class="hljs-attr">  destination_cidr_block</span> = <span class="hljs-string">"0.0.0.0/0"</span>
<span class="hljs-attr">  nat_gateway_id</span>         = aws_nat_gateway.this[<span class="hljs-built_in">each</span>.key].id
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_route_table_association"</span> <span class="hljs-string">"private"</span> {
<span class="hljs-keyword">  for_each</span> = <span class="hljs-built_in">local</span>.private_subnets
<span class="hljs-attr">
  subnet_id</span>      = aws_subnet.this[<span class="hljs-built_in">each</span>.key].id
<span class="hljs-attr">  route_table_id</span> = aws_route_table.private[<span class="hljs-built_in">each</span>.value.availability_zone].id
}</code></pre></figure>
<h4 id="outputs">Outputs</h4>
<figure class="code"><figcaption>modules/network/outputs.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">output</span> <span class="hljs-string">"vpc_id"</span> {
<span class="hljs-attr">  description</span> = <span class="hljs-string">"ID of the VPC"</span>
<span class="hljs-attr">  value</span>       = aws_vpc.this.id
}
<span class="hljs-keyword">
output</span> <span class="hljs-string">"subnet_ids"</span> {
<span class="hljs-attr">  description</span> = <span class="hljs-string">"IDs of all the subnets by name"</span>
<span class="hljs-attr">  value</span>       = { for name, s in aws_subnet.this : name =&gt; s.id }
}
<span class="hljs-keyword">
output</span> <span class="hljs-string">"public_subnet_ids"</span> {
<span class="hljs-attr">  description</span> = <span class="hljs-string">"IDs of the public subnets"</span>
<span class="hljs-attr">  value</span>       = [for name, s in <span class="hljs-built_in">local</span>.public_subnets : aws_subnet.this[name].id]
}
<span class="hljs-keyword">
output</span> <span class="hljs-string">"private_subnet_ids"</span> {
<span class="hljs-attr">  description</span> = <span class="hljs-string">"IDs of the private subnets"</span>
<span class="hljs-attr">  value</span>       = [for name, s in <span class="hljs-built_in">local</span>.private_subnets : aws_subnet.this[name].id]
}</code></pre></figure>
<h3 id="using-the-module">Using the module</h3>
<p>Replace the VPC, subnet, gateway and routing table resources in <code>terraform-aws-tutorial.tf</code> with one <code>module</code> block:</p>
<figure class="code"><figcaption>main.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">module</span> <span class="hljs-string">"network"</span> {
<span class="hljs-attr">  source</span> = <span class="hljs-string">"./modules/network"</span>
<span class="hljs-attr">
  name_prefix</span>    = <span class="hljs-string">"ditwl"</span>
<span class="hljs-attr">  vpc_name</span>       = <span class="hljs-string">"ditlw-vpc"</span>
<span class="hljs-attr">  vpc_cidr_block</span> = <span class="hljs-string">"172.21.0.0/19"</span>
<span class="hljs-attr">
  subnets</span> = {
    <span class="hljs-string">"ditwl-sn-za-pro-pub-00"</span> = { cidr_block = <span class="hljs-string">"172.21.0.0/23"</span>, availability_zone = <span class="hljs-string">"us-east-1a"</span>, public = <span class="hljs-literal">true</span> }
    <span class="hljs-string">"ditwl-sn-za-pro-pri-02"</span> = { cidr_block = <span class="hljs-string">"172.21.2.0/23"</span>, availability_zone = <span class="hljs-string">"us-east-1a"</span>, public = <span class="hljs-literal">false</span> }
    <span class="hljs-string">"ditwl-sn-zb-pro-pub-04"</span> = { cidr_block = <span class="hljs-string">"172.21.4.0/23"</span>, availability_zone = <span class="hljs-string">"us-east-1b"</span>, public = <span class="hljs-literal">true</span> }
    <span class="hljs-string">"ditwl-sn-zb-pro-pri-06"</span> = { cidr_block = <span class="hljs-string">"172.21.6.0/23"</span>, availability_zone = <span class="hljs-string">"us-east-1b"</span>, public = <span class="hljs-literal">false</span> }
  }
}</code></pre></figure>
<p>The rest of the infrastructure used to reference the resources directly. Now it uses the outputs of the module:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>aws_vpc.ditlw-vpc.id</code></td>
<td><code>module.network.vpc_id</code></td>
</tr>
<tr>
<td><code>aws_subnet.ditwl-sn-za-pro-pub-00.id</code></td>
<td><code>module.network.subnet_ids["ditwl-sn-za-pro-pub-00"]</code></td>
</tr>
<tr>
<td><code>aws_subnet.ditwl-sn-zb-pro-pri-06.id</code></td>
<td><code>module.network.subnet_ids["ditwl-sn-zb-pro-pri-06"]</code></td>
</tr>
</tbody>
</table></div>
<p>For example, an <a href="https://www.itwonderlab.com/aws-ec2/">EC2</a> instance now uses <code>subnet_id = module.network.subnet_ids["ditwl-sn-za-pro-pub-00"]</code> and a <a href="https://www.itwonderlab.com/aws-security-groups/">security group</a> <code>vpc_id = module.network.vpc_id</code>.</p>
<h3 id="moving-the-existing-resources-the-moved-block">Moving the existing resources: the <code>moved</code> block</h3>
<p>If you run <code>tofu plan</code> now, Terraform sees that <code>aws_vpc.ditlw-vpc</code> disappeared from the code and that <code>module.network.aws_vpc.this</code> is new: it would <strong>destroy the whole network and create it again</strong>. A <code>moved</code> block tells Terraform that the resource did not change, only its address did, and the state is updated without touching the real infrastructure.</p>
<figure class="code"><figcaption>moved.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">moved</span> {
<span class="hljs-attr">  from</span> = aws_vpc.ditlw-vpc
<span class="hljs-attr">  to</span>   = <span class="hljs-built_in">module</span>.network.aws_vpc.this
}
<span class="hljs-keyword">
moved</span> {
<span class="hljs-attr">  from</span> = aws_subnet.ditwl-sn-za-pro-pub-<span class="hljs-number">00</span>
<span class="hljs-attr">  to</span>   = <span class="hljs-built_in">module</span>.network.aws_subnet.this[<span class="hljs-string">"ditwl-sn-za-pro-pub-00"</span>]
}
<span class="hljs-keyword">moved</span> {
<span class="hljs-attr">  from</span> = aws_subnet.ditwl-sn-za-pro-pri-<span class="hljs-number">02</span>
<span class="hljs-attr">  to</span>   = <span class="hljs-built_in">module</span>.network.aws_subnet.this[<span class="hljs-string">"ditwl-sn-za-pro-pri-02"</span>]
}
<span class="hljs-keyword">moved</span> {
<span class="hljs-attr">  from</span> = aws_subnet.ditwl-sn-zb-pro-pub-<span class="hljs-number">04</span>
<span class="hljs-attr">  to</span>   = <span class="hljs-built_in">module</span>.network.aws_subnet.this[<span class="hljs-string">"ditwl-sn-zb-pro-pub-04"</span>]
}
<span class="hljs-keyword">moved</span> {
<span class="hljs-attr">  from</span> = aws_subnet.ditwl-sn-zb-pro-pri-<span class="hljs-number">06</span>
<span class="hljs-attr">  to</span>   = <span class="hljs-built_in">module</span>.network.aws_subnet.this[<span class="hljs-string">"ditwl-sn-zb-pro-pri-06"</span>]
}
<span class="hljs-keyword">
moved</span> {
<span class="hljs-attr">  from</span> = aws_internet_gateway.ditwl-ig
<span class="hljs-attr">  to</span>   = <span class="hljs-built_in">module</span>.network.aws_internet_gateway.this
}
<span class="hljs-keyword">
moved</span> {
<span class="hljs-attr">  from</span> = aws_eip.ditwl-eip-ngw-za
<span class="hljs-attr">  to</span>   = <span class="hljs-built_in">module</span>.network.aws_eip.nat[<span class="hljs-string">"us-east-1a"</span>]
}
<span class="hljs-keyword">moved</span> {
<span class="hljs-attr">  from</span> = aws_eip.ditwl-eip-ngw-zb
<span class="hljs-attr">  to</span>   = <span class="hljs-built_in">module</span>.network.aws_eip.nat[<span class="hljs-string">"us-east-1b"</span>]
}
<span class="hljs-keyword">moved</span> {
<span class="hljs-attr">  from</span> = aws_nat_gateway.ditwl-ngw-za-pub
<span class="hljs-attr">  to</span>   = <span class="hljs-built_in">module</span>.network.aws_nat_gateway.this[<span class="hljs-string">"us-east-1a"</span>]
}
<span class="hljs-keyword">moved</span> {
<span class="hljs-attr">  from</span> = aws_nat_gateway.ditwl-ngw-zb-pub
<span class="hljs-attr">  to</span>   = <span class="hljs-built_in">module</span>.network.aws_nat_gateway.this[<span class="hljs-string">"us-east-1b"</span>]
}
<span class="hljs-keyword">
moved</span> {
<span class="hljs-attr">  from</span> = aws_route_table.ditwl-rt-pub-main
<span class="hljs-attr">  to</span>   = <span class="hljs-built_in">module</span>.network.aws_route_table.public
}
<span class="hljs-keyword">moved</span> {
<span class="hljs-attr">  from</span> = aws_main_route_table_association.ditwl-rta-default
<span class="hljs-attr">  to</span>   = <span class="hljs-built_in">module</span>.network.aws_main_route_table_association.public
}
<span class="hljs-keyword">moved</span> {
<span class="hljs-attr">  from</span> = aws_route_table.ditwl-rt-priv-za
<span class="hljs-attr">  to</span>   = <span class="hljs-built_in">module</span>.network.aws_route_table.private[<span class="hljs-string">"us-east-1a"</span>]
}
<span class="hljs-keyword">moved</span> {
<span class="hljs-attr">  from</span> = aws_route_table.ditwl-rt-priv-zb
<span class="hljs-attr">  to</span>   = <span class="hljs-built_in">module</span>.network.aws_route_table.private[<span class="hljs-string">"us-east-1b"</span>]
}
<span class="hljs-keyword">moved</span> {
<span class="hljs-attr">  from</span> = aws_route.ditwl-r-rt-priv-za-ngw-za
<span class="hljs-attr">  to</span>   = <span class="hljs-built_in">module</span>.network.aws_route.private_nat[<span class="hljs-string">"us-east-1a"</span>]
}
<span class="hljs-keyword">moved</span> {
<span class="hljs-attr">  from</span> = aws_route.ditwl-r-rt-priv-zb-ngw-zb
<span class="hljs-attr">  to</span>   = <span class="hljs-built_in">module</span>.network.aws_route.private_nat[<span class="hljs-string">"us-east-1b"</span>]
}
<span class="hljs-keyword">moved</span> {
<span class="hljs-attr">  from</span> = aws_route_table_association.ditwl-rta-za-pro-pri-<span class="hljs-number">02</span>
<span class="hljs-attr">  to</span>   = <span class="hljs-built_in">module</span>.network.aws_route_table_association.private[<span class="hljs-string">"ditwl-sn-za-pro-pri-02"</span>]
}
<span class="hljs-keyword">moved</span> {
<span class="hljs-attr">  from</span> = aws_route_table_association.ditwl-rta-zb-pro-pri-<span class="hljs-number">06</span>
<span class="hljs-attr">  to</span>   = <span class="hljs-built_in">module</span>.network.aws_route_table_association.private[<span class="hljs-string">"ditwl-sn-zb-pro-pri-06"</span>]
}</code></pre></figure>
<h3 id="run-the-terraform-plan">Run the Terraform Plan</h3>
<p>A new module has to be installed before it can be used:</p>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu init</span>
Initializing modules...
- network in modules/network</code></pre>
<p>Then check the plan. Every <code>moved</code> resource is listed, and <strong>nothing</strong> is created or destroyed:</p>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu plan</span>
...
<span class="hljs-meta prompt_">  # </span><span class="language-bash">aws_vpc.ditlw-vpc has moved to module.network.aws_vpc.this</span>
    resource "aws_vpc" "this" {
        id = "vpc-0361cf67e9e74acf6"
        # (14 unchanged attributes hidden)
    }
...
Plan: 0 to add, 0 to change, 0 to destroy.</code></pre>
<aside class="note note-tip" role="note"><p class="note-title">Tip</p>
<p>If the plan wants to create or destroy resources, something does not match. Fix the module (a name, a tag or a CIDR block) until the plan is empty. The two NAT routes can appear as updated in place: the original code used <code>gateway_id</code> for them and the module uses <code>nat_gateway_id</code>, the argument meant for <a href="https://www.itwonderlab.com/aws-nat-gateway/">NAT Gateways</a>.</p>
</aside>
<p>Apply the plan to save the new addresses in the state:</p>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu apply</span></code></pre>
<p>Keep <code>moved.tf</code> until everyone who shares the state has applied the change, then delete it.</p>
<h3 id="public-registry-modules">Public registry modules</h3>
<p>You do not always have to write the module. The <a href="https://registry.terraform.io/">Terraform Registry</a> and the <a href="https://search.opentofu.org/">OpenTofu Registry</a> have thousands of them. The most popular one for this job, <code>terraform-aws-modules/vpc/aws</code>, builds the same network:</p>
<pre><code class="hljs language-hcl"><span class="hljs-keyword">module</span> <span class="hljs-string">"vpc"</span> {
<span class="hljs-attr">  source</span>  = <span class="hljs-string">"terraform-aws-modules/vpc/aws"</span>
<span class="hljs-attr">  version</span> = <span class="hljs-string">"~&gt; 5.0"</span> <span class="hljs-comment"># always pin the version</span>
<span class="hljs-attr">
  name</span> = <span class="hljs-string">"ditlw-vpc"</span>
<span class="hljs-attr">  cidr</span> = <span class="hljs-string">"172.21.0.0/19"</span>
<span class="hljs-attr">
  azs</span>             = [<span class="hljs-string">"us-east-1a"</span>, <span class="hljs-string">"us-east-1b"</span>]
<span class="hljs-attr">  public_subnets</span>  = [<span class="hljs-string">"172.21.0.0/23"</span>, <span class="hljs-string">"172.21.4.0/23"</span>]
<span class="hljs-attr">  private_subnets</span> = [<span class="hljs-string">"172.21.2.0/23"</span>, <span class="hljs-string">"172.21.6.0/23"</span>]
<span class="hljs-attr">
  enable_nat_gateway</span>     = <span class="hljs-literal">true</span>
<span class="hljs-attr">  one_nat_gateway_per_az</span> = <span class="hljs-literal">true</span>
}</code></pre>
<p>Read the code of a registry module before using it and pin its version, as it will create resources in your AWS account.</p>
<h3 id="module-best-practices">Module best practices</h3>
<ul>
<li><strong>One purpose per module</strong>, with a short list of variables. If a module needs 40 variables, it is probably several modules.</li>
<li><strong>Describe and validate</strong> every variable (<code>description</code>, <code>type</code>, <code>validation</code>) and every output.</li>
<li><strong>No provider blocks</strong> inside modules, only <code>required_providers</code>. The root module configures the provider.</li>
<li><strong>Use <code>for_each</code> over a map</strong> instead of <code>count</code>: adding or removing an element does not renumber the others.</li>
<li><strong>Version the modules</strong> that are shared (Git tags or a registry) and update them in a controlled way.</li>
<li><strong>Document them</strong> with a README. The <a href="https://www.itwonderlab.com/aws-terraform-tutorial-terraform-tools/">Terraform Tools</a> section shows how to generate it automatically.</li>
</ul>
<h3 id="common-questions-about-terraform-modules">Common Questions About Terraform Modules</h3>
<h4 id="what-is-the-difference-between-a-module-and-a-workspace">What is the difference between a module and a workspace?</h4>
<p>A module is a way to <strong>reuse code</strong>. A workspace is a way to keep <strong>several states</strong> for the same code (for example, one per environment). They solve different problems and can be combined.</p>
<h4 id="can-a-module-use-resources-created-outside-of-it">Can a module use resources created outside of it?</h4>
<p>Yes, but pass them as variables (for example <code>vpc_id</code>) instead of reading them inside the module. The module stays independent and easier to test.</p>
<h4 id="how-do-i-use-the-same-module-for-several-environments">How do I use the same module for several environments?</h4>
<p>Call it several times with different inputs, from different root modules (one directory and one state per environment) or in the same one with different names.</p>
<h4 id="what-happens-if-i-rename-a-resource-inside-a-module">What happens if I rename a resource inside a module?</h4>
<p>Terraform considers it a different resource and recreates it. Add a <code>moved</code> block inside the module with the old and the new address.</p>
<h3 id="next-steps">Next Steps</h3>
<p>The infrastructure is now organized in modules. Before sharing it with a team, the state must stop living on your computer: continue with <a href="https://www.itwonderlab.com/aws-terraform-tutorial-terraform-backends/">Terraform Backends</a>.</p>]]></content:encoded>
</item>
<item>
<title>AWS with Terraform Tutorial: AWS Load Balancers (16)</title>
<link>https://www.itwonderlab.com/aws-terraform-tutorial-aws-load-balancers/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/aws-terraform-tutorial-aws-load-balancers/</guid>
<pubDate>Mon, 05 Oct 2026 10:10:00 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>Terraform &amp; OpenTofu Tutorials</category>
<description>Use Terraform to create an Application Load Balancer with a target group, listeners and HTTPS in front of an Auto Scaling group on AWS.</description>
<content:encoded><![CDATA[<h2 id="how-to-create-aws-application-load-balancers-with-terraform">How to create AWS Application Load Balancers with Terraform</h2>
<p><strong>Using the Terraform <code>aws_lb</code>, <code>aws_lb_target_group</code> and <code>aws_lb_listener</code> resource blocks to distribute the traffic between the instances of an <a href="https://www.itwonderlab.com/aws-auto-scaling/">Auto Scaling</a> group.</strong></p>
<p>Welcome to our tutorial series about <a href="https://www.itwonderlab.com/tag/aws-terraform-tutorial/">Terraform or OpenTofu on AWS</a>. The <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-auto-scaling/">previous section</a> created a group of front-end instances whose IP addresses change all the time. In this section an Application <a href="https://www.itwonderlab.com/aws-elastic-load-balancing/">Load Balancer</a> becomes the single, stable entry point: it receives the traffic from the Internet, sends it only to healthy instances and, with a certificate, serves it over HTTPS.</p>
<figure></figure>
<h3 id="prerequisites">Prerequisites</h3>
<p>Read the previous sections of the tutorial, listed in the <a href="#series">series index</a> at the end of this page. This section builds on:</p>
<ul>
<li><a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-auto-scaling/">AWS Auto Scaling</a>: the group <code>ditwl-asg-front-end</code>,</li>
<li><a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-security-groups/">AWS Security Groups</a>: the group <code>ditwl-sg-front-end</code>,</li>
<li><a href="https://www.itwonderlab.com/aws-with-terraform-tutorial-aws-route-53/">AWS Route 53 (DNS)</a>: the public zone <code>ditwl-r53-public</code> (only for the DNS name and HTTPS).</li>
</ul>
<h3 id="aws-load-balancers">AWS Load Balancers</h3>
<p>Elastic Load Balancing offers several types of load balancers:</p>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Type</th>
<th>Layer</th>
<th>Use it for</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Application Load Balancer (ALB)</strong></td>
<td>7 (HTTP/HTTPS)</td>
<td>Web sites and APIs: routing by host name, path or header, redirects, HTTPS termination</td>
</tr>
<tr>
<td><strong>Network Load Balancer (NLB)</strong></td>
<td>4 (TCP/UDP/TLS)</td>
<td>Very high performance, static IP addresses, non-HTTP protocols</td>
</tr>
<tr>
<td><strong>Gateway Load Balancer (GWLB)</strong></td>
<td>3</td>
<td>Firewalls and traffic inspection appliances</td>
</tr>
</tbody>
</table></div>
<p>This tutorial uses an <strong>Application Load Balancer</strong>. It has four parts:</p>
<ul>
<li>the <strong>load balancer</strong> (<code>aws_lb</code>), placed in the public <a href="https://www.itwonderlab.com/aws-subnets/">subnets</a> of at least two <a href="https://www.itwonderlab.com/aws-regions-availability-zones/">Availability Zones</a>,</li>
<li>a <strong><a href="https://www.itwonderlab.com/aws-security-groups/">security group</a></strong> that controls who can reach it,</li>
<li>a <strong>target group</strong> (<code>aws_lb_target_group</code>): the list of instances that receive the traffic and the health check used to know which are healthy,</li>
<li>a <strong>listener</strong> (<code>aws_lb_listener</code>): the port and protocol the load balancer listens on and what it does with the requests.</li>
</ul>
<h3 id="definition-of-an-application-load-balancer-with-terraform">Definition of an Application Load Balancer with Terraform</h3>
<h4 id="security-groups">Security groups</h4>
<p>The load balancer accepts HTTP from the Internet. The front-end instances now accept HTTP <strong>only from the load balancer</strong>, which means nobody can reach them directly. Remove the rule <code>ditwl-sr-internet-to-front-end-http</code> created in the Security Groups section and add:</p>
<figure class="code"><figcaption>terraform-aws-tutorial.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-comment"># Security Group for the load balancer</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_security_group"</span> <span class="hljs-string">"ditwl-sg-alb-front-end"</span> {
<span class="hljs-attr">  name</span>        = <span class="hljs-string">"ditwl-sg-alb-front-end"</span>
<span class="hljs-attr">  vpc_id</span>      = aws_vpc.ditlw-vpc.id
<span class="hljs-attr">  description</span> = <span class="hljs-string">"Load balancer of the front-end servers"</span>
}

<span class="hljs-comment"># Allow access from the Internet to port 80 HTTP in the load balancer</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_security_group_rule"</span> <span class="hljs-string">"ditwl-sr-internet-to-alb-http"</span> {
<span class="hljs-attr">  security_group_id</span> = aws_security_group.ditwl-sg-alb-front-end.id
<span class="hljs-attr">  type</span>              = <span class="hljs-string">"ingress"</span>
<span class="hljs-attr">  from_port</span>         = <span class="hljs-number">80</span>
<span class="hljs-attr">  to_port</span>           = <span class="hljs-number">80</span>
<span class="hljs-attr">  protocol</span>          = <span class="hljs-string">"tcp"</span>
<span class="hljs-attr">  cidr_blocks</span>       = [<span class="hljs-string">"0.0.0.0/0"</span>] <span class="hljs-comment"># Internet</span>
<span class="hljs-attr">  description</span>       = <span class="hljs-string">"Allow access from the Internet to port 80 in the load balancer"</span>
}

<span class="hljs-comment"># Allow the load balancer to reach port 80 in the front-end servers</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_security_group_rule"</span> <span class="hljs-string">"ditwl-sr-alb-to-front-end-egress"</span> {
<span class="hljs-attr">  security_group_id</span>        = aws_security_group.ditwl-sg-alb-front-end.id
<span class="hljs-attr">  type</span>                     = <span class="hljs-string">"egress"</span>
<span class="hljs-attr">  from_port</span>                = <span class="hljs-number">80</span>
<span class="hljs-attr">  to_port</span>                  = <span class="hljs-number">80</span>
<span class="hljs-attr">  protocol</span>                 = <span class="hljs-string">"tcp"</span>
<span class="hljs-attr">  source_security_group_id</span> = aws_security_group.ditwl-sg-front-end.id
<span class="hljs-attr">  description</span>              = <span class="hljs-string">"Allow traffic from the load balancer to the front-end servers"</span>
}

<span class="hljs-comment"># Allow access from the load balancer to port 80 in the front-end servers</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_security_group_rule"</span> <span class="hljs-string">"ditwl-sr-alb-to-front-end-http"</span> {
<span class="hljs-attr">  security_group_id</span>        = aws_security_group.ditwl-sg-front-end.id
<span class="hljs-attr">  type</span>                     = <span class="hljs-string">"ingress"</span>
<span class="hljs-attr">  from_port</span>                = <span class="hljs-number">80</span>
<span class="hljs-attr">  to_port</span>                  = <span class="hljs-number">80</span>
<span class="hljs-attr">  protocol</span>                 = <span class="hljs-string">"tcp"</span>
<span class="hljs-attr">  source_security_group_id</span> = aws_security_group.ditwl-sg-alb-front-end.id
<span class="hljs-attr">  description</span>              = <span class="hljs-string">"Allow access from the load balancer to port 80 in the front-end"</span>
}</code></pre></figure>
<p>Referencing a security group instead of an IP range is a best practice: the rule keeps working when the load balancer changes its addresses.</p>
<h4 id="load-balancer-target-group-and-listener">Load balancer, target group and listener</h4>
<figure class="code"><figcaption>terraform-aws-tutorial.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-comment"># Application Load Balancer in the two public subnets</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_lb"</span> <span class="hljs-string">"ditwl-alb-front-end"</span> {
<span class="hljs-attr">  name</span>                       = <span class="hljs-string">"ditwl-alb-front-end"</span>
<span class="hljs-attr">  load_balancer_type</span>         = <span class="hljs-string">"application"</span>
<span class="hljs-attr">  internal</span>                   = <span class="hljs-literal">false</span>
<span class="hljs-attr">  security_groups</span>            = [aws_security_group.ditwl-sg-alb-front-end.id]
<span class="hljs-attr">  subnets</span>                    = [aws_subnet.ditwl-sn-za-pro-pub-<span class="hljs-number">00</span>.id, aws_subnet.ditwl-sn-zb-pro-pub-<span class="hljs-number">04</span>.id]
<span class="hljs-attr">  drop_invalid_header_fields</span> = <span class="hljs-literal">true</span>
}

<span class="hljs-comment"># Target group: the front-end instances and how to check that they are healthy</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_lb_target_group"</span> <span class="hljs-string">"ditwl-tg-front-end"</span> {
<span class="hljs-attr">  name</span>                 = <span class="hljs-string">"ditwl-tg-front-end"</span>
<span class="hljs-attr">  port</span>                 = <span class="hljs-number">80</span>
<span class="hljs-attr">  protocol</span>             = <span class="hljs-string">"HTTP"</span>
<span class="hljs-attr">  vpc_id</span>               = aws_vpc.ditlw-vpc.id
<span class="hljs-attr">  deregistration_delay</span> = <span class="hljs-number">30</span> <span class="hljs-comment"># seconds to finish the requests in progress before removing an instance</span>

  health_check {
<span class="hljs-attr">    path</span>                = <span class="hljs-string">"/"</span>
<span class="hljs-attr">    matcher</span>             = <span class="hljs-string">"200"</span>
<span class="hljs-attr">    interval</span>            = <span class="hljs-number">15</span>
<span class="hljs-attr">    timeout</span>             = <span class="hljs-number">5</span>
<span class="hljs-attr">    healthy_threshold</span>   = <span class="hljs-number">2</span>
<span class="hljs-attr">    unhealthy_threshold</span> = <span class="hljs-number">3</span>
  }
}

<span class="hljs-comment"># Listener: HTTP on port 80 forwards the requests to the target group</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_lb_listener"</span> <span class="hljs-string">"ditwl-lbl-front-end-http"</span> {
<span class="hljs-attr">  load_balancer_arn</span> = aws_lb.ditwl-alb-front-end.arn
<span class="hljs-attr">  port</span>              = <span class="hljs-number">80</span>
<span class="hljs-attr">  protocol</span>          = <span class="hljs-string">"HTTP"</span>

  default_action {
<span class="hljs-attr">    type</span>             = <span class="hljs-string">"forward"</span>
<span class="hljs-attr">    target_group_arn</span> = aws_lb_target_group.ditwl-tg-front-end.arn
  }
}
<span class="hljs-keyword">
output</span> <span class="hljs-string">"ditwl-alb-front-end-dns"</span> {
<span class="hljs-attr">  value</span> = aws_lb.ditwl-alb-front-end.dns_name
}</code></pre></figure>
<h4 id="connect-the-auto-scaling-group-to-the-target-group">Connect the Auto Scaling group to the target group</h4>
<p>Modify the Auto Scaling group from the previous section. Two arguments change: the group registers its instances in the target group, and it uses the load balancer health check, so an instance that does not answer is replaced and not only the ones that are stopped.</p>
<figure class="code"><figcaption>terraform-aws-tutorial.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_autoscaling_group"</span> <span class="hljs-string">"ditwl-asg-front-end"</span> {
  <span class="hljs-comment"># ... the rest of the arguments do not change ...</span>
<span class="hljs-attr">  target_group_arns</span> = [aws_lb_target_group.ditwl-tg-front-end.arn]
<span class="hljs-attr">  health_check_type</span> = <span class="hljs-string">"ELB"</span>
}</code></pre></figure>
<p>For a production environment, move the instances to the private subnets (<code>vpc_zone_identifier = [aws_subnet.ditwl-sn-za-pro-pri-02.id, aws_subnet.ditwl-sn-zb-pro-pri-06.id]</code>). Only the load balancer is then exposed to the Internet, and the instances use the <a href="https://www.itwonderlab.com/aws-nat-gateway/">NAT Gateways</a> to download packages.</p>
<h4 id="a-dns-name-for-the-load-balancer">A DNS name for the load balancer</h4>
<p>An <strong>alias record</strong> in the public zone points <code>www</code> to the load balancer. Unlike a CNAME, it is free and can also be used for the zone apex.</p>
<figure class="code"><figcaption>terraform-aws-tutorial.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_route53_record"</span> <span class="hljs-string">"ditwl-r53-public-www"</span> {
<span class="hljs-attr">  zone_id</span> = aws_route53_zone.ditwl-r53-public.zone_id
<span class="hljs-attr">  name</span>    = <span class="hljs-string">"www.<span class="hljs-subst">${aws_route53_zone.ditwl-r53-public.name}</span>"</span>
<span class="hljs-attr">  type</span>    = <span class="hljs-string">"A"</span>

  alias {
<span class="hljs-attr">    name</span>                   = aws_lb.ditwl-alb-front-end.dns_name
<span class="hljs-attr">    zone_id</span>                = aws_lb.ditwl-alb-front-end.zone_id
<span class="hljs-attr">    evaluate_target_health</span> = <span class="hljs-literal">true</span>
  }
}</code></pre></figure>
<h3 id="https-with-aws-certificate-manager">HTTPS with AWS Certificate Manager</h3>
<p><a href="https://www.itwonderlab.com/aws-acm/">AWS Certificate Manager</a> (ACM) issues free public certificates for the load balancer. The certificate is validated with a DNS record, which Terraform creates in the public zone, so the zone must be delegated and working.</p>
<figure class="code"><figcaption>terraform-aws-tutorial.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-comment"># Certificate for www.demo.itwonderlab.com</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_acm_certificate"</span> <span class="hljs-string">"ditwl-acm-www"</span> {
<span class="hljs-attr">  domain_name</span>       = <span class="hljs-string">"www.<span class="hljs-subst">${aws_route53_zone.ditwl-r53-public.name}</span>"</span>
<span class="hljs-attr">  validation_method</span> = <span class="hljs-string">"DNS"</span>
<span class="hljs-keyword">
  lifecycle</span> {
<span class="hljs-attr">    create_before_destroy</span> = <span class="hljs-literal">true</span>
  }
}

<span class="hljs-comment"># DNS records that prove that we own the domain</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_route53_record"</span> <span class="hljs-string">"ditwl-r53-acm-www-validation"</span> {
<span class="hljs-keyword">  for_each</span> = {
    for o in aws_acm_certificate.ditwl-acm-www.domain_validation_options : o.domain_name =&gt; {
<span class="hljs-attr">      name</span>   = o.resource_record_name
<span class="hljs-attr">      record</span> = o.resource_record_value
<span class="hljs-attr">      type</span>   = o.resource_record_type
    }
  }
<span class="hljs-attr">
  allow_overwrite</span> = <span class="hljs-literal">true</span>
<span class="hljs-attr">  zone_id</span>         = aws_route53_zone.ditwl-r53-public.zone_id
<span class="hljs-attr">  name</span>            = <span class="hljs-built_in">each</span>.value.name
<span class="hljs-attr">  type</span>            = <span class="hljs-built_in">each</span>.value.type
<span class="hljs-attr">  records</span>         = [<span class="hljs-built_in">each</span>.value.record]
<span class="hljs-attr">  ttl</span>             = <span class="hljs-number">60</span>
}

<span class="hljs-comment"># Wait until the certificate is issued</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_acm_certificate_validation"</span> <span class="hljs-string">"ditwl-acm-www"</span> {
<span class="hljs-attr">  certificate_arn</span>         = aws_acm_certificate.ditwl-acm-www.arn
<span class="hljs-attr">  validation_record_fqdns</span> = [for r in aws_route53_record.ditwl-r53-acm-www-validation : r.fqdn]
}

<span class="hljs-comment"># HTTPS listener</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_lb_listener"</span> <span class="hljs-string">"ditwl-lbl-front-end-https"</span> {
<span class="hljs-attr">  load_balancer_arn</span> = aws_lb.ditwl-alb-front-end.arn
<span class="hljs-attr">  port</span>              = <span class="hljs-number">443</span>
<span class="hljs-attr">  protocol</span>          = <span class="hljs-string">"HTTPS"</span>
<span class="hljs-attr">  ssl_policy</span>        = <span class="hljs-string">"ELBSecurityPolicy-TLS13-1-2-2021-06"</span>
<span class="hljs-attr">  certificate_arn</span>   = aws_acm_certificate_validation.ditwl-acm-www.certificate_arn

  default_action {
<span class="hljs-attr">    type</span>             = <span class="hljs-string">"forward"</span>
<span class="hljs-attr">    target_group_arn</span> = aws_lb_target_group.ditwl-tg-front-end.arn
  }
}</code></pre></figure>
<p>Then allow port 443 in <code>ditwl-sg-alb-front-end</code> (a second <code>aws_security_group_rule</code> like <code>ditwl-sr-internet-to-alb-http</code> with port 443), and change the HTTP listener to redirect to HTTPS:</p>
<figure class="code"><figcaption>terraform-aws-tutorial.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_lb_listener"</span> <span class="hljs-string">"ditwl-lbl-front-end-http"</span> {
<span class="hljs-attr">  load_balancer_arn</span> = aws_lb.ditwl-alb-front-end.arn
<span class="hljs-attr">  port</span>              = <span class="hljs-number">80</span>
<span class="hljs-attr">  protocol</span>          = <span class="hljs-string">"HTTP"</span>

  default_action {
<span class="hljs-attr">    type</span> = <span class="hljs-string">"redirect"</span>
    redirect {
<span class="hljs-attr">      port</span>        = <span class="hljs-string">"443"</span>
<span class="hljs-attr">      protocol</span>    = <span class="hljs-string">"HTTPS"</span>
<span class="hljs-attr">      status_code</span> = <span class="hljs-string">"HTTP_301"</span>
    }
  }
}</code></pre></figure>
<h3 id="run-the-terraform-plan">Run the Terraform Plan</h3>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu plan</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu apply</span></code></pre>
<p>The ALB takes a few minutes to be active and the instances need to pass the health checks before they receive traffic. Then test it:</p>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu output ditwl-alb-front-end-dns</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">curl http://&lt;alb-dns-name&gt;/</span>
front-end ip-172-21-1-35
<span class="hljs-meta prompt_">$ </span><span class="language-bash">curl http://&lt;alb-dns-name&gt;/</span>
front-end ip-172-21-5-120</code></pre>
<p>Each request can be answered by a different instance: the response shows the name of the instance that served it. Check the status of the targets in the AWS console (<a href="https://www.itwonderlab.com/aws-ec2/">EC2</a> → Target Groups → <code>ditwl-tg-front-end</code> → Targets) or with <code>aws elbv2 describe-target-health</code>.</p>
<p>To destroy the infrastructure and avoid charges:</p>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu destroy</span></code></pre>
<h3 id="aws-load-balancers-cost">AWS Load Balancers Cost</h3>
<p>An Application Load Balancer is billed <strong>per hour</strong> while it exists plus per <strong>Load Balancer Capacity Unit (LCU)</strong> consumed (new connections, active connections, processed bytes and rule evaluations). A small test environment costs a few cents a day, but a forgotten load balancer is a monthly charge, so destroy it after the tests. See the <a href="https://aws.amazon.com/elasticloadbalancing/pricing/">Elastic Load Balancing pricing</a>. ACM public certificates are free.</p>
<h3 id="common-questions-about-aws-load-balancers">Common Questions About AWS Load Balancers</h3>
<h4 id="should-i-use-an-application-or-a-network-load-balancer">Should I use an Application or a Network Load Balancer?</h4>
<p>Use an ALB for HTTP and HTTPS applications: it understands the requests and can route by host name or path. Use an NLB when you need static IP addresses, very low latency or protocols that are not HTTP.</p>
<h4 id="how-do-i-send-different-paths-to-different-target-groups">How do I send different paths to different target groups?</h4>
<p>Add <code>aws_lb_listener_rule</code> resources to a listener with a <code>condition</code> on the path (<code>/api/*</code>) or the host name and an <code>action</code> that forwards to another target group.</p>
<h4 id="why-does-the-target-group-show-unhealthy-instances">Why does the target group show unhealthy instances?</h4>
<p>Check that the security group of the instances allows the traffic from the load balancer, that the application listens on the target group port and that the <code>health_check</code> path returns the code in <code>matcher</code>. The <code>health_check_grace_period</code> of the Auto Scaling group must be long enough for the instance to boot.</p>
<h4 id="can-i-terminate-https-in-the-instances-instead">Can I terminate HTTPS in the instances instead?</h4>
<p>You can, with an NLB in TLS passthrough mode, but terminating HTTPS in the ALB is simpler: ACM renews the certificates automatically and the instances do not handle certificates.</p>
<h3 id="next-steps">Next Steps</h3>
<p>So far Terraform and AWS were used together to create the infrastructure. The next section shows how to use <a href="https://www.itwonderlab.com/terraform-aws-ansible/">Terraform, AWS and Ansible together</a> to configure the servers.</p>]]></content:encoded>
</item>
<item>
<title>AWS with Terraform Tutorial: AWS Auto Scaling (15)</title>
<link>https://www.itwonderlab.com/aws-terraform-tutorial-aws-auto-scaling/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/aws-terraform-tutorial-aws-auto-scaling/</guid>
<pubDate>Mon, 05 Oct 2026 10:00:00 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>Terraform &amp; OpenTofu Tutorials</category>
<description>Use the Terraform aws_launch_template and aws_autoscaling_group resources to run a self-healing, scalable group of EC2 instances on AWS.</description>
<content:encoded><![CDATA[<h2 id="how-to-create-aws-auto-scaling-groups-with-terraform">How to create AWS Auto Scaling Groups with Terraform</h2>
<p><strong>Using the Terraform <code>aws_launch_template</code>, <code>aws_autoscaling_group</code> and <code>aws_autoscaling_policy</code> resource blocks to run a self-healing group of <a href="https://www.itwonderlab.com/aws-ec2/">EC2</a> instances that grows and shrinks with demand.</strong></p>
<p>Welcome to our tutorial series about <a href="https://www.itwonderlab.com/tag/aws-terraform-tutorial/">Terraform or OpenTofu on AWS</a>. In the previous sections a <a href="https://www.itwonderlab.com/aws-vpc/">VPC</a>, <a href="https://www.itwonderlab.com/aws-subnets/">subnets</a>, <a href="https://www.itwonderlab.com/aws-security-groups/">security groups</a>, EC2 instances, a database and DNS were defined. In this section the front-end server stops being a single instance and becomes a group of instances that AWS replaces when they fail and scales when the load changes.</p>
<figure></figure>
<h3 id="prerequisites">Prerequisites</h3>
<p>Read the previous sections of the tutorial, listed in the <a href="#series">series index</a> at the end of this page. This section reuses:</p>
<ul>
<li>the public subnets <code>ditwl-sn-za-pro-pub-00</code> and <code>ditwl-sn-zb-pro-pub-04</code> from <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-subnets/">AWS Subnets</a>,</li>
<li>the security groups <code>ditwl-sg-base-ec2</code> and <code>ditwl-sg-front-end</code> from <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-security-groups/">AWS Security Groups</a>,</li>
<li>the key pair <code>ditwl-kp-config-user</code> from <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-key-pairs/">AWS Key Pairs</a>,</li>
<li>the <a href="https://www.itwonderlab.com/aws-ami/">AMI</a> data source <code>ubuntu-23-04-arm64-minimal</code> from <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-ami/">AWS AMIs</a>.</li>
</ul>
<aside class="note note-note" role="note"><p class="note-title">Note</p>
<p>Ubuntu 23.04 reached the end of its life and its package repositories were moved. If the AMI filter from the AWS AMIs section no longer finds an image, or <code>apt-get</code> fails when the instances start, update the filter to a supported Ubuntu release.</p>
</aside>
<h3 id="aws-auto-scaling">AWS Auto Scaling</h3>
<p>An <strong>Amazon EC2 <a href="https://www.itwonderlab.com/aws-auto-scaling/">Auto Scaling</a> group</strong> (ASG) is a collection of EC2 instances managed as a single unit. It keeps the number of instances between a minimum and a maximum, spreads them across <a href="https://www.itwonderlab.com/aws-regions-availability-zones/">Availability Zones</a>, replaces the ones that fail their health checks and adds or removes instances following scaling policies.</p>
<p>Three resources are needed:</p>
<ul>
<li><strong>Launch template</strong> (<code>aws_launch_template</code>): the blueprint of every instance in the group: AMI, instance type, key pair, security groups, user data and tags.</li>
<li><strong>Auto Scaling group</strong> (<code>aws_autoscaling_group</code>): how many instances, in which subnets (Availability Zones) and how to check their health.</li>
<li><strong>Scaling policy</strong> (<code>aws_autoscaling_policy</code>) and optionally <strong>scheduled actions</strong> (<code>aws_autoscaling_schedule</code>): when to add or remove instances.</li>
</ul>
<h3 id="what-changes-in-the-infrastructure">What changes in the infrastructure</h3>
<p>The EC2 section defined one front-end server, <code>ditwl-ec-front-end-001</code>. Instances in an Auto Scaling group are created and destroyed by AWS, so they cannot be defined one by one in Terraform and their IP addresses change. Make these changes in <code>terraform-aws-tutorial.tf</code>:</p>
<ol>
<li>Remove (or comment out) the resource <code>aws_instance.ditwl-ec-front-end-001</code>. The back-end server <code>ditwl-ec-back-end-123</code> is not changed.</li>
<li>Remove the <a href="https://www.itwonderlab.com/aws-route-53/">Route 53</a> records that point to that instance (<code>ditwl-r53-public-front-end-001</code> and <code>ditwl-r53-private-front-end-001</code>). The next section, <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-load-balancers/">AWS Load Balancers</a>, adds one stable DNS name for the whole group.</li>
</ol>
<h3 id="definition-of-an-auto-scaling-group-with-terraform">Definition of an Auto Scaling group with Terraform</h3>
<h4 id="launch-template-ditwl-lt-front-end">Launch template: ditwl-lt-front-end</h4>
<figure class="code"><figcaption>terraform-aws-tutorial.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-comment"># Launch template for the front-end servers</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_launch_template"</span> <span class="hljs-string">"ditwl-lt-front-end"</span> {
<span class="hljs-attr">  name</span>                   = <span class="hljs-string">"ditwl-lt-front-end"</span>
<span class="hljs-attr">  image_id</span>               = <span class="hljs-built_in">data</span>.aws_ami.ubuntu-<span class="hljs-number">23</span>-<span class="hljs-number">04</span>-arm64-minimal.id
<span class="hljs-attr">  instance_type</span>          = <span class="hljs-string">"t4g.micro"</span>
<span class="hljs-attr">  key_name</span>               = <span class="hljs-string">"ditwl-kp-config-user"</span>
<span class="hljs-attr">  vpc_security_group_ids</span> = [aws_security_group.ditwl-sg-base-ec2.id, aws_security_group.ditwl-sg-front-end.id]
<span class="hljs-attr">  update_default_version</span> = <span class="hljs-literal">true</span> <span class="hljs-comment"># every change creates a new version and makes it the default</span>

  metadata_options {
<span class="hljs-attr">    http_endpoint</span> = <span class="hljs-string">"enabled"</span>
<span class="hljs-attr">    http_tokens</span>   = <span class="hljs-string">"required"</span> <span class="hljs-comment"># only IMDSv2 (session tokens) can read the instance metadata</span>
  }

  <span class="hljs-comment"># Cloud-init script: install a web server that shows the name of the instance</span>
<span class="hljs-attr">  user_data</span> = base64encode(<span class="hljs-string">&lt;&lt;-EOT
    #!/bin/bash
    apt-get update
    apt-get install -y nginx
    echo "front-end $(hostname)" &gt; /var/www/html/index.html
  EOT</span>
  )

  tag_specifications {
<span class="hljs-attr">    resource_type</span> = <span class="hljs-string">"instance"</span>
<span class="hljs-attr">    tags</span> = {
      <span class="hljs-string">"Name"</span>        = <span class="hljs-string">"ditwl-ec-front-end"</span>
      <span class="hljs-string">"app"</span>         = <span class="hljs-string">"front-end"</span>
      <span class="hljs-string">"os"</span>          = <span class="hljs-string">"ubuntu"</span>
      <span class="hljs-string">"environment"</span> = <span class="hljs-string">"pro"</span>
      <span class="hljs-string">"cost_center"</span> = <span class="hljs-string">"marketing-department"</span>
      <span class="hljs-string">"owner"</span>       = <span class="hljs-string">"IT Wonder Lab"</span>
    }
  }
}</code></pre></figure>
<ul>
<li><code>user_data</code> must be Base64 encoded in launch templates. <code>base64encode()</code> takes care of it.</li>
<li><code>http_tokens = "required"</code> is a good security default: it disables IMDSv1, which is used in server-side request forgery attacks.</li>
<li>The provider <code>default_tags</code> are <strong>not</strong> copied to the instances launched by an Auto Scaling group, that is why the tags are repeated in <code>tag_specifications</code>.</li>
</ul>
<h4 id="auto-scaling-group-ditwl-asg-front-end">Auto Scaling group: ditwl-asg-front-end</h4>
<figure class="code"><figcaption>terraform-aws-tutorial.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-comment"># Auto Scaling group for the front-end servers, one or more instances in two Availability Zones</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_autoscaling_group"</span> <span class="hljs-string">"ditwl-asg-front-end"</span> {
<span class="hljs-attr">  name</span>                      = <span class="hljs-string">"ditwl-asg-front-end"</span>
<span class="hljs-attr">  min_size</span>                  = <span class="hljs-number">1</span>
<span class="hljs-attr">  max_size</span>                  = <span class="hljs-number">4</span>
<span class="hljs-attr">  desired_capacity</span>          = <span class="hljs-number">2</span>
<span class="hljs-attr">  vpc_zone_identifier</span>       = [aws_subnet.ditwl-sn-za-pro-pub-<span class="hljs-number">00</span>.id, aws_subnet.ditwl-sn-zb-pro-pub-<span class="hljs-number">04</span>.id]
<span class="hljs-attr">  health_check_type</span>         = <span class="hljs-string">"EC2"</span>
<span class="hljs-attr">  health_check_grace_period</span> = <span class="hljs-number">120</span>

  launch_template {
<span class="hljs-attr">    id</span>      = aws_launch_template.ditwl-lt-front-end.id
<span class="hljs-attr">    version</span> = aws_launch_template.ditwl-lt-front-end.latest_version
  }

  <span class="hljs-comment"># Replace the instances gradually when the launch template changes (new AMI, new user data...)</span>
  instance_refresh {
<span class="hljs-attr">    strategy</span> = <span class="hljs-string">"Rolling"</span>
    preferences {
<span class="hljs-attr">      min_healthy_percentage</span> = <span class="hljs-number">50</span>
    }
  }

  <span class="hljs-comment"># Tag applied to the group and to every instance it launches</span>
  tag {
<span class="hljs-attr">    key</span>                 = <span class="hljs-string">"Name"</span>
<span class="hljs-attr">    value</span>               = <span class="hljs-string">"ditwl-ec-front-end"</span>
<span class="hljs-attr">    propagate_at_launch</span> = <span class="hljs-literal">true</span>
  }

  <span class="hljs-comment"># The scaling policy changes the desired capacity, Terraform must not undo it</span>
<span class="hljs-keyword">  lifecycle</span> {
<span class="hljs-attr">    ignore_changes</span> = [desired_capacity]
  }
}</code></pre></figure>
<ul>
<li><code>min_size</code>, <code>max_size</code> and <code>desired_capacity</code> are the lower limit, the upper limit (also a cost guardrail) and the number of instances at the start.</li>
<li><code>vpc_zone_identifier</code> lists one subnet per Availability Zone. The group balances the instances between them.</li>
<li><code>version = ...latest_version</code> makes the group change when the launch template changes, which is what triggers the instance refresh. With <code>"$Latest"</code> Terraform would not see any difference.</li>
</ul>
<h4 id="scaling-policy-keep-the-average-cpu-at-50">Scaling policy: keep the average CPU at 50%</h4>
<figure class="code"><figcaption>terraform-aws-tutorial.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-comment"># Target tracking: AWS adds or removes instances to keep the average CPU around 50%</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_autoscaling_policy"</span> <span class="hljs-string">"ditwl-asp-front-end-cpu"</span> {
<span class="hljs-attr">  name</span>                      = <span class="hljs-string">"ditwl-asp-front-end-cpu"</span>
<span class="hljs-attr">  autoscaling_group_name</span>    = aws_autoscaling_group.ditwl-asg-front-end.name
<span class="hljs-attr">  policy_type</span>               = <span class="hljs-string">"TargetTrackingScaling"</span>
<span class="hljs-attr">  estimated_instance_warmup</span> = <span class="hljs-number">120</span>

  target_tracking_configuration {
    predefined_metric_specification {
<span class="hljs-attr">      predefined_metric_type</span> = <span class="hljs-string">"ASGAverageCPUUtilization"</span>
    }
<span class="hljs-attr">    target_value</span> = <span class="hljs-number">50.0</span>
  }
}</code></pre></figure>
<p>Target tracking works like a thermostat: it creates the <a href="https://www.itwonderlab.com/aws-cloudwatch/">CloudWatch</a> alarms and calculates how many instances are needed. It is the simplest policy and the right choice in most cases.</p>
<h4 id="scheduled-action-fewer-instances-at-night">Scheduled action: fewer instances at night</h4>
<figure class="code"><figcaption>terraform-aws-tutorial.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-comment"># Every day at 22:00 UTC reduce the group to one instance (and back to two at 06:00 UTC)</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_autoscaling_schedule"</span> <span class="hljs-string">"ditwl-ass-front-end-night"</span> {
<span class="hljs-attr">  scheduled_action_name</span>  = <span class="hljs-string">"ditwl-ass-front-end-night"</span>
<span class="hljs-attr">  autoscaling_group_name</span> = aws_autoscaling_group.ditwl-asg-front-end.name
<span class="hljs-attr">  min_size</span>               = <span class="hljs-number">1</span>
<span class="hljs-attr">  max_size</span>               = <span class="hljs-number">2</span>
<span class="hljs-attr">  desired_capacity</span>       = <span class="hljs-number">1</span>
<span class="hljs-attr">  recurrence</span>             = <span class="hljs-string">"0 22 * * *"</span>
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_autoscaling_schedule"</span> <span class="hljs-string">"ditwl-ass-front-end-day"</span> {
<span class="hljs-attr">  scheduled_action_name</span>  = <span class="hljs-string">"ditwl-ass-front-end-day"</span>
<span class="hljs-attr">  autoscaling_group_name</span> = aws_autoscaling_group.ditwl-asg-front-end.name
<span class="hljs-attr">  min_size</span>               = <span class="hljs-number">1</span>
<span class="hljs-attr">  max_size</span>               = <span class="hljs-number">4</span>
<span class="hljs-attr">  desired_capacity</span>       = <span class="hljs-number">2</span>
<span class="hljs-attr">  recurrence</span>             = <span class="hljs-string">"0 6 * * *"</span>
}</code></pre></figure>
<h3 id="run-the-terraform-plan">Run the Terraform Plan</h3>
<h4 id="plan-using-opentofu">Plan using OpenTofu</h4>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu plan</span></code></pre>
<p>The plan creates the launch template, the Auto Scaling group, the scaling policy and the two scheduled actions, and destroys the instance <code>ditwl-ec-front-end-001</code> (and the DNS records if they were defined).</p>
<h4 id="apply-the-changes-using-opentofu">Apply the changes using OpenTofu</h4>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu apply</span></code></pre>
<p>Type <code>yes</code> to confirm. The group starts two instances, one in each Availability Zone. List them with the AWS CLI:</p>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">aws autoscaling describe-auto-scaling-groups \
    --auto-scaling-group-names ditwl-asg-front-end \
    --query <span class="hljs-string">'AutoScalingGroups[0].Instances[].[InstanceId,AvailabilityZone,LifecycleState,HealthStatus]'</span> \
    --output table --profile ditwl_infradmin</span></code></pre>
<h4 id="test-the-self-healing">Test the self-healing</h4>
<p>Terminate one of the instances and watch the group create a replacement in a couple of minutes, without any change in Terraform:</p>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">aws ec2 terminate-instances --instance-ids i-0123456789abcdef0 --profile ditwl_infradmin</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">aws autoscaling describe-scaling-activities --auto-scaling-group-name ditwl-asg-front-end \
    --max-items 3 --profile ditwl_infradmin</span></code></pre>
<p>To see the scale-out, connect to one instance and generate CPU load (for example with <code>stress-ng</code>). After a few minutes the average CPU goes over 50% and the group adds instances up to <code>max_size</code>.</p>
<h4 id="destroy-the-infrastructure-using-opentofu">Destroy the infrastructure using OpenTofu</h4>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu destroy</span></code></pre>
<h3 id="aws-auto-scaling-cost">AWS Auto Scaling Cost</h3>
<p>An Auto Scaling group has <strong>no additional charge</strong>: you pay for the resources it launches (EC2 instances, <a href="https://www.itwonderlab.com/aws-ebs/">EBS</a> volumes and data transfer). The cost is therefore controlled by <code>max_size</code> and the instance type. Detailed CloudWatch monitoring, if enabled in the launch template, is billed separately. Check the <a href="https://aws.amazon.com/ec2/pricing/">EC2 pricing page</a> for the current prices and remember to destroy the infrastructure after the tests.</p>
<h3 id="common-questions-about-aws-auto-scaling">Common Questions About AWS Auto Scaling</h3>
<h4 id="what-is-the-difference-between-minimum-maximum-and-desired-capacity">What is the difference between minimum, maximum and desired capacity?</h4>
<p><code>min_size</code> and <code>max_size</code> are the limits that no policy can cross. <code>desired_capacity</code> is the number of instances the group tries to have right now. Scaling policies change the desired capacity inside those limits.</p>
<h4 id="why-is-desired-capacity-in-ignore-changes">Why is <code>desired_capacity</code> in <code>ignore_changes</code>?</h4>
<p>Because AWS changes it while scaling. Without <code>ignore_changes</code>, the next <code>tofu apply</code> would reset the group to the value in the code and fight with the scaling policy.</p>
<h4 id="how-can-i-roll-out-a-new-ami-or-a-new-version-of-the-user-data">How can I roll out a new AMI or a new version of the user data?</h4>
<p>Change the launch template. The new version is used by new instances, and the <code>instance_refresh</code> block replaces the existing ones gradually, keeping at least 50% of the capacity healthy.</p>
<h4 id="can-i-use-spot-instances-to-reduce-the-cost">Can I use Spot Instances to reduce the cost?</h4>
<p>Yes. Replace the <code>launch_template</code> block with a <code>mixed_instances_policy</code> block, which lets the group combine On-Demand and Spot capacity and several instance types. It is a good fit for stateless front-end servers.</p>
<h4 id="why-not-use-count-or-for-each-on-aws-instance">Why not use <code>count</code> or <code>for_each</code> on <code>aws_instance</code>?</h4>
<p>Terraform would create a fixed set of instances and nothing would replace a failed one or react to the load. An Auto Scaling group delegates that work to AWS.</p>
<h3 id="next-steps">Next Steps</h3>
<p>The instances in the group are created and destroyed all the time and each one has a different IP address, so users need a single stable entry point. Continue with <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-load-balancers/">AWS Load Balancers</a>.</p>]]></content:encoded>
</item>
<item>
<title>Estimate AWS Costs in Terraform Pull Requests with Infracost</title>
<link>https://www.itwonderlab.com/terraform-cost-estimation-infracost/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/terraform-cost-estimation-infracost/</guid>
<pubDate>Fri, 02 Oct 2026 20:21:50 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>Terraform &amp; OpenTofu Tutorials</category>
<description>How to see the monthly cost of Terraform and OpenTofu changes before applying them with Infracost, and how to keep AWS demo costs under control.</description>
<content:encoded><![CDATA[<h2 id="know-the-price-before-you-apply">Know the price before you apply</h2>
<p>A single line of Terraform can add hundreds of dollars a month: a <a href="https://www.itwonderlab.com/aws-nat-gateway/">NAT gateway</a>, a larger <a href="https://www.itwonderlab.com/aws-rds/">RDS</a> instance, a <a href="https://www.itwonderlab.com/aws-elastic-load-balancing/">load balancer</a>. <a href="https://www.infracost.io/">Infracost</a> reads the code or the plan and estimates the monthly cost using public cloud prices. It works with Terraform and <a href="https://www.itwonderlab.com/opentofu/">OpenTofu</a>, and supports AWS, Azure and Google Cloud.</p>
<h3 id="install-and-use">Install and use</h3>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">infracost auth login          <span class="hljs-comment"># free API key</span></span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">infracost breakdown --path .</span></code></pre>
<p>Example of the output for a configuration with a NAT gateway and an <a href="https://www.itwonderlab.com/aws-ec2/">EC2</a> instance:</p>
<pre><code class="hljs language-bash"> Name                                  Monthly Qty  Unit   Monthly Cost

 aws_nat_gateway.public_a
 ├─ NAT gateway                                730  hours        <span class="hljs-variable">$36</span>.00
 └─ Data processed                  Monthly cost depends on usage: <span class="hljs-variable">$0</span>.045 per GB

 aws_instance.web
 ├─ Instance usage (Linux, t3.micro)             730  hours         <span class="hljs-variable">$7</span>.59
 └─ root_block_device
    └─ Storage (general purpose SSD, gp3)         8  GB            <span class="hljs-variable">$0</span>.64

 OVERALL TOTAL                                                    <span class="hljs-variable">$44</span>.23</code></pre>
<p>The prices and format depend on the region and on the version of the tool, so treat the example as an illustration. The estimate does not include usage-based charges (data transfer, requests) unless you provide a usage file.</p>
<h3 id="compare-with-the-current-state-diff">Compare with the current state: diff</h3>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">infracost breakdown --path . --format json --out-file base.json</span>
<span class="hljs-meta prompt_"># </span><span class="language-bash">change the code</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">infracost diff --path . --compare-to base.json</span></code></pre>
<p>The <code>diff</code> command shows the difference in monthly cost caused by your change.</p>
<h3 id="usage-estimates">Usage estimates</h3>
<p>For resources that bill by usage, such as <a href="https://www.itwonderlab.com/aws-s3/">S3</a>, <a href="https://www.itwonderlab.com/aws-lambda/">Lambda</a> or data transfer, create a usage file:</p>
<figure class="code"><figcaption>infracost-usage.yml</figcaption><pre><code class="hljs language-yaml"><span class="hljs-attr">version:</span> <span class="hljs-number">0.1</span>
<span class="hljs-attr">resource_usage:</span>
  <span class="hljs-attr">aws_lambda_function.api:</span>
    <span class="hljs-attr">monthly_requests:</span> <span class="hljs-number">5000000</span>
    <span class="hljs-attr">request_duration_ms:</span> <span class="hljs-number">200</span>
  <span class="hljs-attr">aws_s3_bucket.assets:</span>
    <span class="hljs-attr">standard:</span>
      <span class="hljs-attr">storage_gb:</span> <span class="hljs-number">500</span>
      <span class="hljs-attr">monthly_tier_1_requests:</span> <span class="hljs-number">1000000</span></code></pre></figure>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">infracost breakdown --path . --usage-file infracost-usage.yml</span></code></pre>
<h3 id="in-pull-requests">In pull requests</h3>
<p>Add a step to the <a href="https://www.itwonderlab.com/terraform-github-actions-aws-oidc/">GitHub Actions pipeline</a> that posts the cost difference as a comment on each pull request, using the <code>infracost/actions/setup</code> action and <code>infracost comment github</code>. Reviewers then see "+$38/month" next to the code change. You can also set a policy that fails the check when the increase passes a threshold.</p>
<h3 id="keep-demo-and-tutorial-costs-low">Keep demo and tutorial costs low</h3>
<p>When you follow the <a href="https://www.itwonderlab.com/tutorials/aws/">AWS with Terraform tutorials</a>:</p>
<ul>
<li>Use a separate <a href="https://www.itwonderlab.com/create-an-aws-account-for-demos/">AWS account for demos</a> and set a budget alert in AWS Budgets.</li>
<li>Run <code>tofu destroy</code> when you finish. The most expensive items are <strong>NAT gateways</strong>, load balancers, RDS instances and unattached <a href="https://www.itwonderlab.com/aws-elastic-ip/">Elastic IPs</a>, which are charged by the hour.</li>
<li>Prefer small instance types (<code>t3.micro</code>, <code>db.t4g.micro</code>) and check the <a href="https://aws.amazon.com/free/">free tier</a> conditions for your account.</li>
<li>Tag all resources (<a href="https://www.itwonderlab.com/aws-resource-tagging/">resource tagging</a>) and use Cost Explorer to filter by tag.</li>
<li>Delete forgotten resources: an old <a href="https://www.itwonderlab.com/aws-ebs/">EBS</a> volume or snapshot still costs money.</li>
</ul>
<p>Cost estimation is part of the <a href="https://www.itwonderlab.com/terraform-best-practices/">Terraform best practices</a>.</p>]]></content:encoded>
</item>
<item>
<title>AWS VPC Peering and Transit Gateway with Terraform</title>
<link>https://www.itwonderlab.com/terraform-aws-vpc-peering-transit-gateway/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/terraform-aws-vpc-peering-transit-gateway/</guid>
<pubDate>Sun, 20 Sep 2026 08:19:27 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>Terraform &amp; OpenTofu Tutorials</category>
<description>Connect AWS VPCs with Terraform or OpenTofu using VPC peering for simple cases or a Transit Gateway for many networks, with routes and a comparison of costs.</description>
<content:encoded><![CDATA[<h2 id="connecting-vpcs">Connecting VPCs</h2>
<p>When you have more than one <a href="https://www.itwonderlab.com/aws-vpc/">VPC</a> (one per environment, per team or per account), you need a way for them to talk using private IP addresses. The two main options are <strong>VPC peering</strong> and <strong>AWS <a href="https://www.itwonderlab.com/aws-transit-gateway/">Transit Gateway</a></strong>. The CIDR blocks of the connected VPCs <strong>must not overlap</strong>, so plan them in advance (<a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-vpc/">VPC tutorial</a>).</p>
<h3 id="comparison">Comparison</h3>
<div class="table-wrap"><table>
<thead>
<tr>
<th></th>
<th>VPC peering</th>
<th>Transit Gateway</th>
</tr>
</thead>
<tbody>
<tr>
<td>Topology</td>
<td>One connection between two VPCs</td>
<td>A hub that connects many VPCs, VPNs and Direct Connect</td>
</tr>
<tr>
<td>Transitive routing</td>
<td>No (A-B and B-C does not mean A-C)</td>
<td>Yes</td>
</tr>
<tr>
<td>Number of connections</td>
<td>N x (N-1) / 2 for a full mesh</td>
<td>One attachment per VPC</td>
</tr>
<tr>
<td>Cost</td>
<td>No hourly charge, only data transfer between AZs or regions</td>
<td>Hourly charge per attachment plus data processed</td>
</tr>
<tr>
<td>Best for</td>
<td>Two or three VPCs</td>
<td>Many VPCs or hybrid networks</td>
</tr>
</tbody>
</table></div>
<h3 id="vpc-peering-in-the-same-account-and-region">VPC peering in the same account and region</h3>
<figure class="code"><figcaption>peering.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_vpc_peering_connection"</span> <span class="hljs-string">"app_to_shared"</span> {
<span class="hljs-attr">  vpc_id</span>      = aws_vpc.app.id
<span class="hljs-attr">  peer_vpc_id</span> = aws_vpc.shared.id
<span class="hljs-attr">  auto_accept</span> = <span class="hljs-literal">true</span>
<span class="hljs-attr">
  tags</span> = {
<span class="hljs-attr">    Name</span> = <span class="hljs-string">"app-to-shared"</span>
  }
}</code></pre></figure>
<p><code>auto_accept</code> only works when both VPCs are in the same account and region. After the peering exists, <strong>add routes on both sides</strong>: the connection does nothing without them.</p>
<figure class="code"><figcaption>peering.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_route"</span> <span class="hljs-string">"app_to_shared"</span> {
<span class="hljs-attr">  route_table_id</span>            = aws_route_table.app_private.id
<span class="hljs-attr">  destination_cidr_block</span>    = aws_vpc.shared.cidr_block
<span class="hljs-attr">  vpc_peering_connection_id</span> = aws_vpc_peering_connection.app_to_shared.id
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_route"</span> <span class="hljs-string">"shared_to_app"</span> {
<span class="hljs-attr">  route_table_id</span>            = aws_route_table.shared_private.id
<span class="hljs-attr">  destination_cidr_block</span>    = aws_vpc.app.cidr_block
<span class="hljs-attr">  vpc_peering_connection_id</span> = aws_vpc_peering_connection.app_to_shared.id
}</code></pre></figure>
<p>Then allow the traffic in the <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-security-groups/">security groups</a> (reference the CIDR of the other VPC). Routing is explained in <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-routing-tables/">routing tables</a>.</p>
<h3 id="peering-across-accounts">Peering across accounts</h3>
<p>The requester creates the connection and the owner of the other VPC accepts it, using a second provider with credentials for that account:</p>
<figure class="code"><figcaption>peering-cross-account.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">provider</span> <span class="hljs-string">"aws"</span> {
<span class="hljs-attr">  alias</span>   = <span class="hljs-string">"peer"</span>
<span class="hljs-attr">  region</span>  = <span class="hljs-string">"eu-west-1"</span>
<span class="hljs-attr">  profile</span> = <span class="hljs-string">"other-account"</span>
}
<span class="hljs-keyword">
data</span> <span class="hljs-string">"aws_caller_identity"</span> <span class="hljs-string">"peer"</span> {
<span class="hljs-keyword">  provider</span> = aws.peer
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_vpc_peering_connection"</span> <span class="hljs-string">"cross"</span> {
<span class="hljs-attr">  vpc_id</span>        = aws_vpc.app.id
<span class="hljs-attr">  peer_vpc_id</span>   = <span class="hljs-built_in">var</span>.peer_vpc_id
<span class="hljs-attr">  peer_owner_id</span> = <span class="hljs-built_in">data</span>.aws_caller_identity.peer.account_id
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_vpc_peering_connection_accepter"</span> <span class="hljs-string">"cross"</span> {
<span class="hljs-keyword">  provider</span>                  = aws.peer
<span class="hljs-attr">  vpc_peering_connection_id</span> = aws_vpc_peering_connection.cross.id
<span class="hljs-attr">  auto_accept</span>               = <span class="hljs-literal">true</span>
}</code></pre></figure>
<h3 id="transit-gateway">Transit Gateway</h3>
<figure class="code"><figcaption>tgw.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_ec2_transit_gateway"</span> <span class="hljs-string">"main"</span> {
<span class="hljs-attr">  description</span>                     = <span class="hljs-string">"Central hub"</span>
<span class="hljs-attr">  default_route_table_association</span> = <span class="hljs-string">"enable"</span>
<span class="hljs-attr">  default_route_table_propagation</span> = <span class="hljs-string">"enable"</span>
<span class="hljs-attr">  dns_support</span>                     = <span class="hljs-string">"enable"</span>
<span class="hljs-attr">
  tags</span> = {
<span class="hljs-attr">    Name</span> = <span class="hljs-string">"ditwl-tgw"</span>
  }
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_ec2_transit_gateway_vpc_attachment"</span> <span class="hljs-string">"vpc"</span> {
<span class="hljs-keyword">  for_each</span> = {
<span class="hljs-attr">    app</span>    = { vpc_id = aws_vpc.app.id,    subnet_ids = aws_subnet.app_tgw[*].id }
<span class="hljs-attr">    shared</span> = { vpc_id = aws_vpc.shared.id, subnet_ids = aws_subnet.shared_tgw[*].id }
  }
<span class="hljs-attr">
  transit_gateway_id</span> = aws_ec2_transit_gateway.main.id
<span class="hljs-attr">  vpc_id</span>             = <span class="hljs-built_in">each</span>.value.vpc_id
<span class="hljs-attr">  subnet_ids</span>         = <span class="hljs-built_in">each</span>.value.subnet_ids
}</code></pre></figure>
<p>With default association and propagation, every attached VPC can reach every other VPC. The routes inside the VPCs still need to point to the gateway:</p>
<figure class="code"><figcaption>tgw.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_route"</span> <span class="hljs-string">"app_to_tgw"</span> {
<span class="hljs-attr">  route_table_id</span>         = aws_route_table.app_private.id
<span class="hljs-attr">  destination_cidr_block</span> = <span class="hljs-string">"10.0.0.0/8"</span>   <span class="hljs-comment"># all your internal networks</span>
<span class="hljs-attr">  transit_gateway_id</span>     = aws_ec2_transit_gateway.main.id
<span class="hljs-keyword">
  depends_on</span> = [aws_ec2_transit_gateway_vpc_attachment.vpc]
}</code></pre></figure>
<p>For isolation (for example production cannot reach development), disable the defaults and create separate Transit Gateway <a href="https://www.itwonderlab.com/aws-route-tables/">route tables</a> and associations.</p>
<h3 id="share-it-across-accounts">Share it across accounts</h3>
<p>Use AWS Resource Access Manager (<code>aws_ram_resource_share</code> and <code>aws_ram_principal_association</code>) to share the Transit Gateway with other accounts in your organization (<a href="https://www.itwonderlab.com/terraform-aws-organizations-multi-account/">multi-account</a>), then each account creates its attachment.</p>
<h3 id="costs">Costs</h3>
<p>Transit Gateway charges per attachment per hour and per GB processed, so a small setup with two VPCs is much cheaper with peering. Peering has no hourly fee. Data transfer between AZs and regions applies to both. Consider <a href="https://www.itwonderlab.com/terraform-aws-vpc-endpoints/">VPC endpoints</a> for access to specific services and estimate with <a href="https://www.itwonderlab.com/terraform-cost-estimation-infracost/">Infracost</a>.</p>]]></content:encoded>
</item>
<item>
<title>What is OpenTofu? The Open-Source Fork of Terraform Explained</title>
<link>https://www.itwonderlab.com/what-is-opentofu/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/what-is-opentofu/</guid>
<pubDate>Sat, 19 Sep 2026 09:55:31 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>Terraform &amp; OpenTofu Tutorials</category>
<description>OpenTofu is the open-source Infrastructure as Code tool, a fork of Terraform managed by the Linux Foundation. Learn what it is, how it works and how to start.</description>
<content:encoded><![CDATA[<h2 id="opentofu-in-a-nutshell">OpenTofu in a nutshell</h2>
<p><strong><a href="https://www.itwonderlab.com/opentofu/">OpenTofu</a></strong> is an open-source <a href="https://www.itwonderlab.com/iac/">Infrastructure as Code (IaC)</a> tool. You describe the infrastructure you want (networks, servers, databases, DNS records) in text files written in <a href="https://www.itwonderlab.com/hcl/">HCL</a>, and OpenTofu compares it with what exists and creates, changes or deletes resources until both match. It works with AWS, Azure, Google Cloud, Kubernetes and thousands of other services through <strong>providers</strong>.</p>
<p>It started in 2023 as a fork of Terraform 1.5 after HashiCorp changed Terraform's license. It is governed by the Linux Foundation and is now a CNCF project, and it keeps the MPL 2.0 open-source license. For a detailed comparison read <a href="https://www.itwonderlab.com/terraform-vs-opentofu/">Terraform vs OpenTofu</a>.</p>
<h3 id="how-it-works">How it works</h3>
<ol>
<li><strong>Write</strong> the configuration in <code>.tf</code> files.</li>
<li><strong><code>tofu init</code></strong> downloads the providers and modules and configures the <a href="https://www.itwonderlab.com/terraform-backend/">backend</a>.</li>
<li><strong><code>tofu plan</code></strong> reads the real state of the infrastructure and shows what will change.</li>
<li><strong><code>tofu apply</code></strong> executes the changes and records the result in the <a href="https://www.itwonderlab.com/terraform-state/">state</a>.</li>
<li><strong><code>tofu destroy</code></strong> removes everything that the configuration manages.</li>
</ol>
<h3 id="a-first-example">A first example</h3>
<figure class="code"><figcaption>main.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">terraform</span> {
<span class="hljs-keyword">  required_providers</span> {
<span class="hljs-attr">    aws</span> = {
<span class="hljs-attr">      source</span>  = <span class="hljs-string">"hashicorp/aws"</span>
<span class="hljs-attr">      version</span> = <span class="hljs-string">"~&gt; 5.0"</span>
    }
  }
}
<span class="hljs-keyword">
provider</span> <span class="hljs-string">"aws"</span> {
<span class="hljs-attr">  region</span> = <span class="hljs-string">"eu-west-1"</span>
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_s3_bucket"</span> <span class="hljs-string">"example"</span> {
<span class="hljs-attr">  bucket</span> = <span class="hljs-string">"my-unique-bucket-name-12345"</span>
}</code></pre></figure>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu init</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu plan</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu apply</span></code></pre>
<p>The configuration blocks are still named <code>terraform {}</code>, and the files still use the <code>.tf</code> extension, so existing code works without changes.</p>
<h3 id="features-that-stand-out">Features that stand out</h3>
<ul>
<li><strong>State and plan encryption</strong> on the client (<a href="https://www.itwonderlab.com/terraform-state-file-encryption/">how to encrypt the state</a>).</li>
<li><strong>Provider iteration with <code>for_each</code></strong> and <strong>early variable evaluation</strong> for module sources and backends.</li>
<li><strong>Built-in testing</strong> with <code>tofu test</code> (<a href="https://www.itwonderlab.com/terraform-testing-opentofu-test/">testing</a>).</li>
<li>An <strong>open registry</strong> at registry.opentofu.org, with the same providers and modules that Terraform uses.</li>
<li>A public roadmap driven by community RFCs.</li>
</ul>
<h3 id="who-uses-it">Who uses it</h3>
<p>OpenTofu is a replacement for teams that need an open-source license, that want a tool not controlled by a single company or that need state encryption. Platforms such as Spacelift, env0, Scalr and Terragrunt support it.</p>
<h3 id="start-learning">Start learning</h3>
<ol>
<li><a href="https://www.itwonderlab.com/how-to-install-opentofu/">Install OpenTofu</a>.</li>
<li>Follow the <a href="https://www.itwonderlab.com/aws-terraform-tutorial-terraform-basics/">AWS with Terraform series</a>, which uses OpenTofu in every example.</li>
<li>If you already use Terraform, <a href="https://www.itwonderlab.com/terraform-to-opentofu/">migrate your infrastructure</a>.</li>
<li>Learn the <a href="https://www.itwonderlab.com/terraform-cheat-sheet/">commands</a> and the recommended <a href="https://www.itwonderlab.com/terraform-project-structure/">project structure</a>.</li>
</ol>]]></content:encoded>
</item>
<item>
<title>Terraform lifecycle: prevent_destroy, create_before_destroy, ignore_changes</title>
<link>https://www.itwonderlab.com/terraform-lifecycle-meta-argument/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/terraform-lifecycle-meta-argument/</guid>
<pubDate>Wed, 16 Sep 2026 15:57:27 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>Terraform &amp; OpenTofu Tutorials</category>
<description>How to use the Terraform and OpenTofu lifecycle block: prevent_destroy, create_before_destroy, ignore_changes, replace_triggered_by and conditions.</description>
<content:encoded><![CDATA[<h2 id="control-how-terraform-creates-updates-and-destroys-a-resource">Control how Terraform creates, updates and destroys a resource</h2>
<p>The <code>lifecycle</code> block is available in every <code>resource</code> and changes the default behavior of the plan. For a different approach to a related problem, see <a href="https://www.itwonderlab.com/avoiding-instance-destroy-aws-with-terraform/">avoiding instance destroy in AWS</a>.</p>
<h3 id="prevent-destroy">prevent_destroy</h3>
<p>Makes any plan that would destroy the resource fail. Use it for databases, buckets with data and anything hard to recover:</p>
<figure class="code"><figcaption>prevent_destroy.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_db_instance"</span> <span class="hljs-string">"main"</span> {
<span class="hljs-attr">  identifier</span> = <span class="hljs-string">"ditwl-pro-db"</span>
  <span class="hljs-comment"># ...</span>
<span class="hljs-keyword">
  lifecycle</span> {
<span class="hljs-attr">    prevent_destroy</span> = <span class="hljs-literal">true</span>
  }
}</code></pre></figure>
<p>It does not protect against removing the resource and its <code>lifecycle</code> block from the code at the same time, so it is a safety net and not a security control. Use <a href="https://www.itwonderlab.com/aws-rds/">AWS deletion protection</a> too.</p>
<h3 id="create-before-destroy">create_before_destroy</h3>
<p>When a change forces replacement, Terraform destroys first and then creates. With <code>create_before_destroy</code> the new object is created first, so there is no gap:</p>
<figure class="code"><figcaption>create_before_destroy.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_launch_template"</span> <span class="hljs-string">"web"</span> {
<span class="hljs-attr">  name_prefix</span>   = <span class="hljs-string">"web-"</span>
<span class="hljs-attr">  image_id</span>      = <span class="hljs-built_in">data</span>.aws_ami.ubuntu.id
<span class="hljs-attr">  instance_type</span> = <span class="hljs-string">"t3.micro"</span>
<span class="hljs-keyword">
  lifecycle</span> {
<span class="hljs-attr">    create_before_destroy</span> = <span class="hljs-literal">true</span>
  }
}</code></pre></figure>
<p>Both objects exist for a moment, so unique names must not collide. Use <code>name_prefix</code> instead of <code>name</code>, as in the example. It is common in <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-auto-scaling/">auto scaling groups and load balancers</a>.</p>
<h3 id="ignore-changes">ignore_changes</h3>
<p>Tells Terraform not to react to differences in some attributes. It is useful when another system changes them, for example autoscaling changing <code>desired_capacity</code>:</p>
<figure class="code"><figcaption>ignore_changes.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_autoscaling_group"</span> <span class="hljs-string">"web"</span> {
  <span class="hljs-comment"># ...</span>
<span class="hljs-attr">  desired_capacity</span> = <span class="hljs-number">2</span>
<span class="hljs-keyword">
  lifecycle</span> {
<span class="hljs-attr">    ignore_changes</span> = [desired_capacity]
  }
}</code></pre></figure>
<p>Use <code>ignore_changes = all</code> only as a last resort, because Terraform will stop managing the resource.</p>
<h3 id="replace-triggered-by">replace_triggered_by</h3>
<p>Forces the replacement of a resource when another resource or attribute changes:</p>
<figure class="code"><figcaption>replace_triggered_by.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_instance"</span> <span class="hljs-string">"app"</span> {
<span class="hljs-attr">  ami</span>           = <span class="hljs-built_in">data</span>.aws_ami.ubuntu.id
<span class="hljs-attr">  instance_type</span> = <span class="hljs-string">"t3.micro"</span>
<span class="hljs-keyword">
  lifecycle</span> {
<span class="hljs-attr">    replace_triggered_by</span> = [aws_security_group.app.id]
  }
}</code></pre></figure>
<h3 id="precondition-and-postcondition">precondition and postcondition</h3>
<p>Custom checks that fail the plan or apply with your own message:</p>
<figure class="code"><figcaption>conditions.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">data</span> <span class="hljs-string">"aws_ami"</span> <span class="hljs-string">"ubuntu"</span> {
<span class="hljs-attr">  most_recent</span> = <span class="hljs-literal">true</span>
<span class="hljs-attr">  owners</span>      = [<span class="hljs-string">"099720109477"</span>]

  filter {
<span class="hljs-attr">    name</span>   = <span class="hljs-string">"name"</span>
<span class="hljs-attr">    values</span> = [<span class="hljs-string">"ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"</span>]
  }
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_instance"</span> <span class="hljs-string">"app"</span> {
<span class="hljs-attr">  ami</span>           = <span class="hljs-built_in">data</span>.aws_ami.ubuntu.id
<span class="hljs-attr">  instance_type</span> = <span class="hljs-built_in">var</span>.instance_type
<span class="hljs-keyword">
  lifecycle</span> {
    precondition {
<span class="hljs-attr">      condition</span>     = <span class="hljs-built_in">data</span>.aws_ami.ubuntu.architecture == <span class="hljs-string">"x86_64"</span>
<span class="hljs-attr">      error_message</span> = <span class="hljs-string">"The AMI must be x86_64."</span>
    }

    postcondition {
<span class="hljs-attr">      condition</span>     = <span class="hljs-built_in">self</span>.public_ip != <span class="hljs-string">""</span>
<span class="hljs-attr">      error_message</span> = <span class="hljs-string">"The instance must have a public IP."</span>
    }
  }
}</code></pre></figure>
<p>A <code>precondition</code> is evaluated before creating the resource and a <code>postcondition</code> after, with <code>self</code> pointing to the result.</p>
<h3 id="summary">Summary</h3>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Argument</th>
<th>Use it for</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>prevent_destroy</code></td>
<td>Protect critical resources from accidental destruction</td>
</tr>
<tr>
<td><code>create_before_destroy</code></td>
<td>Replace without downtime</td>
</tr>
<tr>
<td><code>ignore_changes</code></td>
<td>Attributes changed outside of Terraform</td>
</tr>
<tr>
<td><code>replace_triggered_by</code></td>
<td>Replace a resource when a dependency changes</td>
</tr>
<tr>
<td><code>precondition</code> / <code>postcondition</code></td>
<td>Validate assumptions with clear errors</td>
</tr>
</tbody>
</table></div>
<p><code>lifecycle</code> arguments must be literal values: they cannot use variables or expressions. Related: <a href="https://www.itwonderlab.com/terraform-import-moved-removed/">import, moved and removed</a>.</p>]]></content:encoded>
</item>
<item>
<title>Terraform import, moved and removed Blocks: Refactor Without Destroying</title>
<link>https://www.itwonderlab.com/terraform-import-moved-removed/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/terraform-import-moved-removed/</guid>
<pubDate>Mon, 07 Sep 2026 12:47:55 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>Terraform &amp; OpenTofu Tutorials</category>
<description>How to import existing AWS resources into Terraform or OpenTofu, rename them with moved blocks and stop managing them with removed blocks, without downtime.</description>
<content:encoded><![CDATA[<h2 id="change-what-terraform-manages-without-touching-the-infrastructure">Change what Terraform manages without touching the infrastructure</h2>
<p>Sooner or later you need to bring existing infrastructure under Terraform, rename a resource, move it into a <a href="https://www.itwonderlab.com/terraform-module/">module</a> or stop managing it. All of these change the <a href="https://www.itwonderlab.com/terraform-state/">state</a> and the code, but must <strong>not</strong> change the real infrastructure. Terraform (1.5 and later) and <a href="https://www.itwonderlab.com/opentofu/">OpenTofu</a> (1.6 and later) do it with configuration blocks that go through <code>plan</code> and code review, instead of the old imperative commands.</p>
<h3 id="import-blocks-adopt-existing-resources">import blocks: adopt existing resources</h3>
<p>Suppose an <a href="https://www.itwonderlab.com/aws-s3/">S3</a> bucket was created by hand in the console:</p>
<figure class="code"><figcaption>import.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">import</span> {
<span class="hljs-attr">  to</span> = aws_s3_bucket.legacy
<span class="hljs-attr">  id</span> = <span class="hljs-string">"ditwl-legacy-bucket"</span>
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_s3_bucket"</span> <span class="hljs-string">"legacy"</span> {
<span class="hljs-attr">  bucket</span> = <span class="hljs-string">"ditwl-legacy-bucket"</span>
}</code></pre></figure>
<p><code>terraform plan</code> shows that the resource will be imported, and <code>apply</code> adds it to the state. The <code>id</code> format depends on the resource and is documented at the end of each resource page in the provider documentation.</p>
<p>You can ask Terraform to write the resource code for you:</p>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform plan -generate-config-out=generated.tf</span></code></pre>
<p>Review the generated file before using it: it contains every attribute, including some computed ones that you should remove.</p>
<p>After the import is applied you can delete the <code>import</code> block. With <code>for_each</code> in the <code>import</code> block (supported in current versions) you can import many resources at once.</p>
<aside class="note note-tip" role="note"><p class="note-title">Tip</p>
<p>After importing, run <code>plan</code> again. A clean plan with no changes confirms that the code matches reality. If the plan wants to change something, adjust the code until it does not.</p>
</aside>
<h3 id="moved-blocks-rename-or-relocate-a-resource">moved blocks: rename or relocate a resource</h3>
<p>Renaming <code>aws_instance.web</code> to <code>aws_instance.app</code> makes Terraform destroy one and create the other. A <code>moved</code> block tells it that it is the same object:</p>
<figure class="code"><figcaption>moved.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">moved</span> {
<span class="hljs-attr">  from</span> = aws_instance.web
<span class="hljs-attr">  to</span>   = aws_instance.app
}</code></pre></figure>
<p>It also works to move a resource into a module, or between modules:</p>
<figure class="code"><figcaption>moved-module.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">moved</span> {
<span class="hljs-attr">  from</span> = aws_vpc.main
<span class="hljs-attr">  to</span>   = <span class="hljs-built_in">module</span>.network.aws_vpc.main
}</code></pre></figure>
<p>Keep the <code>moved</code> blocks while there may be other users of the module with the old state, then you can remove them. Modules published for others should keep them.</p>
<h3 id="removed-blocks-stop-managing-without-destroying">removed blocks: stop managing without destroying</h3>
<p>To remove a resource from the code <strong>without</strong> deleting the real infrastructure, use a <code>removed</code> block (Terraform 1.7 and OpenTofu 1.7 and later):</p>
<figure class="code"><figcaption>removed.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">removed</span> {
<span class="hljs-attr">  from</span> = aws_s3_bucket.legacy
<span class="hljs-keyword">
  lifecycle</span> {
<span class="hljs-attr">    destroy</span> = <span class="hljs-literal">false</span>
  }
}</code></pre></figure>
<p>The resource is forgotten by the state, and the bucket keeps existing. Without <code>destroy = false</code>, removing the block from the code means <code>destroy</code>.</p>
<h3 id="the-command-line-equivalents">The command line equivalents</h3>
<p>Before these blocks existed the same was done with commands. You may still find them in older tutorials:</p>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform import aws_s3_bucket.legacy ditwl-legacy-bucket</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform state <span class="hljs-built_in">mv</span> aws_instance.web aws_instance.app</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform state <span class="hljs-built_in">rm</span> aws_s3_bucket.legacy</span></code></pre>
<p>They change the state immediately and without a plan, so they are easy to get wrong and leave nothing in the code history. Prefer the blocks, especially in a team or in a CI/CD pipeline.</p>
<h3 id="checklist">Checklist</h3>
<ol>
<li>Back up the state (or use a versioned <a href="https://www.itwonderlab.com/terraform-backend/">backend</a>).</li>
<li>Write the block and run <code>plan</code>.</li>
<li>Confirm the plan says import or move, with <strong>no</strong> destroy.</li>
<li>Apply, then run <code>plan</code> again to confirm no changes remain.</li>
</ol>
<p>Related: <a href="https://www.itwonderlab.com/terraform-lifecycle-meta-argument/">lifecycle</a>, <a href="https://www.itwonderlab.com/terraform-for-each-vs-count/">for_each vs count</a> and <a href="https://www.itwonderlab.com/terraform-to-opentofu/">migrating from Terraform to OpenTofu</a>.</p>]]></content:encoded>
</item>
<item>
<title>Terraform -replace, taint and -target: Recreate or Limit Resources Safely</title>
<link>https://www.itwonderlab.com/terraform-replace-taint-target/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/terraform-replace-taint-target/</guid>
<pubDate>Sat, 05 Sep 2026 18:05:00 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>Terraform &amp; OpenTofu Tutorials</category>
<description>How to force Terraform and OpenTofu to recreate a resource with -replace (the replacement for taint), when to use -target and how to avoid its risks.</description>
<content:encoded><![CDATA[<h2 id="recreate-a-resource-or-limit-a-run">Recreate a resource or limit a run</h2>
<p>Sometimes Terraform thinks that a resource is fine but you know it is not: an instance with a broken configuration, a corrupted node, a failed provisioner. Other times you want to apply only part of the configuration. Three tools exist, and the first is the safest.</p>
<h3 id="-replace-force-recreation">-replace: force recreation</h3>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform plan -replace=aws_instance.web</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform apply -replace=aws_instance.web</span></code></pre>
<p>The plan shows the resource as <code># aws_instance.web will be replaced, as requested</code> and, since it goes through the normal plan, you can review exactly what will be destroyed and created before confirming. For resources with <code>count</code> or <code>for_each</code>, quote the address:</p>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform apply -replace=<span class="hljs-string">'aws_instance.web[1]'</span></span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform apply -replace=<span class="hljs-string">'aws_subnet.private["b"]'</span></span></code></pre>
<p>You can repeat the flag to replace several resources.</p>
<h3 id="taint-and-untaint-the-old-way">taint and untaint: the old way</h3>
<p><code>terraform taint</code> marks a resource in the <a href="https://www.itwonderlab.com/terraform-state/">state</a> as damaged, so the next apply replaces it:</p>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform taint aws_instance.web</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform untaint aws_instance.web</span></code></pre>
<p>It is <strong>deprecated</strong> in favor of <code>-replace</code>. The problem with <code>taint</code> is that it changes the state immediately and without a plan, so a teammate might apply while the mark is there without realizing it. Use <code>-replace</code> instead.</p>
<p>A provisioner failure (<a href="https://www.itwonderlab.com/terraform-provisioners-user-data/">provisioners</a>) still taints a resource automatically.</p>
<h3 id="-target-apply-only-part-of-the-graph">-target: apply only part of the graph</h3>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform plan -target=module.network</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform apply -target=aws_security_group.web</span></code></pre>
<p><code>-target</code> limits the plan to the resource and what it <strong>depends on</strong>. It is useful in emergencies, for example to fix a broken resource when the rest of the configuration has an error, or to create a resource that a <code>for_each</code> depends on (see <a href="https://www.itwonderlab.com/terraform-common-errors/">common errors</a>).</p>
<p>Terraform itself warns that it is for exceptional use. The risks:</p>
<ul>
<li>The state and the code can diverge, because other resources that should change are skipped.</li>
<li>It skips dependents, so you may leave the infrastructure in an inconsistent state.</li>
<li>It becomes a habit that hides structural problems, such as a state that is too big (<a href="https://www.itwonderlab.com/terraform-project-structure/">project structure</a>).</li>
</ul>
<p>Run a full <code>plan</code> afterwards to confirm that nothing remains. <a href="https://www.itwonderlab.com/opentofu/">OpenTofu</a> 1.9 and later also has <code>-exclude</code>, the opposite option, to leave out resources.</p>
<h3 id="other-targeted-operations">Other targeted operations</h3>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Goal</th>
<th>Command</th>
</tr>
</thead>
<tbody>
<tr>
<td>Update the state without changing infrastructure</td>
<td><code>terraform apply -refresh-only</code></td>
</tr>
<tr>
<td>Destroy one resource</td>
<td><code>terraform destroy -target=aws_instance.web</code></td>
</tr>
<tr>
<td>Stop managing a resource without deleting it</td>
<td><code>removed</code> block (<a href="https://www.itwonderlab.com/terraform-import-moved-removed/">guide</a>)</td>
</tr>
<tr>
<td>Rename in state</td>
<td><code>moved</code> block</td>
</tr>
<tr>
<td>Ignore changes made elsewhere</td>
<td><code>ignore_changes</code> (<a href="https://www.itwonderlab.com/terraform-lifecycle-meta-argument/">lifecycle</a>)</td>
</tr>
</tbody>
</table></div>
<h3 id="recreating-without-downtime">Recreating without downtime</h3>
<p>If the resource serves traffic, use <code>create_before_destroy</code> in its <a href="https://www.itwonderlab.com/terraform-lifecycle-meta-argument/">lifecycle</a> so the new one exists before the old one is deleted. With <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-auto-scaling/">Auto Scaling</a>, prefer an instance refresh over replacing instances by hand.</p>
<h3 id="in-cicd">In CI/CD</h3>
<p>Avoid <code>-target</code> in pipelines. Apply the saved plan of the whole configuration (<a href="https://www.itwonderlab.com/terraform-github-actions-aws-oidc/">GitHub Actions</a>). Use <code>-replace</code> only through a reviewed manual workflow.</p>]]></content:encoded>
</item>
<item>
<title>Terraform vs OpenTofu: Differences, License and Which One to Choose</title>
<link>https://www.itwonderlab.com/terraform-vs-opentofu/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/terraform-vs-opentofu/</guid>
<pubDate>Wed, 02 Sep 2026 10:00:42 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>Terraform &amp; OpenTofu Tutorials</category>
<description>Compare Terraform and OpenTofu: license, governance, compatibility, exclusive features such as state encryption, and how to decide which one to use.</description>
<content:encoded><![CDATA[<h2 id="why-there-are-two-tools">Why there are two tools</h2>
<p>In August 2023 HashiCorp changed the license of Terraform from the open-source MPL 2.0 to the Business Source License (BSL 1.1). The community answered with a fork, first called OpenTF and now <a href="https://www.itwonderlab.com/what-is-opentofu/">OpenTofu</a>, which became a project of the Linux Foundation and was later accepted into the CNCF. <a href="https://www.itwonderlab.com/opentofu/">OpenTofu</a> keeps the MPL 2.0 license. HashiCorp was later acquired by IBM, but the Terraform license did not return to open source.</p>
<p>Both tools read the same <a href="https://www.itwonderlab.com/hcl/">HCL</a> language and use the same providers and modules, so for most people the day-to-day commands are identical: <code>tofu init</code>, <code>tofu plan</code> and <code>tofu apply</code>.</p>
<h3 id="comparison">Comparison</h3>
<div class="table-wrap"><table>
<thead>
<tr>
<th></th>
<th>Terraform</th>
<th>OpenTofu</th>
</tr>
</thead>
<tbody>
<tr>
<td>License</td>
<td>BSL 1.1 (source available)</td>
<td>MPL 2.0 (open source)</td>
</tr>
<tr>
<td>Governance</td>
<td>IBM / HashiCorp</td>
<td>Linux Foundation, community</td>
</tr>
<tr>
<td>Command</td>
<td><code>terraform</code></td>
<td><code>tofu</code></td>
</tr>
<tr>
<td>Registry</td>
<td>registry.terraform.io</td>
<td>registry.opentofu.org</td>
</tr>
<tr>
<td>Providers and modules</td>
<td>Yes</td>
<td>Yes (same ones)</td>
</tr>
<tr>
<td>State and backends</td>
<td>Yes</td>
<td>Yes, compatible</td>
</tr>
<tr>
<td>Client-side state encryption</td>
<td>No</td>
<td>Yes (<a href="https://www.itwonderlab.com/terraform-state-file-encryption/">state encryption</a>)</td>
</tr>
<tr>
<td>Provider <code>for_each</code></td>
<td>No</td>
<td>Yes (1.9 and later)</td>
</tr>
<tr>
<td>Native tests</td>
<td>Yes</td>
<td>Yes</td>
</tr>
<tr>
<td>Managed platform</td>
<td>HCP Terraform</td>
<td>Third parties: Spacelift, env0, Scalr and others</td>
</tr>
</tbody>
</table></div>
<p>Features change in every release, so check the current release notes of both projects before deciding.</p>
<h3 id="what-the-bsl-license-means-for-you">What the BSL license means for you</h3>
<p>The BSL does not prevent you from using Terraform to manage your own or your customers' infrastructure. It restricts using it to build a product that <strong>competes</strong> with HashiCorp's commercial offerings. If you are a company that only deploys infrastructure, you are probably not affected, but legal teams of some companies prefer an open-source license, and some Linux distributions and cloud vendors cannot redistribute BSL software.</p>
<h3 id="differences-in-practice">Differences in practice</h3>
<ul>
<li><strong>State encryption</strong> in OpenTofu protects the state and plan files without depending on the backend. This is a significant advantage when state contains secrets (<a href="https://www.itwonderlab.com/terraform-secrets-management/">secrets management</a>).</li>
<li><strong>Early variable evaluation</strong> (1.8) allows variables and locals in module sources and backend configuration, something Terraform does not support.</li>
<li><strong>Provider <code>for_each</code></strong> (1.9) lets you create one provider configuration per region or account in a loop.</li>
<li><strong>Terraform-only features:</strong> HCP Terraform integration, ephemeral resources and write-only arguments appeared first in Terraform (OpenTofu has since added support for ephemeral resources, check your version), and Terraform Stacks.</li>
</ul>
<h3 id="compatibility-and-migration">Compatibility and migration</h3>
<p>OpenTofu 1.6 was compatible with Terraform 1.5 and the 1.6 series. Since then both evolve independently, so the compatibility with the latest Terraform versions can not be assumed. Migration is simple for most projects and reversible while you stay inside the common feature set. See the <a href="https://www.itwonderlab.com/terraform-to-opentofu/">migration tutorial</a> and the <a href="https://www.itwonderlab.com/how-to-install-opentofu/">installation guide</a>.</p>
<h3 id="which-one-should-you-choose">Which one should you choose?</h3>
<ul>
<li><strong>OpenTofu</strong> if you want an open-source license, state encryption, or you are starting a new project and have no HCP Terraform dependency.</li>
<li><strong>Terraform</strong> if you use HCP Terraform, Sentinel or Stacks, or your company has a support contract with HashiCorp.</li>
<li>Either one if the team is small and nothing above matters. You can write code that works with both by avoiding exclusive features.</li>
</ul>
<p>All the tutorials in this site run on both. See the <a href="https://www.itwonderlab.com/tutorials/aws/">AWS with Terraform series</a> for a complete example.</p>]]></content:encoded>
</item>
<item>
<title>Terraform Variables, Outputs and Locals Explained with Examples</title>
<link>https://www.itwonderlab.com/terraform-variables-outputs-locals/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/terraform-variables-outputs-locals/</guid>
<pubDate>Mon, 24 Aug 2026 05:58:32 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>Terraform &amp; OpenTofu Tutorials</category>
<description>How to use input variables, validation, outputs and local values in Terraform and OpenTofu, and how to set variables with tfvars files and environment variables.</description>
<content:encoded><![CDATA[<h2 id="input-variables-output-values-and-local-values">Input variables, output values and local values</h2>
<p>A Terraform or <a href="https://www.itwonderlab.com/opentofu/">OpenTofu</a> configuration becomes reusable when it stops having fixed values inside the resources. Three blocks of <a href="https://www.itwonderlab.com/hcl/">HCL</a> take care of this:</p>
<ul>
<li><strong><code>variable</code></strong>: an input of the configuration or of a <a href="https://www.itwonderlab.com/terraform-module/">module</a>.</li>
<li><strong><code>output</code></strong>: a value that the configuration returns, shown after <code>apply</code> and available to other configurations.</li>
<li><strong><code>locals</code></strong>: a named expression calculated inside the configuration, to avoid repeating it.</li>
</ul>
<h3 id="input-variables">Input variables</h3>
<figure class="code"><figcaption>variables.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">variable</span> <span class="hljs-string">"environment"</span> {
<span class="hljs-attr">  type</span>        = string
<span class="hljs-attr">  description</span> = <span class="hljs-string">"Environment name"</span>
<span class="hljs-attr">  default</span>     = <span class="hljs-string">"dev"</span>

  validation {
<span class="hljs-attr">    condition</span>     = contains([<span class="hljs-string">"dev"</span>, <span class="hljs-string">"pre"</span>, <span class="hljs-string">"pro"</span>], <span class="hljs-built_in">var</span>.environment)
<span class="hljs-attr">    error_message</span> = <span class="hljs-string">"The environment must be dev, pre or pro."</span>
  }
}
<span class="hljs-keyword">
variable</span> <span class="hljs-string">"instance_count"</span> {
<span class="hljs-attr">  type</span>    = number
<span class="hljs-attr">  default</span> = <span class="hljs-number">1</span>
}
<span class="hljs-keyword">
variable</span> <span class="hljs-string">"tags"</span> {
<span class="hljs-attr">  type</span>    = map(string)
<span class="hljs-attr">  default</span> = {}
}
<span class="hljs-keyword">
variable</span> <span class="hljs-string">"db_password"</span> {
<span class="hljs-attr">  type</span>      = string
<span class="hljs-attr">  sensitive</span> = <span class="hljs-literal">true</span>
}</code></pre></figure>
<p>Use the value with <code>var.&lt;name&gt;</code>:</p>
<figure class="code"><figcaption>main.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_instance"</span> <span class="hljs-string">"web"</span> {
<span class="hljs-attr">  count</span>         = <span class="hljs-built_in">var</span>.instance_count
<span class="hljs-attr">  ami</span>           = <span class="hljs-built_in">data</span>.aws_ami.ubuntu.id
<span class="hljs-attr">  instance_type</span> = <span class="hljs-string">"t3.micro"</span>
<span class="hljs-attr">  tags</span>          = merge(<span class="hljs-built_in">var</span>.tags, { Environment = <span class="hljs-built_in">var</span>.environment })
}</code></pre></figure>
<p>Types can be <code>string</code>, <code>number</code>, <code>bool</code>, <code>list(...)</code>, <code>set(...)</code>, <code>map(...)</code>, <code>object({...})</code> and <code>tuple([...])</code>. A variable without <code>default</code> is required.</p>
<h3 id="setting-the-values">Setting the values</h3>
<p>Terraform looks for values in this order, the last one wins:</p>
<ol>
<li>The <code>default</code> of the variable.</li>
<li>Environment variables named <code>TF_VAR_&lt;name&gt;</code>.</li>
<li><code>terraform.tfvars</code> and <code>terraform.tfvars.json</code>.</li>
<li>Files <code>*.auto.tfvars</code> and <code>*.auto.tfvars.json</code>, in alphabetical order.</li>
<li><code>-var-file</code> and <code>-var</code> on the command line.</li>
</ol>
<figure class="code"><figcaption>pro.tfvars</figcaption><pre><code class="hljs language-hcl"><span class="hljs-attr">environment</span>    = <span class="hljs-string">"pro"</span>
<span class="hljs-attr">instance_count</span> = <span class="hljs-number">3</span>
<span class="hljs-attr">tags</span> = {
<span class="hljs-attr">  Project</span> = <span class="hljs-string">"demo"</span>
}</code></pre></figure>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu plan -var-file=pro.tfvars</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">TF_VAR_db_password=<span class="hljs-string">'S3cret'</span> tofu apply</span></code></pre>
<aside class="note note-warning" role="note"><p class="note-title">Warning</p>
<p><code>sensitive = true</code> hides the value in the plan output, but the value is still stored in plain text in the <a href="https://www.itwonderlab.com/terraform-state/">state file</a>. Protect the state with an encrypted <a href="https://www.itwonderlab.com/terraform-backend/">backend</a> or with <a href="https://www.itwonderlab.com/terraform-state-file-encryption/">state encryption in OpenTofu</a>. See also <a href="https://www.itwonderlab.com/terraform-secrets-management/">secrets management</a>.</p>
</aside>
<h3 id="local-values">Local values</h3>
<p>Use <code>locals</code> for expressions that you repeat or to give a name to a calculation:</p>
<figure class="code"><figcaption>locals.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">locals</span> {
<span class="hljs-attr">  name_prefix</span> = <span class="hljs-string">"ditwl-<span class="hljs-subst">${var.environment}</span>"</span>
<span class="hljs-attr">
  common_tags</span> = merge(<span class="hljs-built_in">var</span>.tags, {
<span class="hljs-attr">    Environment</span> = <span class="hljs-built_in">var</span>.environment
<span class="hljs-attr">    ManagedBy</span>   = <span class="hljs-string">"opentofu"</span>
  })
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_s3_bucket"</span> <span class="hljs-string">"logs"</span> {
<span class="hljs-attr">  bucket</span> = <span class="hljs-string">"<span class="hljs-subst">${local.name_prefix}</span>-logs"</span>
<span class="hljs-attr">  tags</span>   = <span class="hljs-built_in">local</span>.common_tags
}</code></pre></figure>
<p>Locals are not inputs: users of your module cannot change them. A good rule is to use variables for what the caller decides and locals for what is derived.</p>
<h3 id="output-values">Output values</h3>
<figure class="code"><figcaption>outputs.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">output</span> <span class="hljs-string">"vpc_id"</span> {
<span class="hljs-attr">  description</span> = <span class="hljs-string">"ID of the VPC"</span>
<span class="hljs-attr">  value</span>       = aws_vpc.main.id
}
<span class="hljs-keyword">
output</span> <span class="hljs-string">"db_endpoint"</span> {
<span class="hljs-attr">  value</span>     = aws_db_instance.main.endpoint
<span class="hljs-attr">  sensitive</span> = <span class="hljs-literal">true</span>
}</code></pre></figure>
<p>After <code>apply</code> the values are printed, and you can read them any time:</p>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu output vpc_id</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu output -json</span></code></pre>
<p>In a module, the outputs are the only way to expose values to the caller: <code>module.network.vpc_id</code>. To read the outputs of another configuration use <a href="https://www.itwonderlab.com/terraform-data-sources-remote-state/">terraform_remote_state</a>.</p>
<h3 id="variable-validation-and-preconditions">Variable validation and preconditions</h3>
<p><code>validation</code> blocks check a variable before the plan. For checks that involve resources or data sources, use <code>precondition</code> and <code>postcondition</code> inside <code>lifecycle</code> (see <a href="https://www.itwonderlab.com/terraform-lifecycle-meta-argument/">lifecycle</a>).</p>
<h3 id="file-organization">File organization</h3>
<p>By convention, a configuration has <code>main.tf</code>, <code>variables.tf</code>, <code>outputs.tf</code>, <code>providers.tf</code> and <code>versions.tf</code>. Terraform loads every <code>.tf</code> file in the directory, so the names are only a convention. See <a href="https://www.itwonderlab.com/terraform-project-structure/">project structure</a>.</p>]]></content:encoded>
</item>
<item>
<title>AWS IAM Roles and Policies with Terraform and OpenTofu</title>
<link>https://www.itwonderlab.com/aws-terraform-tutorial-aws-iam-roles-policies/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/aws-terraform-tutorial-aws-iam-roles-policies/</guid>
<pubDate>Sat, 22 Aug 2026 08:43:41 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>Terraform &amp; OpenTofu Tutorials</category>
<description>How to create IAM roles, policies, instance profiles and assume-role trust relationships with Terraform or OpenTofu, following least privilege, with AWS examples.</description>
<content:encoded><![CDATA[<h2 id="how-to-create-aws-iam-roles-and-policies-with-terraform">How to create AWS IAM roles and policies with Terraform</h2>
<p><a href="https://www.itwonderlab.com/aws-iam/">AWS IAM</a> decides who can do what in an AWS account. In Terraform you work with four objects: <strong>policies</strong> (what is allowed), <strong>roles</strong> (an identity that someone or something can assume), <strong>trust policies</strong> (who can assume the role) and <strong>instance profiles</strong> (how an <a href="https://www.itwonderlab.com/aws-ec2/">EC2</a> instance uses a role). For users, see <a href="https://www.itwonderlab.com/terraform-aws-iam-users/">IAM users with Terraform</a>.</p>
<h3 id="the-two-policies-of-a-role">The two policies of a role</h3>
<p>Every role has:</p>
<ol>
<li>A <strong>trust policy</strong> (<em>assume role policy</em>): which principal can assume the role (an EC2 instance, a <a href="https://www.itwonderlab.com/aws-lambda/">Lambda function</a>, another account, GitHub).</li>
<li>One or more <strong>permission policies</strong>: what the role can do once assumed.</li>
</ol>
<h3 id="build-policies-with-aws-iam-policy-document">Build policies with aws_iam_policy_document</h3>
<p>Writing JSON by hand is error-prone. The <code>aws_iam_policy_document</code> data source builds it in <a href="https://www.itwonderlab.com/hcl/">HCL</a> and lets you use references:</p>
<figure class="code"><figcaption>iam.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">data</span> <span class="hljs-string">"aws_iam_policy_document"</span> <span class="hljs-string">"ec2_assume"</span> {
  statement {
<span class="hljs-attr">    actions</span> = [<span class="hljs-string">"sts:AssumeRole"</span>]

    principals {
<span class="hljs-attr">      type</span>        = <span class="hljs-string">"Service"</span>
<span class="hljs-attr">      identifiers</span> = [<span class="hljs-string">"ec2.amazonaws.com"</span>]
    }
  }
}
<span class="hljs-keyword">
data</span> <span class="hljs-string">"aws_iam_policy_document"</span> <span class="hljs-string">"read_bucket"</span> {
  statement {
<span class="hljs-attr">    sid</span>       = <span class="hljs-string">"ListBucket"</span>
<span class="hljs-attr">    actions</span>   = [<span class="hljs-string">"s3:ListBucket"</span>]
<span class="hljs-attr">    resources</span> = [aws_s3_bucket.assets.arn]
  }

  statement {
<span class="hljs-attr">    sid</span>       = <span class="hljs-string">"ReadObjects"</span>
<span class="hljs-attr">    actions</span>   = [<span class="hljs-string">"s3:GetObject"</span>]
<span class="hljs-attr">    resources</span> = [<span class="hljs-string">"<span class="hljs-subst">${aws_s3_bucket.assets.arn}</span>/*"</span>]
  }
}</code></pre></figure>
<h3 id="role-policy-and-attachment">Role, policy and attachment</h3>
<figure class="code"><figcaption>iam.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_iam_role"</span> <span class="hljs-string">"app"</span> {
<span class="hljs-attr">  name</span>               = <span class="hljs-string">"ditwl-pro-app-role"</span>
<span class="hljs-attr">  assume_role_policy</span> = <span class="hljs-built_in">data</span>.aws_iam_policy_document.ec2_assume.json
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_iam_policy"</span> <span class="hljs-string">"read_bucket"</span> {
<span class="hljs-attr">  name</span>   = <span class="hljs-string">"ditwl-pro-app-read-bucket"</span>
<span class="hljs-attr">  policy</span> = <span class="hljs-built_in">data</span>.aws_iam_policy_document.read_bucket.json
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_iam_role_policy_attachment"</span> <span class="hljs-string">"read_bucket"</span> {
<span class="hljs-attr">  role</span>       = aws_iam_role.app.name
<span class="hljs-attr">  policy_arn</span> = aws_iam_policy.read_bucket.arn
}

<span class="hljs-comment"># AWS managed policy, for example to use Systems Manager Session Manager</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_iam_role_policy_attachment"</span> <span class="hljs-string">"ssm"</span> {
<span class="hljs-attr">  role</span>       = aws_iam_role.app.name
<span class="hljs-attr">  policy_arn</span> = <span class="hljs-string">"arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"</span>
}</code></pre></figure>
<p>An alternative for permissions that belong to only one role is <code>aws_iam_role_policy</code>, an inline policy. Avoid <code>aws_iam_policy_attachment</code> (without <code>role_</code>), which takes exclusive ownership of the policy and can detach it from other identities.</p>
<h3 id="instance-profile-for-ec2">Instance profile for EC2</h3>
<p>EC2 instances use roles through an instance profile:</p>
<figure class="code"><figcaption>ec2.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_iam_instance_profile"</span> <span class="hljs-string">"app"</span> {
<span class="hljs-attr">  name</span> = <span class="hljs-string">"ditwl-pro-app-profile"</span>
<span class="hljs-attr">  role</span> = aws_iam_role.app.name
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_instance"</span> <span class="hljs-string">"app"</span> {
<span class="hljs-attr">  ami</span>                  = <span class="hljs-built_in">data</span>.aws_ami.ubuntu.id
<span class="hljs-attr">  instance_type</span>        = <span class="hljs-string">"t3.micro"</span>
<span class="hljs-attr">  iam_instance_profile</span> = aws_iam_instance_profile.app.name
}</code></pre></figure>
<p>The application on the instance now gets temporary credentials from the metadata service. No access keys are stored on disk. See <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-ec2/">EC2 with Terraform</a>.</p>
<h3 id="allow-another-account-or-a-person-to-assume-a-role">Allow another account or a person to assume a role</h3>
<figure class="code"><figcaption>cross-account.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">data</span> <span class="hljs-string">"aws_iam_policy_document"</span> <span class="hljs-string">"cross_account"</span> {
  statement {
<span class="hljs-attr">    actions</span> = [<span class="hljs-string">"sts:AssumeRole"</span>]

    principals {
<span class="hljs-attr">      type</span>        = <span class="hljs-string">"AWS"</span>
<span class="hljs-attr">      identifiers</span> = [<span class="hljs-string">"arn:aws:iam::111111111111:root"</span>]
    }

    condition {
<span class="hljs-attr">      test</span>     = <span class="hljs-string">"Bool"</span>
<span class="hljs-keyword">      variable</span> = <span class="hljs-string">"aws:MultiFactorAuthPresent"</span>
<span class="hljs-attr">      values</span>   = [<span class="hljs-string">"true"</span>]
    }
  }
}</code></pre></figure>
<p>The same pattern is used to give a CI/CD pipeline access: <a href="https://www.itwonderlab.com/terraform-github-actions-aws-oidc/">GitHub Actions with OIDC</a>.</p>
<h3 id="permission-boundaries">Permission boundaries</h3>
<p>A permission boundary is a policy that sets the <strong>maximum</strong> permissions of a role, whatever policies are attached. It is useful when you let developers create their own roles:</p>
<figure class="code"><figcaption>boundary.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_iam_role"</span> <span class="hljs-string">"developer_created"</span> {
<span class="hljs-attr">  name</span>                 = <span class="hljs-string">"app-role"</span>
<span class="hljs-attr">  assume_role_policy</span>   = <span class="hljs-built_in">data</span>.aws_iam_policy_document.ec2_assume.json
<span class="hljs-attr">  permissions_boundary</span> = aws_iam_policy.boundary.arn
}</code></pre></figure>
<h3 id="least-privilege-checklist">Least privilege checklist</h3>
<ul>
<li>Name actions and resources explicitly. Avoid <code>"Action": "*"</code> and <code>"Resource": "*"</code>.</li>
<li>Use conditions (source <a href="https://www.itwonderlab.com/aws-vpc/">VPC</a>, MFA, tags, <code>aws:PrincipalOrgID</code>).</li>
<li>One role per application and per environment.</li>
<li>Prefer roles to users and temporary to long-lived credentials.</li>
<li>Review the findings of IAM Access Analyzer and the "last used" information.</li>
<li>Scan the code with <a href="https://www.itwonderlab.com/terraform-security-scanning-tflint-checkov-trivy/">Checkov or Trivy</a>.</li>
</ul>
<aside class="note note-warning" role="note"><p class="note-title">Warning</p>
<p>Never create IAM users with access keys through Terraform for applications: the secret ends up in the <a href="https://www.itwonderlab.com/terraform-state/">state</a>. Use roles.</p>
</aside>
<p>Next: <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-s3/">S3 with Terraform</a>.</p>]]></content:encoded>
</item>
<item>
<title>Terraform and OpenTofu with GitHub Actions and AWS OIDC (No Access Keys)</title>
<link>https://www.itwonderlab.com/terraform-github-actions-aws-oidc/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/terraform-github-actions-aws-oidc/</guid>
<pubDate>Sun, 16 Aug 2026 03:30:21 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>Terraform &amp; OpenTofu Tutorials</category>
<description>Run Terraform or OpenTofu plan on pull requests and apply on merge with GitHub Actions, authenticating to AWS with OIDC and short-lived credentials.</description>
<content:encoded><![CDATA[<h2 id="a-cicd-pipeline-for-infrastructure-without-stored-aws-keys">A CI/CD pipeline for infrastructure without stored AWS keys</h2>
<p>The <a href="https://www.itwonderlab.com/aws-terraform-tutorial-terraform-cicd/">Terraform CI/CD tutorial</a> explains the concepts. This guide shows a concrete pipeline for GitHub Actions. The key point is authentication: instead of saving an AWS access key and secret in GitHub, the workflow proves its identity to AWS with an <strong>OpenID Connect (OIDC)</strong> token and receives temporary credentials.</p>
<h3 id="1-create-the-oidc-provider-and-the-role-in-aws">1. Create the OIDC provider and the role in AWS</h3>
<p>Create these resources once, with Terraform, in the AWS account where the pipeline will deploy:</p>
<figure class="code"><figcaption>github-oidc.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_iam_openid_connect_provider"</span> <span class="hljs-string">"github"</span> {
<span class="hljs-attr">  url</span>            = <span class="hljs-string">"https://token.actions.githubusercontent.com"</span>
<span class="hljs-attr">  client_id_list</span> = [<span class="hljs-string">"sts.amazonaws.com"</span>]
}
<span class="hljs-keyword">
data</span> <span class="hljs-string">"aws_iam_policy_document"</span> <span class="hljs-string">"assume"</span> {
  statement {
<span class="hljs-attr">    actions</span> = [<span class="hljs-string">"sts:AssumeRoleWithWebIdentity"</span>]

    principals {
<span class="hljs-attr">      type</span>        = <span class="hljs-string">"Federated"</span>
<span class="hljs-attr">      identifiers</span> = [aws_iam_openid_connect_provider.github.arn]
    }

    condition {
<span class="hljs-attr">      test</span>     = <span class="hljs-string">"StringEquals"</span>
<span class="hljs-keyword">      variable</span> = <span class="hljs-string">"token.actions.githubusercontent.com:aud"</span>
<span class="hljs-attr">      values</span>   = [<span class="hljs-string">"sts.amazonaws.com"</span>]
    }

    condition {
<span class="hljs-attr">      test</span>     = <span class="hljs-string">"StringLike"</span>
<span class="hljs-keyword">      variable</span> = <span class="hljs-string">"token.actions.githubusercontent.com:sub"</span>
<span class="hljs-attr">      values</span>   = [<span class="hljs-string">"repo:my-org/my-infra:*"</span>]
    }
  }
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_iam_role"</span> <span class="hljs-string">"terraform"</span> {
<span class="hljs-attr">  name</span>               = <span class="hljs-string">"github-terraform"</span>
<span class="hljs-attr">  assume_role_policy</span> = <span class="hljs-built_in">data</span>.aws_iam_policy_document.assume.json
}</code></pre></figure>
<aside class="note note-important" role="note"><p class="note-title">Important</p>
<p>The <code>sub</code> condition is what restricts which repository (and branch or environment) can use the role. Never leave it open. For the apply role, restrict it to the main branch or to a protected GitHub environment, for example <code>repo:my-org/my-infra:ref:refs/heads/main</code>.</p>
</aside>
<p>Attach to the role the <a href="https://www.itwonderlab.com/terraform-aws-iam-users/">IAM</a> permissions that your code needs. Use two roles: a <strong>read-only role for plan</strong> (used in pull requests) and a <strong>write role for apply</strong> (only on main). See <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-iam-roles-policies/">IAM roles and policies</a>.</p>
<h3 id="2-the-workflow">2. The workflow</h3>
<figure class="code"><figcaption>.github/workflows/infra.yml</figcaption><pre><code class="hljs language-yaml"><span class="hljs-attr">name:</span> <span class="hljs-string">infra</span>

<span class="hljs-attr">on:</span>
  <span class="hljs-attr">pull_request:</span>
    <span class="hljs-attr">paths:</span> [<span class="hljs-string">"infra/**"</span>]
  <span class="hljs-attr">push:</span>
    <span class="hljs-attr">branches:</span> [<span class="hljs-string">main</span>]
    <span class="hljs-attr">paths:</span> [<span class="hljs-string">"infra/**"</span>]

<span class="hljs-attr">permissions:</span>
  <span class="hljs-attr">id-token:</span> <span class="hljs-string">write</span>   <span class="hljs-comment"># needed to request the OIDC token</span>
  <span class="hljs-attr">contents:</span> <span class="hljs-string">read</span>
  <span class="hljs-attr">pull-requests:</span> <span class="hljs-string">write</span>

<span class="hljs-attr">env:</span>
  <span class="hljs-attr">AWS_REGION:</span> <span class="hljs-string">eu-west-1</span>

<span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">plan:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">defaults:</span>
      <span class="hljs-attr">run:</span>
        <span class="hljs-attr">working-directory:</span> <span class="hljs-string">infra</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v4</span>

      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">opentofu/setup-opentofu@v1</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">tofu_version:</span> <span class="hljs-number">1.10</span><span class="hljs-number">.0</span>

      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">aws-actions/configure-aws-credentials@v4</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">role-to-assume:</span> <span class="hljs-string">arn:aws:iam::111111111111:role/github-terraform-plan</span>
          <span class="hljs-attr">aws-region:</span> <span class="hljs-string">${{</span> <span class="hljs-string">env.AWS_REGION</span> <span class="hljs-string">}}</span>

      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">tofu</span> <span class="hljs-string">fmt</span> <span class="hljs-string">-check</span> <span class="hljs-string">-recursive</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">tofu</span> <span class="hljs-string">init</span> <span class="hljs-string">-input=false</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">tofu</span> <span class="hljs-string">validate</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">tofu</span> <span class="hljs-string">plan</span> <span class="hljs-string">-input=false</span> <span class="hljs-string">-out=tfplan</span>

      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/upload-artifact@v4</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">name:</span> <span class="hljs-string">tfplan</span>
          <span class="hljs-attr">path:</span> <span class="hljs-string">infra/tfplan</span>

  <span class="hljs-attr">apply:</span>
    <span class="hljs-attr">needs:</span> <span class="hljs-string">plan</span>
    <span class="hljs-attr">if:</span> <span class="hljs-string">github.ref</span> <span class="hljs-string">==</span> <span class="hljs-string">'refs/heads/main'</span> <span class="hljs-string">&amp;&amp;</span> <span class="hljs-string">github.event_name</span> <span class="hljs-string">==</span> <span class="hljs-string">'push'</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">environment:</span> <span class="hljs-string">production</span>        <span class="hljs-comment"># add required reviewers in GitHub</span>
    <span class="hljs-attr">defaults:</span>
      <span class="hljs-attr">run:</span>
        <span class="hljs-attr">working-directory:</span> <span class="hljs-string">infra</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v4</span>

      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">opentofu/setup-opentofu@v1</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">tofu_version:</span> <span class="hljs-number">1.10</span><span class="hljs-number">.0</span>

      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">aws-actions/configure-aws-credentials@v4</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">role-to-assume:</span> <span class="hljs-string">arn:aws:iam::111111111111:role/github-terraform-apply</span>
          <span class="hljs-attr">aws-region:</span> <span class="hljs-string">${{</span> <span class="hljs-string">env.AWS_REGION</span> <span class="hljs-string">}}</span>

      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/download-artifact@v4</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">name:</span> <span class="hljs-string">tfplan</span>
          <span class="hljs-attr">path:</span> <span class="hljs-string">infra</span>

      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">tofu</span> <span class="hljs-string">init</span> <span class="hljs-string">-input=false</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">tofu</span> <span class="hljs-string">apply</span> <span class="hljs-string">-input=false</span> <span class="hljs-string">tfplan</span></code></pre></figure>
<p>Replace the account ID, role names, region and the versions of the tools with yours. To use Terraform, replace the setup action with <code>hashicorp/setup-terraform</code> and the <code>tofu</code> commands with <code>terraform</code>.</p>
<h3 id="how-it-works">How it works</h3>
<ul>
<li><code>permissions: id-token: write</code> lets the job request the OIDC token from GitHub.</li>
<li><code>configure-aws-credentials</code> exchanges the token for temporary credentials of the role. They expire in about an hour and are never stored.</li>
<li>On pull requests only the <code>plan</code> job runs, with the read-only role.</li>
<li>On merge to <code>main</code>, <code>apply</code> runs the <strong>saved plan</strong> (<code>tfplan</code>), so what is applied is exactly what was reviewed. The <code>environment</code> setting lets you require a manual approval.</li>
</ul>
<h3 id="good-practices">Good practices</h3>
<ul>
<li>Use a <a href="https://www.itwonderlab.com/aws-terraform-tutorial-terraform-backends/">remote backend</a> with locking, since two runs must never apply at the same time. Add a <code>concurrency</code> group to the workflow too.</li>
<li>Do not print secrets: see <a href="https://www.itwonderlab.com/terraform-secrets-management/">secrets management</a>.</li>
<li>Add <a href="https://www.itwonderlab.com/terraform-security-scanning-tflint-checkov-trivy/">security scanning</a> and <a href="https://www.itwonderlab.com/terraform-testing-opentofu-test/">tests</a> before the plan.</li>
<li>Pin third-party actions to a full commit SHA in sensitive repositories.</li>
<li>Show the plan in the pull request comments with a tool such as Atlantis, Spacelift or a plan-comment action, so reviewers see the changes.</li>
</ul>]]></content:encoded>
</item>
<item>
<title>Terraform and OpenTofu Cheat Sheet: Commands and HCL Syntax</title>
<link>https://www.itwonderlab.com/terraform-cheat-sheet/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/terraform-cheat-sheet/</guid>
<pubDate>Sat, 15 Aug 2026 07:50:22 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>Terraform &amp; OpenTofu Tutorials</category>
<description>A quick reference of the most used Terraform and OpenTofu commands (init, plan, apply, state, import, workspace) and HCL syntax, with examples to copy.</description>
<content:encoded><![CDATA[<h2 id="terraform-and-opentofu-quick-reference">Terraform and OpenTofu quick reference</h2>
<p>Replace <code>terraform</code> with <code>tofu</code> to use <a href="https://www.itwonderlab.com/opentofu/">OpenTofu</a>: every command below works in both. For explanations, see the <a href="https://www.itwonderlab.com/tutorials/terraform/">tutorials</a>.</p>
<h3 id="main-workflow">Main workflow</h3>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Command</th>
<th>What it does</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>terraform init</code></td>
<td>Downloads providers and modules and configures the backend</td>
</tr>
<tr>
<td><code>terraform init -upgrade</code></td>
<td>Updates providers and modules within the version constraints</td>
</tr>
<tr>
<td><code>terraform init -reconfigure</code></td>
<td>Reconfigures the backend ignoring the saved configuration</td>
</tr>
<tr>
<td><code>terraform init -migrate-state</code></td>
<td>Moves the state to a new backend</td>
</tr>
<tr>
<td><code>terraform fmt -recursive</code></td>
<td>Formats the code</td>
</tr>
<tr>
<td><code>terraform validate</code></td>
<td>Checks syntax and consistency</td>
</tr>
<tr>
<td><code>terraform plan</code></td>
<td>Shows the changes that would be made</td>
</tr>
<tr>
<td><code>terraform plan -out=tfplan</code></td>
<td>Saves the plan to a file</td>
</tr>
<tr>
<td><code>terraform apply</code></td>
<td>Applies the changes after confirmation</td>
</tr>
<tr>
<td><code>terraform apply tfplan</code></td>
<td>Applies a saved plan, with no confirmation</td>
</tr>
<tr>
<td><code>terraform apply -auto-approve</code></td>
<td>Applies without asking (CI only)</td>
</tr>
<tr>
<td><code>terraform destroy</code></td>
<td>Destroys everything managed</td>
</tr>
</tbody>
</table></div>
<h3 id="useful-plan-and-apply-flags">Useful plan and apply flags</h3>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Flag</th>
<th>Use</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>-var 'name=value'</code></td>
<td>Sets a variable</td>
</tr>
<tr>
<td><code>-var-file=pro.tfvars</code></td>
<td>Loads variables from a file</td>
</tr>
<tr>
<td><code>-target=aws_instance.web</code></td>
<td>Limits the run to a resource (use in emergencies only)</td>
</tr>
<tr>
<td><code>-replace=aws_instance.web</code></td>
<td>Forces recreating a resource</td>
</tr>
<tr>
<td><code>-refresh-only</code></td>
<td>Updates the state with reality, changes no infrastructure</td>
</tr>
<tr>
<td><code>-destroy</code></td>
<td>Plans a destroy</td>
</tr>
<tr>
<td><code>-parallelism=20</code></td>
<td>Number of concurrent operations (default 10)</td>
</tr>
<tr>
<td><code>-lock=false</code></td>
<td>Does not lock the state (avoid)</td>
</tr>
<tr>
<td><code>-input=false</code></td>
<td>Never asks for input (CI)</td>
</tr>
</tbody>
</table></div>
<h3 id="state">State</h3>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Command</th>
<th>What it does</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>terraform state list</code></td>
<td>Lists the resources in the <a href="https://www.itwonderlab.com/terraform-state/">state</a></td>
</tr>
<tr>
<td><code>terraform state show aws_instance.web</code></td>
<td>Shows the attributes of one resource</td>
</tr>
<tr>
<td><code>terraform state mv A B</code></td>
<td>Renames a resource in the state</td>
</tr>
<tr>
<td><code>terraform state rm A</code></td>
<td>Removes a resource from the state without destroying it</td>
</tr>
<tr>
<td><code>terraform state pull</code></td>
<td>Prints the remote state</td>
</tr>
<tr>
<td><code>terraform import A id</code></td>
<td>Imports an existing resource</td>
</tr>
<tr>
<td><code>terraform force-unlock ID</code></td>
<td>Releases a stuck lock</td>
</tr>
</tbody>
</table></div>
<p>Prefer <code>import</code>, <code>moved</code> and <code>removed</code> blocks: see <a href="https://www.itwonderlab.com/terraform-import-moved-removed/">import, moved and removed</a>.</p>
<h3 id="output-console-and-graph">Output, console and graph</h3>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform output</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform output -raw vpc_id</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform show</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform console              <span class="hljs-comment"># evaluate expressions</span></span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform graph | dot -Tpng &gt; graph.png</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform providers</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform version</span></code></pre>
<h3 id="workspaces">Workspaces</h3>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform workspace list</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform workspace new dev</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform workspace <span class="hljs-keyword">select</span> dev</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">terraform workspace delete dev</span></code></pre>
<p>See <a href="https://www.itwonderlab.com/terraform-workspaces-vs-directories/">workspaces vs directories</a>.</p>
<h3 id="debugging">Debugging</h3>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash"><span class="hljs-built_in">export</span> TF_LOG=DEBUG</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash"><span class="hljs-built_in">export</span> TF_LOG_PATH=terraform.log</span></code></pre>
<p>More in <a href="https://www.itwonderlab.com/how-to-debug-terraform/">how to debug Terraform</a>.</p>
<h3 id="environment-variables">Environment variables</h3>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Variable</th>
<th>Use</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>TF_VAR_name</code></td>
<td>Value of the variable <code>name</code></td>
</tr>
<tr>
<td><code>TF_LOG</code></td>
<td>Log level: TRACE, DEBUG, INFO, WARN, ERROR</td>
</tr>
<tr>
<td><code>TF_INPUT=0</code></td>
<td>Disables prompts</td>
</tr>
<tr>
<td><code>TF_CLI_ARGS_plan</code></td>
<td>Default arguments for <code>plan</code></td>
</tr>
<tr>
<td><code>TF_DATA_DIR</code></td>
<td>Location of the <code>.terraform</code> directory</td>
</tr>
<tr>
<td><code>TF_WORKSPACE</code></td>
<td>Workspace to use</td>
</tr>
</tbody>
</table></div>
<h3 id="hcl-syntax">HCL syntax</h3>
<figure class="code"><figcaption>syntax.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-comment"># Resource</span>
<span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_instance"</span> <span class="hljs-string">"web"</span> {
<span class="hljs-attr">  ami</span>           = <span class="hljs-built_in">var</span>.ami
<span class="hljs-attr">  instance_type</span> = <span class="hljs-string">"t3.micro"</span>
}

<span class="hljs-comment"># Data source</span>
<span class="hljs-keyword">data</span> <span class="hljs-string">"aws_region"</span> <span class="hljs-string">"current"</span> {}

<span class="hljs-comment"># Variable, local and output</span>
<span class="hljs-keyword">variable</span> <span class="hljs-string">"name"</span> { type = string }
<span class="hljs-keyword">locals</span> { prefix = <span class="hljs-string">"ditwl-<span class="hljs-subst">${var.name}</span>"</span> }
<span class="hljs-keyword">output</span> <span class="hljs-string">"id"</span> { value = aws_instance.web.id }

<span class="hljs-comment"># Module</span>
<span class="hljs-keyword">module</span> <span class="hljs-string">"network"</span> {
<span class="hljs-attr">  source</span> = <span class="hljs-string">"./modules/network"</span>
<span class="hljs-attr">  cidr</span>   = <span class="hljs-string">"10.0.0.0/16"</span>
}

<span class="hljs-comment"># Meta-arguments</span>
<span class="hljs-comment">#   count, for_each, depends_on, provider, lifecycle</span>

<span class="hljs-comment"># Conditional</span>
<span class="hljs-attr">instance_type</span> = <span class="hljs-built_in">var</span>.env == <span class="hljs-string">"pro"</span> ? <span class="hljs-string">"m6i.large"</span> : <span class="hljs-string">"t3.micro"</span>

<span class="hljs-comment"># for expressions</span>
<span class="hljs-attr">names</span> = [for s in <span class="hljs-built_in">var</span>.subnets : upper(s)]
<span class="hljs-attr">map</span>   = { for k, v in <span class="hljs-built_in">var</span>.items : k =&gt; v.id }

<span class="hljs-comment"># Splat</span>
<span class="hljs-attr">ids</span> = aws_instance.web[*].id</code></pre></figure>
<p>See <a href="https://www.itwonderlab.com/terraform-for-each-vs-count/">for_each vs count</a>, <a href="https://www.itwonderlab.com/terraform-dynamic-blocks/">dynamic blocks</a> and <a href="https://www.itwonderlab.com/terraform-lifecycle-meta-argument/">lifecycle</a>.</p>
<h3 id="common-functions">Common functions</h3>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Function</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>merge</code></td>
<td><code>merge(local.tags, { Name = "x" })</code></td>
</tr>
<tr>
<td><code>lookup</code></td>
<td><code>lookup(var.map, "key", "default")</code></td>
</tr>
<tr>
<td><code>format</code></td>
<td><code>format("web-%02d", count.index + 1)</code></td>
</tr>
<tr>
<td><code>join</code>, <code>split</code></td>
<td><code>join(",", var.list)</code></td>
</tr>
<tr>
<td><code>toset</code>, <code>tolist</code>, <code>tomap</code></td>
<td><code>toset(var.names)</code></td>
</tr>
<tr>
<td><code>length</code></td>
<td><code>length(var.list)</code></td>
</tr>
<tr>
<td><code>cidrsubnet</code></td>
<td><code>cidrsubnet("10.0.0.0/16", 8, 1)</code> returns <code>10.0.1.0/24</code></td>
</tr>
<tr>
<td><code>file</code>, <code>templatefile</code></td>
<td><code>templatefile("init.tftpl", { name = "x" })</code></td>
</tr>
<tr>
<td><code>jsonencode</code>, <code>yamlencode</code></td>
<td><code>jsonencode({ a = 1 })</code></td>
</tr>
<tr>
<td><code>try</code>, <code>coalesce</code></td>
<td><code>try(var.obj.value, "default")</code></td>
</tr>
</tbody>
</table></div>
<p>The full list is in <a href="https://www.itwonderlab.com/terraform-functions/">Terraform functions</a>.</p>]]></content:encoded>
</item>
<item>
<title>AWS S3 Buckets with Terraform and OpenTofu: Secure Configuration</title>
<link>https://www.itwonderlab.com/aws-terraform-tutorial-aws-s3/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/aws-terraform-tutorial-aws-s3/</guid>
<pubDate>Wed, 12 Aug 2026 17:58:22 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>Terraform &amp; OpenTofu Tutorials</category>
<description>Create secure AWS S3 buckets with Terraform or OpenTofu: public access block, encryption with KMS, versioning, lifecycle rules and bucket policies.</description>
<content:encoded><![CDATA[<h2 id="a-secure-s3-bucket-with-terraform">A secure S3 bucket with Terraform</h2>
<p><a href="https://www.itwonderlab.com/aws-s3/">AWS S3</a> is object storage. Since version 4 of the AWS provider, the settings of a bucket are separate resources instead of arguments of <code>aws_s3_bucket</code>. This guide creates a private, encrypted and versioned bucket, which is the right default.</p>
<h3 id="the-bucket">The bucket</h3>
<figure class="code"><figcaption>s3.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_s3_bucket"</span> <span class="hljs-string">"data"</span> {
<span class="hljs-attr">  bucket</span> = <span class="hljs-string">"ditwl-pro-data-<span class="hljs-subst">${data.aws_caller_identity.current.account_id}</span>"</span>
<span class="hljs-attr">
  tags</span> = <span class="hljs-built_in">local</span>.common_tags
}
<span class="hljs-keyword">
data</span> <span class="hljs-string">"aws_caller_identity"</span> <span class="hljs-string">"current"</span> {}</code></pre></figure>
<p>Bucket names are global across all AWS accounts, so adding the account ID helps to make it unique. Never use <code>force_destroy = true</code> in production: it deletes all objects when the bucket is destroyed.</p>
<h3 id="block-all-public-access">Block all public access</h3>
<figure class="code"><figcaption>s3.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_s3_bucket_public_access_block"</span> <span class="hljs-string">"data"</span> {
<span class="hljs-attr">  bucket</span> = aws_s3_bucket.<span class="hljs-built_in">data</span>.id
<span class="hljs-attr">
  block_public_acls</span>       = <span class="hljs-literal">true</span>
<span class="hljs-attr">  block_public_policy</span>     = <span class="hljs-literal">true</span>
<span class="hljs-attr">  ignore_public_acls</span>      = <span class="hljs-literal">true</span>
<span class="hljs-attr">  restrict_public_buckets</span> = <span class="hljs-literal">true</span>
}</code></pre></figure>
<p>Enable this by default. For public websites do not open the bucket: serve it through <a href="https://www.itwonderlab.com/terraform-s3-static-website-cloudfront/">CloudFront</a>.</p>
<h3 id="ownership-and-acls">Ownership and ACLs</h3>
<p>New buckets disable ACLs by default. Make it explicit:</p>
<figure class="code"><figcaption>s3.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_s3_bucket_ownership_controls"</span> <span class="hljs-string">"data"</span> {
<span class="hljs-attr">  bucket</span> = aws_s3_bucket.<span class="hljs-built_in">data</span>.id

  rule {
<span class="hljs-attr">    object_ownership</span> = <span class="hljs-string">"BucketOwnerEnforced"</span>
  }
}</code></pre></figure>
<h3 id="encryption-with-kms">Encryption with KMS</h3>
<p>S3 encrypts every object with SSE-S3 by default. To use your own <a href="https://www.itwonderlab.com/aws-kms/">KMS</a> key:</p>
<figure class="code"><figcaption>s3.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_kms_key"</span> <span class="hljs-string">"s3"</span> {
<span class="hljs-attr">  description</span>         = <span class="hljs-string">"Key for the data bucket"</span>
<span class="hljs-attr">  enable_key_rotation</span> = <span class="hljs-literal">true</span>
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_s3_bucket_server_side_encryption_configuration"</span> <span class="hljs-string">"data"</span> {
<span class="hljs-attr">  bucket</span> = aws_s3_bucket.<span class="hljs-built_in">data</span>.id

  rule {
    apply_server_side_encryption_by_default {
<span class="hljs-attr">      sse_algorithm</span>     = <span class="hljs-string">"aws:kms"</span>
<span class="hljs-attr">      kms_master_key_id</span> = aws_kms_key.s3.arn
    }
<span class="hljs-attr">
    bucket_key_enabled</span> = <span class="hljs-literal">true</span>   <span class="hljs-comment"># reduces KMS request costs</span>
  }
}</code></pre></figure>
<h3 id="versioning-and-lifecycle">Versioning and lifecycle</h3>
<figure class="code"><figcaption>s3.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_s3_bucket_versioning"</span> <span class="hljs-string">"data"</span> {
<span class="hljs-attr">  bucket</span> = aws_s3_bucket.<span class="hljs-built_in">data</span>.id

  versioning_configuration {
<span class="hljs-attr">    status</span> = <span class="hljs-string">"Enabled"</span>
  }
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_s3_bucket_lifecycle_configuration"</span> <span class="hljs-string">"data"</span> {
<span class="hljs-attr">  bucket</span> = aws_s3_bucket.<span class="hljs-built_in">data</span>.id
<span class="hljs-keyword">
  depends_on</span> = [aws_s3_bucket_versioning.<span class="hljs-built_in">data</span>]

  rule {
<span class="hljs-attr">    id</span>     = <span class="hljs-string">"archive-and-expire"</span>
<span class="hljs-attr">    status</span> = <span class="hljs-string">"Enabled"</span>

    filter {}

    transition {
<span class="hljs-attr">      days</span>          = <span class="hljs-number">30</span>
<span class="hljs-attr">      storage_class</span> = <span class="hljs-string">"STANDARD_IA"</span>
    }

    noncurrent_version_expiration {
<span class="hljs-attr">      noncurrent_days</span> = <span class="hljs-number">90</span>
    }

    abort_incomplete_multipart_upload {
<span class="hljs-attr">      days_after_initiation</span> = <span class="hljs-number">7</span>
    }
  }
}</code></pre></figure>
<p>Lifecycle rules control costs by moving old data to cheaper classes and deleting old versions.</p>
<h3 id="bucket-policy-force-https">Bucket policy: force HTTPS</h3>
<figure class="code"><figcaption>s3.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">data</span> <span class="hljs-string">"aws_iam_policy_document"</span> <span class="hljs-string">"data_bucket"</span> {
  statement {
<span class="hljs-attr">    sid</span>       = <span class="hljs-string">"DenyInsecureTransport"</span>
<span class="hljs-attr">    effect</span>    = <span class="hljs-string">"Deny"</span>
<span class="hljs-attr">    actions</span>   = [<span class="hljs-string">"s3:*"</span>]
<span class="hljs-attr">    resources</span> = [aws_s3_bucket.<span class="hljs-built_in">data</span>.arn, <span class="hljs-string">"<span class="hljs-subst">${aws_s3_bucket.data.arn}</span>/*"</span>]

    principals {
<span class="hljs-attr">      type</span>        = <span class="hljs-string">"*"</span>
<span class="hljs-attr">      identifiers</span> = [<span class="hljs-string">"*"</span>]
    }

    condition {
<span class="hljs-attr">      test</span>     = <span class="hljs-string">"Bool"</span>
<span class="hljs-keyword">      variable</span> = <span class="hljs-string">"aws:SecureTransport"</span>
<span class="hljs-attr">      values</span>   = [<span class="hljs-string">"false"</span>]
    }
  }
}
<span class="hljs-keyword">
resource</span> <span class="hljs-string">"aws_s3_bucket_policy"</span> <span class="hljs-string">"data"</span> {
<span class="hljs-attr">  bucket</span> = aws_s3_bucket.<span class="hljs-built_in">data</span>.id
<span class="hljs-attr">  policy</span> = <span class="hljs-built_in">data</span>.aws_iam_policy_document.data_bucket.json
<span class="hljs-keyword">
  depends_on</span> = [aws_s3_bucket_public_access_block.<span class="hljs-built_in">data</span>]
}</code></pre></figure>
<p>Grant access to applications with <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-iam-roles-policies/">IAM roles</a> rather than by opening the bucket.</p>
<h3 id="several-buckets">Several buckets</h3>
<p>Use <a href="https://www.itwonderlab.com/terraform-for-each-vs-count/"><code>for_each</code></a> or a <a href="https://www.itwonderlab.com/terraform-module/">module</a> when you need the same configuration more than once.</p>
<h3 id="using-s3-as-a-terraform-backend">Using S3 as a Terraform backend</h3>
<p>The same service can store your <a href="https://www.itwonderlab.com/terraform-state/">state</a>: see <a href="https://www.itwonderlab.com/aws-terraform-tutorial-terraform-backends/">Terraform backends</a>. Use a separate bucket for it, with versioning and no public access.</p>
<h3 id="cost">Cost</h3>
<p>S3 charges for storage by class, requests and data transferred out. Versioning and incomplete uploads add storage cost silently, hence the lifecycle rule. Estimate with <a href="https://www.itwonderlab.com/terraform-cost-estimation-infracost/">Infracost</a>.</p>
<h3 id="verify">Verify</h3>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">tofu apply</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">aws s3 <span class="hljs-built_in">cp</span> hello.txt s3://ditwl-pro-data-111111111111/hello.txt</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">aws s3api get-public-access-block --bucket ditwl-pro-data-111111111111</span></code></pre>]]></content:encoded>
</item>
<item>
<title>terraform-aws-modules: Create a VPC and EKS Cluster with the Official Community Modules</title>
<link>https://www.itwonderlab.com/terraform-aws-modules-vpc-eks/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/terraform-aws-modules-vpc-eks/</guid>
<pubDate>Sat, 08 Aug 2026 16:45:00 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>Terraform &amp; OpenTofu Tutorials</category>
<description>How to use the popular terraform-aws-modules (vpc, iam, s3-bucket, eks) from the Terraform and OpenTofu registry: versions, inputs, outputs and a VPC plus EKS example.</description>
<content:encoded><![CDATA[<h2 id="reuse-maintained-modules-instead-of-writing-everything">Reuse maintained modules instead of writing everything</h2>
<p>The community project <strong>terraform-aws-modules</strong> publishes the most downloaded <a href="https://www.itwonderlab.com/terraform-module/">modules</a> for AWS: <code>vpc</code>, <code>iam</code>, <code>s3-bucket</code>, <code>eks</code>, <code>security-group</code>, <code>rds</code>, <code>alb</code>, <code>lambda</code> and many more. They encode years of fixes and options, and are documented in the <a href="https://registry.terraform.io/namespaces/terraform-aws-modules">Terraform registry</a> (<a href="https://www.itwonderlab.com/opentofu/">OpenTofu</a> uses the same modules from its own registry).</p>
<p>The trade-off: you learn less about the underlying resources and depend on module upgrades. The <a href="https://www.itwonderlab.com/tutorials/aws/">AWS with Terraform series</a> writes the resources by hand to teach them, which is the right way to learn. In production, a maintained module is often the better choice.</p>
<h3 id="how-to-use-a-registry-module">How to use a registry module</h3>
<figure class="code"><figcaption>main.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">module</span> <span class="hljs-string">"vpc"</span> {
<span class="hljs-attr">  source</span>  = <span class="hljs-string">"terraform-aws-modules/vpc/aws"</span>
<span class="hljs-attr">  version</span> = <span class="hljs-string">"~&gt; 5.0"</span>
<span class="hljs-attr">
  name</span> = <span class="hljs-string">"ditwl-pro"</span>
<span class="hljs-attr">  cidr</span> = <span class="hljs-string">"10.10.0.0/16"</span>
<span class="hljs-attr">
  azs</span>             = [<span class="hljs-string">"eu-west-1a"</span>, <span class="hljs-string">"eu-west-1b"</span>, <span class="hljs-string">"eu-west-1c"</span>]
<span class="hljs-attr">  private_subnets</span> = [<span class="hljs-string">"10.10.1.0/24"</span>, <span class="hljs-string">"10.10.2.0/24"</span>, <span class="hljs-string">"10.10.3.0/24"</span>]
<span class="hljs-attr">  public_subnets</span>  = [<span class="hljs-string">"10.10.101.0/24"</span>, <span class="hljs-string">"10.10.102.0/24"</span>, <span class="hljs-string">"10.10.103.0/24"</span>]
<span class="hljs-attr">
  enable_nat_gateway</span>   = <span class="hljs-literal">true</span>
<span class="hljs-attr">  single_nat_gateway</span>   = <span class="hljs-literal">true</span>      <span class="hljs-comment"># cheaper, less available</span>
<span class="hljs-attr">  enable_dns_hostnames</span> = <span class="hljs-literal">true</span>
<span class="hljs-attr">
  public_subnet_tags</span> = {
    <span class="hljs-string">"kubernetes.io/role/elb"</span> = <span class="hljs-number">1</span>
  }
<span class="hljs-attr">
  private_subnet_tags</span> = {
    <span class="hljs-string">"kubernetes.io/role/internal-elb"</span> = <span class="hljs-number">1</span>
  }
<span class="hljs-attr">
  tags</span> = {
<span class="hljs-attr">    Environment</span> = <span class="hljs-string">"pro"</span>
<span class="hljs-attr">    ManagedBy</span>   = <span class="hljs-string">"opentofu"</span>
  }
}</code></pre></figure>
<p>This single block creates the <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-vpc/">VPC</a>, <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-subnets/">subnets</a>, <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-internet-gateway/">internet gateway</a>, <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-nat-gateway/">NAT gateway</a> and <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-routing-tables/">route tables</a> that take several tutorials to write by hand. The module outputs include <code>vpc_id</code>, <code>private_subnets</code> and <code>public_subnets</code>.</p>
<p>Always <strong>pin the version</strong> (<code>~&gt; 5.0</code> allows 5.x updates but not 6.0) and read the changelog before upgrading a major version, since module upgrades can recreate resources. Run <code>plan</code> and review it.</p>
<h3 id="eks-cluster-on-top-of-the-vpc">EKS cluster on top of the VPC</h3>
<figure class="code"><figcaption>eks.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">module</span> <span class="hljs-string">"eks"</span> {
<span class="hljs-attr">  source</span>  = <span class="hljs-string">"terraform-aws-modules/eks/aws"</span>
<span class="hljs-attr">  version</span> = <span class="hljs-string">"~&gt; 20.0"</span>
<span class="hljs-attr">
  cluster_name</span>    = <span class="hljs-string">"ditwl-pro"</span>
<span class="hljs-attr">  cluster_version</span> = <span class="hljs-string">"1.31"</span>
<span class="hljs-attr">
  vpc_id</span>     = <span class="hljs-built_in">module</span>.vpc.vpc_id
<span class="hljs-attr">  subnet_ids</span> = <span class="hljs-built_in">module</span>.vpc.private_subnets
<span class="hljs-attr">
  cluster_endpoint_public_access</span>           = <span class="hljs-literal">true</span>
<span class="hljs-attr">  enable_cluster_creator_admin_permissions</span> = <span class="hljs-literal">true</span>
<span class="hljs-attr">
  eks_managed_node_groups</span> = {
<span class="hljs-attr">    default</span> = {
<span class="hljs-attr">      instance_types</span> = [<span class="hljs-string">"t3.medium"</span>]
<span class="hljs-attr">      min_size</span>       = <span class="hljs-number">2</span>
<span class="hljs-attr">      max_size</span>       = <span class="hljs-number">4</span>
<span class="hljs-attr">      desired_size</span>   = <span class="hljs-number">2</span>
    }
  }
<span class="hljs-attr">
  tags</span> = {
<span class="hljs-attr">    Environment</span> = <span class="hljs-string">"pro"</span>
  }
}</code></pre></figure>
<p>The version numbers above are examples: check the current major version and the supported Kubernetes versions in the module documentation, because the inputs change between majors. The module creates the cluster, <a href="https://www.itwonderlab.com/aws-iam/">IAM</a> roles, <a href="https://www.itwonderlab.com/aws-security-groups/">security groups</a>, an OIDC provider and the managed node group. Connect to it with:</p>
<pre><code class="hljs language-shell"><span class="hljs-meta prompt_">$ </span><span class="language-bash">aws eks update-kubeconfig --name ditwl-pro --region eu-west-1</span>
<span class="hljs-meta prompt_">$ </span><span class="language-bash">kubectl get nodes</span></code></pre>
<p>To write the cluster by hand see <a href="https://www.itwonderlab.com/terraform-eks/">Terraform EKS</a>. To install add-ons such as an ingress controller use the <a href="https://www.itwonderlab.com/terraform-helm-provider-kubernetes/">Helm provider</a>, and to deploy applications use <a href="https://www.itwonderlab.com/argocd-gitops-kubernetes/">Argo CD</a>.</p>
<h3 id="other-popular-modules">Other popular modules</h3>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Module</th>
<th>Use</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>terraform-aws-modules/iam/aws</code></td>
<td>Users, roles, policies, OIDC roles for GitHub (<a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-iam-roles-policies/">IAM</a>)</td>
</tr>
<tr>
<td><code>terraform-aws-modules/s3-bucket/aws</code></td>
<td>Secure <a href="https://www.itwonderlab.com/aws-s3/">S3</a> buckets (<a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-s3/">S3</a>)</td>
</tr>
<tr>
<td><code>terraform-aws-modules/security-group/aws</code></td>
<td>Security groups with predefined rules</td>
</tr>
<tr>
<td><code>terraform-aws-modules/rds/aws</code></td>
<td><a href="https://www.itwonderlab.com/aws-rds/">RDS</a> and <a href="https://www.itwonderlab.com/aws-aurora/">Aurora</a> (<a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-rds/">RDS</a>)</td>
</tr>
<tr>
<td><code>terraform-aws-modules/alb/aws</code></td>
<td>Application and network <a href="https://www.itwonderlab.com/aws-elastic-load-balancing/">load balancers</a></td>
</tr>
<tr>
<td><code>terraform-aws-modules/lambda/aws</code></td>
<td>Lambda with packaging and permissions (<a href="https://www.itwonderlab.com/terraform-aws-lambda-api-gateway/">Lambda</a>)</td>
</tr>
</tbody>
</table></div>
<h3 id="checklist-before-using-a-third-party-module">Checklist before using a third-party module</h3>
<ul>
<li>Is it maintained, with recent releases and responses to issues?</li>
<li>Does it pin provider versions reasonably?</li>
<li>Read the code of the resources it creates: you are responsible for what it deploys.</li>
<li>Pin the version and update deliberately.</li>
<li>Scan it with <a href="https://www.itwonderlab.com/terraform-security-scanning-tflint-checkov-trivy/">security tools</a>.</li>
</ul>
<h3 id="cost">Cost</h3>
<p>The example creates a <a href="https://www.itwonderlab.com/aws-nat-gateway/">NAT gateway</a>, an <a href="https://www.itwonderlab.com/aws-eks/">EKS</a> control plane (charged per hour) and <a href="https://www.itwonderlab.com/aws-ec2/">EC2</a> nodes. Estimate it with <a href="https://www.itwonderlab.com/terraform-cost-estimation-infracost/">Infracost</a> and run <code>destroy</code> when you finish.</p>]]></content:encoded>
</item>
<item>
<title>AWS Cost Optimization and FinOps: A Practical Checklist</title>
<link>https://www.itwonderlab.com/aws-cost-optimization-finops/</link>
<guid isPermaLink="true">https://www.itwonderlab.com/aws-cost-optimization-finops/</guid>
<pubDate>Thu, 30 Jul 2026 10:58:58 GMT</pubDate>
<dc:creator>Javier Ruiz Jiménez</dc:creator>
<category>AWS Best Practices</category>
<description>How to reduce AWS costs: budgets, tagging, right-sizing, Savings Plans, NAT gateway and data transfer savings, storage lifecycle and automation with Terraform.</description>
<content:encoded><![CDATA[<h2 id="finops-on-aws">FinOps on AWS</h2>
<p><strong>FinOps</strong> is the practice of managing cloud costs as an engineering concern: visibility first, then optimization, then continuous governance. The cloud makes it easy to create resources and easy to forget them. This checklist covers what has the greatest effect, in order.</p>
<h3 id="1-see-your-costs">1. See your costs</h3>
<ul>
<li>Enable <strong>Cost Explorer</strong> and the <strong>Cost and Usage Report</strong> (CUR), and review the top services every month.</li>
<li>Define <strong>budgets with alerts</strong> (AWS Budgets) per account and per project, at 50, 80 and 100 percent.</li>
<li>Turn on <strong>Cost Anomaly Detection</strong>.</li>
<li>Use a separate account per environment (<a href="https://www.itwonderlab.com/terraform-aws-organizations-multi-account/">multi-account</a>), which makes costs attributable by default.</li>
</ul>
<figure class="code"><figcaption>budget.tf</figcaption><pre><code class="hljs language-hcl"><span class="hljs-keyword">resource</span> <span class="hljs-string">"aws_budgets_budget"</span> <span class="hljs-string">"monthly"</span> {
<span class="hljs-attr">  name</span>         = <span class="hljs-string">"monthly-total"</span>
<span class="hljs-attr">  budget_type</span>  = <span class="hljs-string">"COST"</span>
<span class="hljs-attr">  limit_amount</span> = <span class="hljs-string">"200"</span>
<span class="hljs-attr">  limit_unit</span>   = <span class="hljs-string">"USD"</span>
<span class="hljs-attr">  time_unit</span>    = <span class="hljs-string">"MONTHLY"</span>

  notification {
<span class="hljs-attr">    comparison_operator</span>        = <span class="hljs-string">"GREATER_THAN"</span>
<span class="hljs-attr">    threshold</span>                  = <span class="hljs-number">80</span>
<span class="hljs-attr">    threshold_type</span>             = <span class="hljs-string">"PERCENTAGE"</span>
<span class="hljs-attr">    notification_type</span>          = <span class="hljs-string">"ACTUAL"</span>
<span class="hljs-attr">    subscriber_email_addresses</span> = [<span class="hljs-string">"finops@example.com"</span>]
  }
}</code></pre></figure>
<h3 id="2-tag-everything">2. Tag everything</h3>
<p>Costs without tags cannot be assigned. Define mandatory tags (<code>Project</code>, <code>Environment</code>, <code>Owner</code>, <code>CostCenter</code>), apply them with the provider's <code>default_tags</code>, and activate them as <strong>cost allocation tags</strong> in the billing console. See <a href="https://www.itwonderlab.com/aws-resource-tagging/">resource tagging</a> and <a href="https://www.itwonderlab.com/terraform-best-practices/">Terraform best practices</a>.</p>
<h3 id="3-delete-what-you-do-not-use">3. Delete what you do not use</h3>
<p>Frequent waste:</p>
<ul>
<li>Unattached <a href="https://www.itwonderlab.com/aws-ebs/">EBS</a> volumes and old snapshots.</li>
<li>Unassociated <a href="https://www.itwonderlab.com/aws-elastic-ip/">Elastic IPs</a>, which are charged since all public IPv4 addresses have a cost.</li>
<li>Idle <a href="https://www.itwonderlab.com/aws-elastic-load-balancing/">load balancers</a> and <a href="https://www.itwonderlab.com/aws-nat-gateway/">NAT gateways</a> in test environments.</li>
<li>Old <a href="https://www.itwonderlab.com/aws-ami/">AMIs</a> with their snapshots, and <a href="https://www.itwonderlab.com/aws-ecr/">ECR</a> images without a lifecycle policy.</li>
<li><a href="https://www.itwonderlab.com/aws-cloudwatch/">CloudWatch</a> log groups with no retention.</li>
<li>Demo environments left running: run <code>tofu destroy</code>, or schedule non-production resources to stop at night.</li>
</ul>
<h3 id="4-right-size-compute">4. Right-size compute</h3>
<ul>
<li>Use <strong>Compute Optimizer</strong> recommendations to find over-provisioned instances and volumes.</li>
<li>Choose current generation instance types, and <strong>Graviton (arm64)</strong>, which usually gives better price-performance.</li>
<li>Convert <code>gp2</code> volumes to <code>gp3</code>, which is cheaper and lets you set IOPS independently.</li>
<li>Use <a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-auto-scaling/">Auto Scaling</a> so capacity follows demand.</li>
</ul>
<h3 id="5-choose-the-right-purchase-option">5. Choose the right purchase option</h3>
<div class="table-wrap"><table>
<thead>
<tr>
<th>Option</th>
<th>Saving</th>
<th>Commitment</th>
<th>Use for</th>
</tr>
</thead>
<tbody>
<tr>
<td>On-demand</td>
<td>None</td>
<td>None</td>
<td>Unpredictable and short workloads</td>
</tr>
<tr>
<td>Savings Plans</td>
<td>Up to around 70%</td>
<td>1 or 3 years of spend per hour</td>
<td>Steady baseline compute (<a href="https://www.itwonderlab.com/aws-ec2/">EC2</a>, Fargate, Lambda)</td>
</tr>
<tr>
<td>Reserved Instances</td>
<td>Similar</td>
<td>1 or 3 years for a specific configuration</td>
<td>RDS, <a href="https://www.itwonderlab.com/aws-elasticache/">ElastiCache</a>, OpenSearch</td>
</tr>
<tr>
<td>Spot</td>
<td>Up to around 90%</td>
<td>Can be interrupted</td>
<td>Batch jobs, CI runners, fault-tolerant workers</td>
</tr>
</tbody>
</table></div>
<p>Commit only to what you are sure to use for the whole period, usually 60 to 70 percent of the stable baseline.</p>
<h3 id="6-reduce-network-costs">6. Reduce network costs</h3>
<p>Network charges are often a surprise:</p>
<ul>
<li>A NAT gateway charges per hour and per GB processed. Add free <strong>gateway endpoints for <a href="https://www.itwonderlab.com/aws-s3/">S3</a> and <a href="https://www.itwonderlab.com/aws-dynamodb/">DynamoDB</a></strong>, and interface endpoints only where traffic justifies them (<a href="https://www.itwonderlab.com/terraform-aws-vpc-endpoints/">VPC endpoints</a>).</li>
<li>Data transfer <strong>between <a href="https://www.itwonderlab.com/aws-regions-availability-zones/">Availability Zones</a></strong> and <strong>out to the Internet</strong> costs money. Keep chatty components in the same AZ when high availability allows it, and put <a href="https://www.itwonderlab.com/amazon-cloudfront/">CloudFront</a> in front of public content.</li>
<li>Use a single NAT gateway for development environments only.</li>
</ul>
<h3 id="7-storage-and-databases">7. Storage and databases</h3>
<ul>
<li><a href="https://www.itwonderlab.com/aws-terraform-tutorial-aws-s3/">S3</a> lifecycle rules and Intelligent-Tiering for old data. Delete incomplete multipart uploads.</li>
<li><a href="https://www.itwonderlab.com/terraform-aws-dynamodb/">DynamoDB</a> TTL and on-demand mode for spiky tables.</li>
<li>Stop or snapshot and delete development <a href="https://www.itwonderlab.com/aws-rds/">RDS</a> instances, and consider <a href="https://www.itwonderlab.com/aws-aurora/">Aurora</a> Serverless for variable loads.</li>
</ul>
<h3 id="8-architecture">8. Architecture</h3>
<p>Serverless (<a href="https://www.itwonderlab.com/aws-lambda/">Lambda</a>, <a href="https://www.itwonderlab.com/aws-fargate/">Fargate</a>) can be cheaper for irregular traffic, and more expensive for constant high load. Measure before migrating.</p>
<h3 id="9-automate-and-shift-left">9. Automate and shift left</h3>
<ul>
<li>Estimate the cost of every change in the pull request with <a href="https://www.itwonderlab.com/terraform-cost-estimation-infracost/">Infracost</a>.</li>
<li>Enforce rules with <a href="https://www.itwonderlab.com/aws-organizations/">AWS Organizations SCPs</a> (for example, deny very large instance types in sandbox).</li>
<li>Review the top ten cost drivers every month with the people who own them.</li>
</ul>
<h3 id="quick-wins-in-order">Quick wins in order</h3>
<ol>
<li>Budgets and alerts. 2. Delete idle resources. 3. Log retention. 4. Gateway endpoints. 5. <code>gp3</code> and Graviton. 6. Savings Plan for the stable base.</li>
</ol>]]></content:encoded>
</item>
</channel>
</rss>
