<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Michael Ethridge</title>
    <description>The latest articles on DEV Community by Michael Ethridge (@methridge).</description>
    <link>https://dev.to/methridge</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4088640%2Fec7f7331-f3ea-4ad5-834e-7114b09bb36f.jpg</url>
      <title>DEV Community: Michael Ethridge</title>
      <link>https://dev.to/methridge</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/methridge"/>
    <language>en</language>
    <item>
      <title>Workstation Setup for Terraform</title>
      <dc:creator>Michael Ethridge</dc:creator>
      <pubDate>Fri, 21 Aug 2026 18:15:36 +0000</pubDate>
      <link>https://dev.to/methridge/workstation-setup-for-terraform-b19</link>
      <guid>https://dev.to/methridge/workstation-setup-for-terraform-b19</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;In my last post, I covered how I set up&lt;br&gt;
&lt;a href="https://www.404-code-not-found.com/posts/vscode-setup-for-terraform" rel="noopener noreferrer"&gt;VSCode for Terraform&lt;/a&gt;. The second most&lt;br&gt;
popular question is how I set up my workstation for Terraform development.&lt;/p&gt;

&lt;p&gt;As with most things in the tech world, there are many ways to do things. The&lt;br&gt;
following is the way that works for me. I am always looking for ways to improve&lt;br&gt;
my workflow, so if you have suggestions, please let me know.&lt;/p&gt;

&lt;p&gt;Additionally, I am a Mac user, so some of the tools I use are harder to use on&lt;br&gt;
Windows. However, the tools can be used in WSL or DevContainers if you use&lt;br&gt;
either of those tools.&lt;/p&gt;

&lt;p&gt;On a Mac, I use &lt;a href="https://brew.sh/" rel="noopener noreferrer"&gt;Homebrew&lt;/a&gt; to install my tools. Also, Since we&lt;br&gt;
have to store all our code in a version control system, I installed the latest&lt;br&gt;
version of &lt;code&gt;git&lt;/code&gt; with Homebrew.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install Homebrew&lt;/span&gt;
/bin/bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Install Git&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Installing Terraform
&lt;/h2&gt;

&lt;p&gt;I have to use multiple versions of Terraform for different projects, so I use&lt;br&gt;
&lt;a href="https://tfswitch.warrensbox.com/" rel="noopener noreferrer"&gt;TFSwitch&lt;/a&gt; to switch the version of Terraform&lt;br&gt;
installed based on the directory I am in and the version constraint in my&lt;br&gt;
Terraform configuration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;warrensbox/tap/tfswitch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I have configured &lt;code&gt;tfswitch&lt;/code&gt; to link the correct version of Terraform to&lt;br&gt;
&lt;code&gt;~/bin/terraform&lt;/code&gt; as the &lt;code&gt;~/bin&lt;/code&gt; directory is in my &lt;code&gt;$PATH&lt;/code&gt;. This is done by&lt;br&gt;
creating a &lt;code&gt;~/.tfswitch.toml&lt;/code&gt; file with the following contents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;bin&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"~/bin/terraform"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I use the following &lt;code&gt;zsh&lt;/code&gt; function to load &lt;code&gt;tfswitch&lt;/code&gt; in a directory with&lt;br&gt;
Terraform configuration files.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;load-tfswitch&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;tfswitchrc_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/.tfswitch.toml"&lt;/span&gt;

  &lt;span class="c"&gt;# if [[ -f "$tfswitchrc_path" ]] &amp;amp;&amp;amp; [[ -f "terraform.tf" ]]; then&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tfswitchrc_path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;.tf | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 0 &lt;span class="o"&gt;]]&lt;/span&gt; 2&amp;gt; /dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
      &lt;/span&gt;tfswitch
    &lt;span class="k"&gt;fi
  fi&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

add-zsh-hook chpwd load-tfswitch
load-tfswitch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, if you don't need to support multiple versions of Terraform, you can&lt;br&gt;
install it with Homebrew from HashiCorp's tap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  brew &lt;span class="nb"&gt;install &lt;/span&gt;hashicorp/tap/terraform
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Otherwise, you can download it directly from the&lt;br&gt;
&lt;a href="https://developer.hashicorp.com/terraform/install" rel="noopener noreferrer"&gt;Terraform Install page&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Git Tools
&lt;/h2&gt;

&lt;p&gt;Storing your Terraform configuration in a version control system is a must. I&lt;br&gt;
don't do development daily, so I forget to format my code and check for secrets&lt;br&gt;
or other vulnerabilities. One of the worst is that I forgot to update the&lt;br&gt;
README.md with any variable or output changes. The most annoying thing is that I&lt;br&gt;
forgot to create a new branch for my changes, as most of the git repos I work in&lt;br&gt;
have the default branch protected, so I can't push directly to it.&lt;/p&gt;

&lt;p&gt;To help me with all of these problems, I use&lt;br&gt;
&lt;a href="https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks" rel="noopener noreferrer"&gt;Git Hooks&lt;/a&gt;,&lt;br&gt;
specifically the &lt;code&gt;pre-commit&lt;/code&gt; hook to check for all of these things before I&lt;br&gt;
commit my changes. If you are unfamiliar with Git Hooks, they are scripts that&lt;br&gt;
run automatically when specific actions occur in a Git repository. You can use&lt;br&gt;
these scripts to enforce coding standards, check for vulnerabilities, or even&lt;br&gt;
run tests before you commit your changes.&lt;/p&gt;

&lt;p&gt;Traditionally, you would have to write these scripts yourself, but there is a&lt;br&gt;
tool called &lt;a href="https://pre-commit.com/" rel="noopener noreferrer"&gt;pre-commit&lt;/a&gt; that makes it easy to install&lt;br&gt;
and manage these hooks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;pre-commit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you have &lt;code&gt;pre-commit&lt;/code&gt; installed, you can create a &lt;code&gt;.pre-commit-config.yaml&lt;/code&gt;&lt;br&gt;
file in the root of your repository with the following contents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# yaml-language-server: $schema=https://json.schemastore.org/pre-commit-config.json&lt;/span&gt;

&lt;span class="na"&gt;repos&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local&lt;/span&gt;
    &lt;span class="na"&gt;hooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;trufflehog&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TruffleHog&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Detect secrets in your data.&lt;/span&gt;
        &lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;trufflehog git file://. --since-commit HEAD --fail&lt;/span&gt;
        &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;golang&lt;/span&gt;
        &lt;span class="na"&gt;pass_filenames&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pre-commit"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pre-push"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/pre-commit/pre-commit-hooks&lt;/span&gt;
    &lt;span class="na"&gt;rev&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v5.0.0&lt;/span&gt;
    &lt;span class="na"&gt;hooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;check-merge-conflict&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;end-of-file-fixer&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;no-commit-to-branch&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/antonbabenko/pre-commit-terraform&lt;/span&gt;
    &lt;span class="na"&gt;rev&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1.97.0&lt;/span&gt;
    &lt;span class="na"&gt;hooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform_fmt&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform_validate&lt;/span&gt;
        &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--hook-config=--retry-once-with-cleanup=true&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform_docs&lt;/span&gt;
        &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--args=--lockfile=false&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform_tflint&lt;/span&gt;
        &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--args=--config=__GIT_WORKING_DIR__/.tflint.hcl&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform_checkov&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;infracost_breakdown&lt;/span&gt;
        &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--args=--path=.&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--args=--terraform-var-file="terraform.tfvars"&lt;/span&gt;
        &lt;span class="c1"&gt;# verbose: true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every time you commit your changes, the &lt;code&gt;pre-commit&lt;/code&gt; tool runs and checks each&lt;br&gt;
hook you have defined. If any hook fails, the commit will abort, and you must&lt;br&gt;
fix the issues before committing your changes.&lt;/p&gt;

&lt;p&gt;We haven't covered all of the hooks in the &lt;code&gt;.pre-commit-config.yaml&lt;/code&gt; file, but&lt;br&gt;
I'll list the ones I use here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;trufflehog&lt;/code&gt;: Scans your git repo for committed secrets 😱.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;check-merge-conflict&lt;/code&gt;: Checks for files that contain merge conflict strings.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;end-of-file-fixer&lt;/code&gt;: Ensures that files end with a newline.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;no-commit-to-branch&lt;/code&gt;: Prevents commits directly to a branch (default branch
in our case).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terraform_fmt&lt;/code&gt;: Formats your Terraform code.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terraform_validate&lt;/code&gt;: Validates your Terraform code.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terraform_docs&lt;/code&gt;: Dynamically updates your README.md with information on your
module's inputs, outputs, and requirements.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terraform_tflint&lt;/code&gt;: A Terraform linter that checks for best practices and
errors in your Terraform code.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terraform_checkov&lt;/code&gt;: A tool that checks your Terraform code for security
vulnerabilities.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;infracost_breakdown&lt;/code&gt;: Gives you a cost estimate for the cloud resources your
module would deploy.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Tools needed for the Pre-commit hooks that I use
&lt;/h3&gt;

&lt;p&gt;You will need to install a few of these tools to use all of the &lt;code&gt;pre-commit&lt;/code&gt;&lt;br&gt;
hooks that I have listed above.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/trufflesecurity/trufflehog" rel="noopener noreferrer"&gt;Trufflehog&lt;/a&gt; scans your git repo&lt;br&gt;
for secrets 😱. Doing this as a pre-commit hook lets you catch secrets before&lt;br&gt;
committing them. This way, they don't end up in your git history, keeping your&lt;br&gt;
security team happy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;trufflesecurity/trufflehog/trufflehog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/terraform-docs/terraform-docs" rel="noopener noreferrer"&gt;Terraform-docs&lt;/a&gt; dynamically&lt;br&gt;
updates your README.md with information on your module's inputs, outputs, and&lt;br&gt;
requirements.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;terraform-docs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In your README.md file, you can add the following comments to have&lt;br&gt;
&lt;code&gt;terraform-docs&lt;/code&gt; update the file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- BEGIN_TF_DOCS --&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;&amp;lt;!-- END_TF_DOCS --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/infracost/infracost" rel="noopener noreferrer"&gt;Infracost&lt;/a&gt; provides a cost estimate for&lt;br&gt;
the cloud resources on which your configuration will be deployed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;infracost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/stedolan/jq" rel="noopener noreferrer"&gt;Jq&lt;/a&gt; is a lightweight and flexible command-line&lt;br&gt;
JSON processor. required for &lt;code&gt;terraform_validate&lt;/code&gt; with&lt;br&gt;
&lt;code&gt;--retry-once-with-cleanup&lt;/code&gt; flag, and for &lt;code&gt;infracost_breakdown&lt;/code&gt; hook.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;jq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/terraform-linters/tflint" rel="noopener noreferrer"&gt;TFLint&lt;/a&gt; is a Terraform linter that&lt;br&gt;
checks for best practices and errors in your Terraform code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;tflint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/bridgecrewio/checkov" rel="noopener noreferrer"&gt;Checkov&lt;/a&gt; is a static code analysis&lt;br&gt;
tool for infrastructure as code (IaC).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;checkov
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Convenience Tools
&lt;/h2&gt;

&lt;p&gt;With Terraform installed via &lt;code&gt;tfswitch&lt;/code&gt; and the &lt;code&gt;pre-commit&lt;/code&gt; hooks setup, I have&lt;br&gt;
a solid foundation for my Terraform development workflow. But I use a few more&lt;br&gt;
tools to make my life easier.&lt;/p&gt;

&lt;p&gt;First, I use &lt;a href="https://1password.com/" rel="noopener noreferrer"&gt;1Password&lt;/a&gt; to store my secrets, API keys,&lt;br&gt;
and passwords. I also use the&lt;br&gt;
&lt;a href="https://1password.com/downloads/command-line/" rel="noopener noreferrer"&gt;1Password CLI&lt;/a&gt; to access my&lt;br&gt;
secrets via environment variables and shell scripts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;1password 1password-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With 1Password and the 1Password CLI installed, I can access my secrets via the&lt;br&gt;
&lt;code&gt;op&lt;/code&gt; command.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TFE_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;op &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;--cache&lt;/span&gt; &lt;span class="s2"&gt;"op://Vault/Item/Key"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This allows me to easily set environment variables for my secrets in my shell.&lt;br&gt;
However, I need to remember to unset these variables when I'm done with them. To&lt;br&gt;
help with this, I use &lt;a href="https://direnv.net/" rel="noopener noreferrer"&gt;Direnv&lt;/a&gt; to set and unset variables&lt;br&gt;
based on my directory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;direnv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I use the following &lt;code&gt;.envrc&lt;/code&gt; file in the root of my Terraform configuration to&lt;br&gt;
set my secrets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Exports&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TFE_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;op &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;--cache&lt;/span&gt; &lt;span class="s2"&gt;"op://Vault/Item/Key"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Terraform Variable exports&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TF_VAR_okta_token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;op &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;--cache&lt;/span&gt; &lt;span class="s2"&gt;"op://Vault/Item/Section/Key"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TF_VAR_okta_client_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;op &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;--cache&lt;/span&gt; &lt;span class="s2"&gt;"op://Vault/Item/Section/Key"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TF_VAR_okta_client_secret&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;op &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;--cache&lt;/span&gt; &lt;span class="s2"&gt;"op://Vault/Item/Section/Key"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The above example exports the &lt;code&gt;TFE_TOKEN&lt;/code&gt; and a few Terraform variables. When I&lt;br&gt;
change into or out of the directory with the &lt;code&gt;.envrc&lt;/code&gt; file, &lt;code&gt;direnv&lt;/code&gt; will set&lt;br&gt;
and unset these variables for me.&lt;/p&gt;

&lt;p&gt;With so many tools in use, remembering all the commands to run can be a pain.&lt;br&gt;
Yes, I could create aliases for them, but I like to keep my shell clean. So, I&lt;br&gt;
use &lt;a href="https://taskfile.dev/" rel="noopener noreferrer"&gt;Task&lt;/a&gt; to create a &lt;code&gt;Taskfile.yml&lt;/code&gt; with all the&lt;br&gt;
commands I use.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# yaml-language-server: $schema=https://taskfile.dev/schema.json&lt;/span&gt;
&lt;span class="c1"&gt;# https://taskfile.dev&lt;/span&gt;

&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;

&lt;span class="na"&gt;dotenv&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.envrc"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;vars&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;CURRENT_DATE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;sh&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;date +"%Y-%m-%dT%H:%M:%S%Z"&lt;/span&gt;

&lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cmds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;task&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pre&lt;/span&gt;

  &lt;span class="na"&gt;hog&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cmds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;trufflehog git file://. --since-commit HEAD --only-verified --fail&lt;/span&gt;

  &lt;span class="na"&gt;pre&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cmds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pre-commit autoupdate&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pre-commit run -a&lt;/span&gt;

  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cmds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;git add .&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;git commit -m "{{.CURRENT_DATE}}"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;git push&lt;/span&gt;
    &lt;span class="na"&gt;silent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cmds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;git push&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;git tag -s {{.CLI_ARGS}} -m "{{.CLI_ARGS}}"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;git push --tags&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now I can run &lt;code&gt;task hog&lt;/code&gt; to scan my git repo for secrets, &lt;code&gt;task pre&lt;/code&gt; to run all&lt;br&gt;
of my &lt;code&gt;pre-commit&lt;/code&gt; hooks, &lt;code&gt;task push&lt;/code&gt; to commit my changes and push them to the&lt;br&gt;
remote, and &lt;code&gt;task tag -- v1.0.0&lt;/code&gt; to tag my release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This is how I set up my workstation for Terraform development. I hope you found&lt;br&gt;
it helpful. If you have any suggestions or tools that you use, please let me&lt;br&gt;
know. I am always looking for ways to improve my workflow.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>VS Code Setup for Terraform</title>
      <dc:creator>Michael Ethridge</dc:creator>
      <pubDate>Fri, 21 Aug 2026 18:15:35 +0000</pubDate>
      <link>https://dev.to/methridge/vs-code-setup-for-terraform-4lmp</link>
      <guid>https://dev.to/methridge/vs-code-setup-for-terraform-4lmp</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;One of the first questions I get when teaching Terraform is, "What editor should&lt;br&gt;
I use?" My answer is always, "Use what you are comfortable with. If you don't&lt;br&gt;
have a preference, I recommend Visual Studio Code." Over the years, I have used&lt;br&gt;
everything from &lt;code&gt;vi&lt;/code&gt; to &lt;code&gt;Notepad++&lt;/code&gt; to &lt;code&gt;Sublime Text&lt;/code&gt; to &lt;code&gt;Atom&lt;/code&gt; to&lt;br&gt;
&lt;code&gt;Visual Studio Code&lt;/code&gt;. Visual Studio Code is easy to pick up for new users and&lt;br&gt;
easy to customize to your liking.&lt;/p&gt;

&lt;p&gt;I am not a developer by trade; I'm more of a hack-it-together person. I copy a&lt;br&gt;
lot of code from the internet or use GenAI and modify it to fit my needs. So, I&lt;br&gt;
don't work in an IDE every day. I need something easy to use and easy to&lt;br&gt;
customize.&lt;/p&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;For those of you like me who want the quick and dirty, here are the steps to set&lt;br&gt;
up your VS Code for Terraform development:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install Visual Studio Code - &lt;a href="https://code.visualstudio.com/" rel="noopener noreferrer"&gt;Download&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Create a profile for Terraform - Profiles
&lt;/li&gt;
&lt;li&gt;Clone my Terraform template repository -
&lt;a href="https://github.com/404-code-not-found-com/terraform-module-template" rel="noopener noreferrer"&gt;Terraform Template&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Open the repository in VS Code - This will prompt you to install the
recommended extensions and import the settings.&lt;/li&gt;
&lt;li&gt;Go forth and Terraform!&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Visual Studio Code Profiles
&lt;/h2&gt;

&lt;p&gt;One of the reasons I like Visual Studio Code is that it supports profiles. I can&lt;br&gt;
create a profile for each type of work I do. I have profiles for Terraform,&lt;br&gt;
Python, Ansible, and Markdown. Each profile has its settings, extensions, and&lt;br&gt;
keybindings. By using profiles, I can tailor the editor to the work I am doing.&lt;/p&gt;
&lt;h3&gt;
  
  
  Creating a Profile
&lt;/h3&gt;

&lt;p&gt;To create a profile, click on the &lt;code&gt;gear&lt;/code&gt; icon in the lower-left corner of the VS&lt;br&gt;
Code window. Click on the &lt;code&gt;Profiles&lt;/code&gt; option in the' Settings' window.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/profiles.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/profiles.png" alt="VS Code Settings" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Profiles tab allows you to view your current profiles and create new ones.&lt;br&gt;
To create a new profile, click the &lt;code&gt;New Profile&lt;/code&gt; button.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/new-profile1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/new-profile1.png" alt="New Profile" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This creates a new &lt;code&gt;Untitled&lt;/code&gt; profile.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/new-profile2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/new-profile2.png" alt="Untitled Profile" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can now name your profile by editing the name field. You also have the&lt;br&gt;
option to copy the settings from an existing profile. This is useful for&lt;br&gt;
creating a new profile similar to an existing one or importing your default&lt;br&gt;
settings.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/copy-profile.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/copy-profile.png" alt="Copy From Profile" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  VS Code Extensions for Terraform
&lt;/h3&gt;

&lt;p&gt;I will admit that I am a bit of an extension junkie. I have a lot of extensions,&lt;br&gt;
but some don't play well together, so I use profiles to help limit the number of&lt;br&gt;
extensions I have enabled at any one time.&lt;/p&gt;

&lt;p&gt;I use four extensions for Terraform development in Visual Studio Code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://marketplace.visualstudio.com/items?itemName=hashicorp.hcl" rel="noopener noreferrer"&gt;HashiCorp HCL&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marketplace.visualstudio.com/items?itemName=hashicorp.sentinel" rel="noopener noreferrer"&gt;HashiCorp Sentinel&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marketplace.visualstudio.com/items?itemName=hashicorp.terraform" rel="noopener noreferrer"&gt;HashiCorp Terraform&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://marketplace.visualstudio.com/items?itemName=cardinal90.multi-cursor-case-preserve" rel="noopener noreferrer"&gt;Multiple cursor case preserver&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You're probably looking at the list and wondering, "Why do you need a case&lt;br&gt;
preserver?" How many times have you decided to change the name of a variable and&lt;br&gt;
then had to go back and change the case of the variable in multiple places? This&lt;br&gt;
extension will preserve the case of the variable when you change it. It's a&lt;br&gt;
small thing, but it saves me a lot of time.&lt;/p&gt;
&lt;h3&gt;
  
  
  VS Code Settings for Terraform
&lt;/h3&gt;

&lt;p&gt;I like to use a few settings when working with Terraform. Most of these can be&lt;br&gt;
set in the VS Code UI, but they can also be shared in a &lt;code&gt;JSON&lt;/code&gt; file within the&lt;br&gt;
&lt;code&gt;.vscode&lt;/code&gt;. directory of your working directory.&lt;/p&gt;

&lt;p&gt;Here are my Terraform specific VS Code settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"[terraform]"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"editor.defaultFormatter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hashicorp.terraform"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"[tfvars]"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"editor.defaultFormatter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hashicorp.terraform"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"editor.bracketPairColorization.enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"editor.formatOnPaste"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"editor.formatOnSave"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"editor.formatOnType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"editor.guides.bracketPairs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"active"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"editor.inlineSuggest.enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"editor.linkedEditing"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"editor.multiCursorModifier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"editor.renderControlCharacters"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"editor.renderWhitespace"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"all"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"editor.rulers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#A5FF90"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"column"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#FF628C"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"column"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"editor.stickyScroll.enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"editor.suggestSelection"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"first"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"editor.tabCompletion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"on"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"editor.tabSize"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"files.associations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"*.sh.tmpl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shellscript"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"files.trimTrailingWhitespace"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"terraform.languageServer.enable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not all of these settings are needed, but they are the ones I like to use. You&lt;br&gt;
can adjust them to fit your needs.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;editor.defaultFormatter&lt;/code&gt; - This sets the default formatter for the editor. I
like to use the HashiCorp Terraform formatter for all of my Terraform,
&lt;code&gt;tfvars&lt;/code&gt;, and Sentinel policy files.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;editor.bracketPairColorization.enabled&lt;/code&gt; - This will colorize the brackets in
your code to make it easier to see where they start and end.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;editor.formatOnPaste&lt;/code&gt;, &lt;code&gt;editor.formatOnSave&lt;/code&gt;, &lt;code&gt;editor.formatOnType&lt;/code&gt; - These
settings will format your code when you paste it, save the file, or type in
the editor.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;editor.guides.bracketPairs&lt;/code&gt; - This will highlight the bracket pair you work
within.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;editor.inlineSuggest.enabled&lt;/code&gt; - This will enable inline suggestions in the
editor.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;editor.linkedEditing&lt;/code&gt; - This will link the editing of a variable name to all
instances of that variable in the file.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;editor.multiCursorModifier&lt;/code&gt; - This sets the keybinding for creating multiple
cursors.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;editor.renderControlCharacters&lt;/code&gt; - This will render control characters in the
editor.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;editor.renderWhitespace&lt;/code&gt; - This will render whitespace in the editor.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;editor.rulers&lt;/code&gt; - This will add rulers to the editor in specified columns. I
don't like to have lines that are too long, so I set my rulers at 80 and 100
columns.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;editor.stickyScroll.enabled&lt;/code&gt; - This will enable sticky scrolling so that the
block header stays at the editor's top as you scroll through the code section.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;editor.suggestSelection&lt;/code&gt; - This sets the default suggestion selection to the
first suggestion.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;editor.tabCompletion&lt;/code&gt; - This will enable tab completion in the editor.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;editor.tabSize&lt;/code&gt; - This sets the tab size to 2 spaces.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;files.associations&lt;/code&gt; - This will associate the specified file extensions with
the specified language. I also work with a lot of shell script templates that
get processed by Terraform, so I associate the &lt;code&gt;.sh.tmpl&lt;/code&gt; files with the
&lt;code&gt;shellscript&lt;/code&gt; language.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;files.trimTrailingWhitespace&lt;/code&gt; - This will trim trailing whitespace from the
end of lines.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terraform.languageServer.enable&lt;/code&gt; will enable the Terraform language server.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This helps you get started with Terraform development in Visual Studio.&lt;/p&gt;

&lt;h2&gt;
  
  
  Updates
&lt;/h2&gt;

&lt;h3&gt;
  
  
  2025-01-25
&lt;/h3&gt;

&lt;p&gt;I removed settings for an older Terraform extension I used before the official&lt;br&gt;
HashiCorp extension was released. Also, I removed file associations that used&lt;br&gt;
the Terraform extension to format Sentinel policy files and generic HCL files. I&lt;br&gt;
now use the HashiCorp Sentinel extension for Sentinel policy files and the&lt;br&gt;
HashiCorp HCL extension for generic HCL files.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Running Vault on OpenShift with HCP Vault Auto-Unseal: Lessons Learned</title>
      <dc:creator>Michael Ethridge</dc:creator>
      <pubDate>Fri, 21 Aug 2026 18:15:34 +0000</pubDate>
      <link>https://dev.to/methridge/running-vault-on-openshift-with-hcp-vault-auto-unseal-lessons-learned-2j1h</link>
      <guid>https://dev.to/methridge/running-vault-on-openshift-with-hcp-vault-auto-unseal-lessons-learned-2j1h</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;I recently set up a three-node Vault Enterprise HA cluster on OpenShift, using&lt;br&gt;
HCP Vault as the auto-unseal provider via the transit secrets engine. On paper&lt;br&gt;
this is a straightforward combination of well-documented features. In practice,&lt;br&gt;
it was a series of traps — some subtle, some spectacular — that took multiple&lt;br&gt;
sessions to fully work through.&lt;/p&gt;

&lt;p&gt;This post covers the four main challenge areas I hit: getting &lt;code&gt;IPC_LOCK&lt;/code&gt; right&lt;br&gt;
on OpenShift, wiring up the auto-unseal token flow securely, managing Raft&lt;br&gt;
quorum safely during rolling updates, and working around a reconciliation bug in&lt;br&gt;
Vault Secrets Operator. I'll focus on what caught me off guard and what the&lt;br&gt;
correct solution looks like.&lt;/p&gt;

&lt;p&gt;The deployment is GitOps-managed via ArgoCD using a three-source Helm pattern:&lt;br&gt;
the upstream Vault chart, a values file from the repo, and raw manifests for&lt;br&gt;
cluster-level resources (SCCs, Routes, ConfigMaps) that Helm can't cleanly&lt;br&gt;
own.&lt;/p&gt;


&lt;h2&gt;
  
  
  Challenge 1: IPC_LOCK on OpenShift
&lt;/h2&gt;
&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;

&lt;p&gt;Vault requires the &lt;code&gt;IPC_LOCK&lt;/code&gt; Linux capability so it can call &lt;code&gt;mlockall()&lt;/code&gt; to&lt;br&gt;
prevent secrets from being swapped to disk. This is non-negotiable — if the&lt;br&gt;
capability is missing, Vault exits at startup. On OpenShift this runs into the&lt;br&gt;
default &lt;code&gt;restricted-v2&lt;/code&gt; Security Context Constraint (SCC), which doesn't&lt;br&gt;
include &lt;code&gt;IPC_LOCK&lt;/code&gt; in its allowed capabilities.&lt;/p&gt;

&lt;p&gt;I discovered this the hard way when all three pods went into &lt;code&gt;CrashLoopBackOff&lt;/code&gt;&lt;br&gt;
immediately after deploying. What made it more confusing was that the error&lt;br&gt;
message differed depending on the image:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Upstream image&lt;/strong&gt; (&lt;code&gt;docker.io/hashicorp/vault-enterprise&lt;/code&gt;): Vault starts,
calls &lt;code&gt;mlockall()&lt;/code&gt;, gets &lt;code&gt;EPERM&lt;/code&gt;, and exits with:
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Vault requires the IPC_LOCK capability
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Red Hat partner image&lt;/strong&gt; (&lt;code&gt;registry.connect.redhat.com/hashicorp/vault-enterprise&lt;/code&gt;):
The binary ships with &lt;code&gt;cap_ipc_lock+ep&lt;/code&gt; set via &lt;code&gt;setcap&lt;/code&gt;. When the kernel
drops that from the bounding set at exec time (because the SCC doesn't allow
it), the &lt;code&gt;exec()&lt;/code&gt; itself fails before Vault even starts:
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;  exec: /usr/bin/vault: operation not permitted
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Both root causes are the same — no &lt;code&gt;IPC_LOCK&lt;/code&gt; in the SCC — but the symptoms&lt;br&gt;
look completely different, which costs time when debugging.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Solution: Custom SCC
&lt;/h3&gt;

&lt;p&gt;The fix is a custom SCC that adds &lt;code&gt;IPC_LOCK&lt;/code&gt; as both an allowed and default&lt;br&gt;
capability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;security.openshift.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SecurityContextConstraints&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vault-ipc-lock&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kubernetes.io/description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;"&lt;/span&gt;
      &lt;span class="s"&gt;Extends restricted-v2 to allow IPC_LOCK for Vault mlock support.&lt;/span&gt;
&lt;span class="na"&gt;allowPrivilegeEscalation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;allowPrivilegedContainer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;allowedCapabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;IPC_LOCK&lt;/span&gt;
&lt;span class="na"&gt;defaultAddCapabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;IPC_LOCK&lt;/span&gt;
&lt;span class="na"&gt;fsGroup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MustRunAs&lt;/span&gt;
&lt;span class="na"&gt;readOnlyRootFilesystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;runAsUser&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MustRunAsRange&lt;/span&gt;
&lt;span class="na"&gt;seLinuxContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MustRunAs&lt;/span&gt;
&lt;span class="na"&gt;seccompProfiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;runtime/default&lt;/span&gt;
&lt;span class="na"&gt;supplementalGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RunAsAny&lt;/span&gt;
&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;configMap&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;downwardAPI&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;emptyDir&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ephemeral&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;persistentVolumeClaim&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;projected&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;secret&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Custom SCCs don't automatically get a &lt;code&gt;ClusterRole&lt;/code&gt; the way built-in ones do,&lt;br&gt;
so you also need to create one manually and bind it to the Vault service account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterRole&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;system:openshift:scc:vault-ipc-lock&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;security.openshift.io"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;securitycontextconstraints"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resourceNames&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vault-ipc-lock"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;use"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RoleBinding&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vault-ipc-lock&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vault&lt;/span&gt;
&lt;span class="na"&gt;roleRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;apiGroup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io&lt;/span&gt;
  &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterRole&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;system:openshift:scc:vault-ipc-lock&lt;/span&gt;
&lt;span class="na"&gt;subjects&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ServiceAccount&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vault&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vault&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The SCC Priority Gotcha
&lt;/h3&gt;

&lt;p&gt;Here's the non-obvious part. When multiple SCCs have the same priority,&lt;br&gt;
OpenShift's SCC resolver picks one based on a deterministic but non-obvious&lt;br&gt;
ordering algorithm. In my cluster, a pre-existing &lt;code&gt;odf-blackbox-scc&lt;/code&gt; had the&lt;br&gt;
same default priority of 10. Without an explicit hint in the pod spec, the&lt;br&gt;
init container landed on &lt;code&gt;odf-blackbox-scc&lt;/code&gt; and failed — despite the&lt;br&gt;
&lt;code&gt;vault&lt;/code&gt; service account being bound to my new SCC.&lt;/p&gt;

&lt;p&gt;The fix is to explicitly declare &lt;code&gt;IPC_LOCK&lt;/code&gt; in the container's &lt;code&gt;securityContext&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# In the Vault Helm values.yaml&lt;/span&gt;
&lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;extraContainers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[]&lt;/span&gt;
  &lt;span class="na"&gt;statefulSet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;securityContext&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;IPC_LOCK&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This hint steers the SCC resolver to select &lt;code&gt;vault-ipc-lock&lt;/code&gt; over any&lt;br&gt;
same-priority SCC that doesn't advertise &lt;code&gt;IPC_LOCK&lt;/code&gt; support. The hint is&lt;br&gt;
required even when &lt;code&gt;defaultAddCapabilities&lt;/code&gt; is set — without it, the resolver&lt;br&gt;
may never reach your SCC.&lt;/p&gt;
&lt;h3&gt;
  
  
  Image Choice
&lt;/h3&gt;

&lt;p&gt;I also switched from the Red Hat partner image to the upstream&lt;br&gt;
&lt;code&gt;docker.io/hashicorp/vault-enterprise:2.0.1-ent&lt;/code&gt;. Without the &lt;code&gt;docker.io/&lt;/code&gt;&lt;br&gt;
prefix, RHCOS short-name rewriting rewrites unqualified image tags to&lt;br&gt;
&lt;code&gt;registry.connect.redhat.com/...&lt;/code&gt; at pull time. That's fine in theory, but in&lt;br&gt;
practice the partner mirror had divergent cached bytes across nodes (different&lt;br&gt;
&lt;code&gt;sha256&lt;/code&gt; digests for the same tag) and was using a floating &lt;code&gt;2.0-ent&lt;/code&gt; tag&lt;br&gt;
rather than a pinned version. Once the SCC was in place, the upstream image&lt;br&gt;
with an explicit registry and pinned tag worked cleanly.&lt;/p&gt;


&lt;h2&gt;
  
  
  Challenge 2: Auto-Unseal Token Flow
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Why HCP Vault Dedicated?
&lt;/h3&gt;

&lt;p&gt;Before getting into the mechanics, it's worth explaining the choice of HCP&lt;br&gt;
Vault as the transit unseal provider rather than a self-hosted instance.&lt;/p&gt;

&lt;p&gt;The transit auto-unseal mechanism doesn't require HCP Vault — any Vault cluster&lt;br&gt;
(including Community Edition) can host a transit secrets engine and serve as the&lt;br&gt;
unseal provider. The problem is the bootstrap paradox: if your transit unseal&lt;br&gt;
cluster is also self-hosted, you need a way to unseal &lt;em&gt;it&lt;/em&gt; before it can unseal&lt;br&gt;
anything else. You've just moved the problem one level up.&lt;/p&gt;

&lt;p&gt;HCP Vault Dedicated sidesteps this entirely. HashiCorp operates the cluster,&lt;br&gt;
handles HA failover, applies upgrades, and manages the underlying infrastructure.&lt;br&gt;
Critically, it uses cloud KMS for its own unsealing, so there's no chicken-and-egg&lt;br&gt;
problem — it's always available when your OpenShift pods start up.&lt;/p&gt;

&lt;p&gt;For a use case like this — a single transit key used only at pod startup — the&lt;br&gt;
&lt;a href="https://developer.hashicorp.com/hcp/docs/vault/get-started/deployment-considerations/tiers-and-features" rel="noopener noreferrer"&gt;Development tier&lt;/a&gt;&lt;br&gt;
is sufficient. It's a single-node cluster (no SLA, fine for a non-critical&lt;br&gt;
dependency like this one), capped at 25 clients, and costs &lt;strong&gt;$0.03/hour&lt;/strong&gt; —&lt;br&gt;
roughly &lt;strong&gt;$21.60/month&lt;/strong&gt;. That's a low price for eliminating the unseal&lt;br&gt;
bootstrapping problem entirely and offloading upgrades and operations to&lt;br&gt;
HashiCorp.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Architecture
&lt;/h3&gt;

&lt;p&gt;Vault's transit auto-unseal requires a token with encrypt/decrypt access to an&lt;br&gt;
HCP Vault transit key. On every pod startup, Vault needs that token to be&lt;br&gt;
available before it can unseal. The token can't live in a static Kubernetes&lt;br&gt;
Secret (that would be a long-lived credential), so it needs to be minted fresh&lt;br&gt;
at startup using the pod's service account JWT.&lt;/p&gt;

&lt;p&gt;The flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;OpenShift projects each pod's service account token to
&lt;code&gt;/var/run/secrets/kubernetes.io/serviceaccount/token&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;An init container uses that JWT to authenticate to HCP Vault's JWT auth
method (&lt;code&gt;POST /v1/auth/jwt/login&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;On successful auth, it mints a short-lived child token (24h TTL, &lt;code&gt;no_parent&lt;/code&gt;
so it's an orphan, &lt;code&gt;autounseal&lt;/code&gt; policy)&lt;/li&gt;
&lt;li&gt;It renders the transit seal stanza with that token to a shared &lt;code&gt;emptyDir&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The main Vault container reads that stanza via &lt;code&gt;vault server -config=...&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;HCP Vault's JWT auth method is configured with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;bound_audiences: "https://kubernetes.default.svc"&lt;/code&gt; — matches the audience
in the SA token&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bound_subject: "system:serviceaccount:vault:vault"&lt;/code&gt; — locks it to
exactly this service account&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;jwks_url&lt;/code&gt; pointing to OpenShift's OIDC JWKS endpoint&lt;/li&gt;
&lt;li&gt;The cluster CA cert for TLS verification&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This gives each pod a fresh, per-pod-bound token that's useless to any other&lt;br&gt;
workload. It's about as tight as you can make a startup credential.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Evolution: curl to Vault Agent
&lt;/h3&gt;

&lt;p&gt;My first implementation used a curl+bash init container — about 50 lines of&lt;br&gt;
shell inline in &lt;code&gt;values.yaml&lt;/code&gt;. It worked but had several problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JSON parsing with &lt;code&gt;jq&lt;/code&gt; and &lt;code&gt;grep&lt;/code&gt; with fragile exit code handling&lt;/li&gt;
&lt;li&gt;The rendered &lt;code&gt;seal.hcl&lt;/code&gt; had the token in plaintext on disk (acceptable but
not great)&lt;/li&gt;
&lt;li&gt;Silent failure modes: if the token-create call failed, the shell would
continue and render an empty &lt;code&gt;seal.hcl&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Hard to maintain and test&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I replaced it with a declarative Vault Agent running in one-shot mode&lt;br&gt;
(&lt;code&gt;exit_after_auth = true&lt;/code&gt;). The agent config lives in a ConfigMap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# vault-agent-config.yaml&lt;/span&gt;
&lt;span class="nx"&gt;vault&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;address&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://vault.hashicorp.cloud:8200"&lt;/span&gt;

  &lt;span class="nx"&gt;tls&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ca_cert&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/vault/userconfig/hcp-ca/ca.crt"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;auto_auth&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="s2"&gt;"jwt"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;namespace&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"admin/my-org"&lt;/span&gt;
    &lt;span class="nx"&gt;mount_path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"auth/jwt"&lt;/span&gt;
    &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;path&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/var/run/secrets/kubernetes.io/serviceaccount/token"&lt;/span&gt;
      &lt;span class="nx"&gt;role&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"autounseal-token"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;sink&lt;/span&gt; &lt;span class="s2"&gt;"file"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/tmp/vault-agent-token"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;template&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;contents&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOT&lt;/span&gt;&lt;span class="sh"&gt;
{{- with secret "auth/token/create" "policies=autounseal" "period=24h" "no_parent=true" "orphan=true" -}}
seal "transit" {
  address     = "https://vault.hashicorp.cloud:8200"
  namespace   = "admin/my-org"
  token       = "{{ .Auth.ClientToken }}"
  key_name    = "autounseal"
  mount_path  = "transit"
  tls_ca_cert = "/vault/userconfig/hcp-ca/ca.crt"
}
{{- end -}}
&lt;/span&gt;&lt;span class="no"&gt;EOT
&lt;/span&gt;  &lt;span class="nx"&gt;destination&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/vault/userconfig/vault-config/seal.hcl"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent logs clearly report whether JWT auth succeeded and whether the&lt;br&gt;
template rendered — a significant improvement over silent shell failures. The&lt;br&gt;
&lt;code&gt;exit_after_auth = true&lt;/code&gt; flag makes it act as a one-shot runner rather than a&lt;br&gt;
persistent sidecar.&lt;/p&gt;
&lt;h3&gt;
  
  
  The &lt;code&gt;command:&lt;/code&gt; Override Gotcha
&lt;/h3&gt;

&lt;p&gt;One subtle issue: if you override &lt;code&gt;command:&lt;/code&gt; in the init container spec, you&lt;br&gt;
bypass the image's &lt;code&gt;docker-entrypoint.sh&lt;/code&gt;. That entrypoint runs a&lt;br&gt;
&lt;code&gt;setcap -r /usr/bin/vault&lt;/code&gt; cap-clearing step before execing the binary —&lt;br&gt;
important because the partner image ships with &lt;code&gt;cap_ipc_lock+ep&lt;/code&gt; baked into the&lt;br&gt;
binary's extended attributes.&lt;/p&gt;

&lt;p&gt;The fix is to only set &lt;code&gt;args:&lt;/code&gt; (e.g., &lt;code&gt;["agent", "-config=/path/to/agent.hcl"]&lt;/code&gt;)&lt;br&gt;
and leave &lt;code&gt;command:&lt;/code&gt; unset. The entrypoint handles the rest.&lt;/p&gt;


&lt;h2&gt;
  
  
  Challenge 3: Raft Quorum During Rolling Updates
&lt;/h2&gt;
&lt;h3&gt;
  
  
  The OnDelete Trap
&lt;/h3&gt;

&lt;p&gt;The Vault Helm chart sets &lt;code&gt;updateStrategy: OnDelete&lt;/code&gt; on the StatefulSet by&lt;br&gt;
default. This means ArgoCD applying a new chart revision stages the change in a&lt;br&gt;
new &lt;code&gt;controllerRevision&lt;/code&gt; but does &lt;strong&gt;not&lt;/strong&gt; restart any pods. You have to manually&lt;br&gt;
delete each pod to trigger the rollout.&lt;/p&gt;

&lt;p&gt;The danger is that multiple staged-but-unrolled revisions can accumulate. If&lt;br&gt;
you pushed three separate changes without rolling, deleting a pod applies all&lt;br&gt;
three at once. Always check &lt;code&gt;oc get statefulset vault -o json | jq&lt;br&gt;
'.status | {currentRevision, updateRevision}'&lt;/code&gt; before starting a rollout.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Voter vs. Follower Distinction
&lt;/h3&gt;

&lt;p&gt;When a Vault pod restarts and rejoins the Raft cluster, it goes through a&lt;br&gt;
stabilization window (default 10 seconds) before Raft's autopilot promotes it&lt;br&gt;
to a full voter. During this window, the pod is a non-voter — it's replicating&lt;br&gt;
and appears healthy, but it doesn't count toward quorum.&lt;/p&gt;

&lt;p&gt;I learned this the hard way. A 3-node cluster needs 2 voters for quorum. I&lt;br&gt;
bounced pod 0, saw it come up &lt;code&gt;1/1 Ready&lt;/code&gt; with&lt;br&gt;
&lt;code&gt;replication.standby: stream started&lt;/code&gt;, assumed it was healthy, and immediately&lt;br&gt;
deleted pod 1. Pod 1 went down before pod 0 achieved voter status, dropping the&lt;br&gt;
active voter count to 1 — below quorum — causing a brief write outage until&lt;br&gt;
things stabilized.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;1/1 Ready&lt;/code&gt; does not mean voter. Always verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check who the current leader is (unauthenticated)&lt;/span&gt;
oc &lt;span class="nt"&gt;-n&lt;/span&gt; vault &lt;span class="nb"&gt;exec &lt;/span&gt;vault-0 &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  wget &lt;span class="nt"&gt;-qO-&lt;/span&gt; http://127.0.0.1:8200/v1/sys/leader | jq &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="c"&gt;# Verify the just-rolled pod is a voter before touching the next&lt;/span&gt;
&lt;span class="nv"&gt;VAULT_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;root-token&amp;gt; vault operator raft list-peers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for &lt;code&gt;Voter: true&lt;/code&gt; on the pod you just rolled before deleting the next one.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Correct Rollout Order
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Identify the current leader at rollout time (don't assume it's pod 0 — it
could have changed since the last restart)&lt;/li&gt;
&lt;li&gt;Delete a follower and wait for &lt;code&gt;1/1 Ready&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;Voter: true&lt;/code&gt; in
&lt;code&gt;raft list-peers&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Repeat for the remaining follower&lt;/li&gt;
&lt;li&gt;Delete the leader last — it triggers re-election to one of the now-healthy
standbys before the leader pod restarts&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This sequence ensures quorum is never threatened.&lt;/p&gt;




&lt;h2&gt;
  
  
  Challenge 4: VSO Reconciliation Bugs
&lt;/h2&gt;

&lt;p&gt;I'm using Vault Secrets Operator (VSO) 1.4.0 to sync Vault secrets into&lt;br&gt;
Kubernetes Secrets. It hit two distinct bugs during this deployment.&lt;/p&gt;
&lt;h3&gt;
  
  
  Bug 1: Static Database Credentials Not Refreshing
&lt;/h3&gt;

&lt;p&gt;Vault was rotating the Terraform Enterprise database password weekly via the&lt;br&gt;
database secrets engine's static role. VSO was supposed to detect the rotation&lt;br&gt;
and update the Kubernetes Secret — but it wasn't. TFE pods started failing with&lt;br&gt;
&lt;code&gt;SQLSTATE 28P01&lt;/code&gt; (authentication failure) after each rotation.&lt;/p&gt;

&lt;p&gt;VSO's logs showed the reason:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;Vault&lt;/span&gt; &lt;span class="err"&gt;secret&lt;/span&gt; &lt;span class="err"&gt;does&lt;/span&gt; &lt;span class="err"&gt;not&lt;/span&gt; &lt;span class="err"&gt;support&lt;/span&gt; &lt;span class="err"&gt;periodic&lt;/span&gt; &lt;span class="err"&gt;renewal/refresh&lt;/span&gt; &lt;span class="err"&gt;via&lt;/span&gt; &lt;span class="err"&gt;reconciliation&lt;/span&gt;
&lt;span class="py"&gt;horizon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;VSO 1.4.0 doesn't treat static-creds responses as renewable by default. The&lt;br&gt;
fix is one field on the &lt;code&gt;VaultDynamicSecret&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;allowStaticCreds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;refreshAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;24h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;allowStaticCreds: true&lt;/code&gt; tells VSO to use the static-creds rotation tracker and&lt;br&gt;
honor the &lt;code&gt;rotation_period&lt;/code&gt; configured in Vault. &lt;code&gt;refreshAfter&lt;/code&gt; is a safety net&lt;br&gt;
in case the rotation tracker misses an event.&lt;/p&gt;
&lt;h3&gt;
  
  
  Bug 2: VSO Stops Reconciling After a Transient Vault HA Event
&lt;/h3&gt;

&lt;p&gt;After Vault HA events (leader re-election, pod restarts), VSO occasionally&lt;br&gt;
emits 500 errors like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;local node not active but active cluster node not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is transient and expected — it resolves once the new leader is fully&lt;br&gt;
established. The problem is that VSO 1.4.0 records the failure and then stops&lt;br&gt;
requeuing the affected &lt;code&gt;VaultStaticSecret&lt;/code&gt;. Days later, the secret is stale and&lt;br&gt;
VSO's controller shows zero log entries for it since the failure event.&lt;/p&gt;

&lt;p&gt;Restarting the VSO controller pod alone is not sufficient — it re-initializes&lt;br&gt;
but doesn't automatically re-pick stuck resources.&lt;/p&gt;

&lt;p&gt;The fix is to touch the VSS spec to force a requeue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Temporarily change refreshAfter to trigger the watcher&lt;/span&gt;
oc &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;namespace&amp;gt; patch vaultstaticsecret &amp;lt;name&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec":{"refreshAfter":"6m"}}'&lt;/span&gt;

&lt;span class="c"&gt;# Verify reconciliation fires, then revert to original value&lt;/span&gt;
oc &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;namespace&amp;gt; patch vaultstaticsecret &amp;lt;name&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec":{"refreshAfter":"5m"}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: the &lt;code&gt;vso.secrets.hashicorp.com/refresh&lt;/code&gt; annotation documented for&lt;br&gt;
triggering immediate reconciliation works for &lt;code&gt;VaultDynamicSecret&lt;/code&gt; but &lt;strong&gt;not&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;VaultStaticSecret&lt;/code&gt;. Only the spec-patch method reliably works for the latter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update (2026):&lt;/strong&gt; After some follow-up questions about whether this was still a&lt;br&gt;
problem, I dug in further — and the picture is more nuanced than "VSO gives up&lt;br&gt;
too early."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's a low-probability race, not a timeout.&lt;/strong&gt; The stuck state only happens&lt;br&gt;
when a reconcile is in flight &lt;em&gt;during&lt;/em&gt; the few-second leader-election window and&lt;br&gt;
lands on a node still returning the 500. With a multi-minute &lt;code&gt;refreshAfter&lt;/code&gt;,&lt;br&gt;
that overlap is rare — which is exactly why it's hard to reproduce on demand. I&lt;br&gt;
only caught it originally because it happened opportunistically across weeks of&lt;br&gt;
real rollouts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Upgrading does not fix it.&lt;/strong&gt; I assumed a later VSO release must have addressed&lt;br&gt;
this, but the release after 1.4.0 is dependency/build-only with no reconciler&lt;br&gt;
changes, and 1.4.0 already ships the &lt;code&gt;VaultStaticSecret&lt;/code&gt; client-callback handler&lt;br&gt;
(&lt;a href="https://github.com/hashicorp/vault-secrets-operator/pull/867" rel="noopener noreferrer"&gt;GH-867&lt;/a&gt;). The&lt;br&gt;
code path that sticks is present in both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I tried hard to reproduce it and couldn't.&lt;/strong&gt; I stood up a throwaway&lt;br&gt;
&lt;code&gt;VaultStaticSecret&lt;/code&gt; with &lt;code&gt;refreshAfter: 5s&lt;/code&gt; (so a reconcile is almost always in&lt;br&gt;
flight) and forced six real leader elections back-to-back. It self-healed every&lt;br&gt;
time — the 500 never even surfaced. Reliably reproducing it seems to need many&lt;br&gt;
more election/reconcile overlaps, not a handful.&lt;/p&gt;

&lt;p&gt;The open question: does GH-867's client-callback handler cover the "local node&lt;br&gt;
not active" case &lt;em&gt;during&lt;/em&gt; an election, or only clean reconnect events? What I&lt;br&gt;
saw suggests it doesn't fully cover the election race. The spec-patch recovery&lt;br&gt;
above still applies.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Running Vault on OpenShift is entirely doable and production-viable, but the&lt;br&gt;
combination of OpenShift's SCC model, Vault's Raft consensus requirements, and&lt;br&gt;
VSO's edge cases means there are more failure modes than the documentation&lt;br&gt;
covers. The main takeaways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;IPC_LOCK needs an explicit SCC&lt;/strong&gt; — and the &lt;code&gt;securityContext.capabilities.add&lt;/code&gt;
hint is required to steer SCC selection when multiple SCCs have equal priority&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Qualify your image references&lt;/strong&gt; — &lt;code&gt;docker.io/hashicorp/vault-enterprise:2.0.1-ent&lt;/code&gt;
is unambiguous; bare &lt;code&gt;hashicorp/vault-enterprise&lt;/code&gt; is not&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vault Agent beats curl+bash&lt;/strong&gt; — one-shot mode gives you declarative auth,
structured failure logging, and no shell fragility&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;1/1 Ready&lt;/code&gt; is not &lt;code&gt;Voter: true&lt;/code&gt;&lt;/strong&gt; — wait for &lt;code&gt;raft list-peers&lt;/code&gt; before
rolling the next pod&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VSO 1.4.0 has a stuck-VSS bug&lt;/strong&gt; — know the spec-patch workaround before
you need it in production&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Up Next: Terraform Enterprise on OpenShift
&lt;/h2&gt;

&lt;p&gt;The same lab cluster runs Terraform Enterprise in active-active mode alongside&lt;br&gt;
Vault, and it has its own set of OpenShift-specific challenges worth a dedicated&lt;br&gt;
post. The short preview:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL via CloudNativePG&lt;/strong&gt; — TFE needs a PostgreSQL cluster with a
specific schema owner and connection string format. Getting the VSO-managed
credentials to rotate cleanly with TFE's connection pool is where the
&lt;code&gt;allowStaticCreds&lt;/code&gt; fix from Challenge 4 above actually first surfaced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NooBaa S3 for object storage&lt;/strong&gt; — TFE uses S3-compatible storage for run
logs and state. OpenShift ships with NooBaa (via ODF) as an in-cluster S3
provider, which avoids a cloud dependency but requires matching TFE's expected
bucket configuration and path-style addressing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis for active-active coordination&lt;/strong&gt; — TFE's active-active mode uses
Redis as its coordination layer. Running Redis on OpenShift under
&lt;code&gt;restricted-v2&lt;/code&gt; has its own SCC story.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same VSO stuck-reconciliation bug&lt;/strong&gt; — hits TFE's Redis and S3 secrets
independently after any Vault HA event, so the spec-patch workaround becomes
a standard part of the Vault rollout runbook.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're planning a full HashiCorp stack on OpenShift, the Vault work above is&lt;br&gt;
the prerequisite — get Vault stable and auto-unsealing before bringing TFE up,&lt;br&gt;
since TFE uses Vault as its secrets backend from day one.&lt;/p&gt;

</description>
      <category>vault</category>
      <category>openshift</category>
      <category>kubernetes</category>
      <category>hcp</category>
    </item>
    <item>
      <title>Running Terraform Enterprise Active-Active on OpenShift: Lessons Learned</title>
      <dc:creator>Michael Ethridge</dc:creator>
      <pubDate>Fri, 21 Aug 2026 18:15:33 +0000</pubDate>
      <link>https://dev.to/methridge/running-terraform-enterprise-active-active-on-openshift-lessons-learned-53o8</link>
      <guid>https://dev.to/methridge/running-terraform-enterprise-active-active-on-openshift-lessons-learned-53o8</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;In my &lt;a href="https://www.404-code-not-found.com/posts/vault-on-openshift-hcp-autounseal" rel="noopener noreferrer"&gt;last post&lt;/a&gt;, I covered the four&lt;br&gt;
challenges I hit running Vault Enterprise on OpenShift. That same lab cluster&lt;br&gt;
also runs Terraform Enterprise (TFE) in active-active mode, using that Vault&lt;br&gt;
cluster as its secrets backend. TFE brought its own set of OpenShift-specific&lt;br&gt;
problems — some in TFE itself, some in the supporting PostgreSQL and object&lt;br&gt;
storage layers, and one that turned out to be the same VSO bug from the Vault&lt;br&gt;
post showing up again in a different place.&lt;/p&gt;

&lt;p&gt;The deployment follows the same GitOps pattern as Vault: ArgoCD, a three-source&lt;br&gt;
Helm setup (upstream TFE chart, a values file from the repo, and raw manifests&lt;br&gt;
for VSO custom resources, the Route, and the ImageStream/BuildConfig that&lt;br&gt;
supporting pieces need). TFE runs with 2 replicas against a CloudNativePG-based&lt;br&gt;
PostgreSQL cluster, a Redis deployment for active-active coordination, and a&lt;br&gt;
NooBaa S3 bucket (via OpenShift Data Foundation) for object storage. Every&lt;br&gt;
secret TFE needs — license, encryption password, database credentials, Redis&lt;br&gt;
password, S3 keys, registry pull secret, TLS certificate — comes from the local&lt;br&gt;
Vault cluster through Vault Secrets Operator (VSO).&lt;/p&gt;

&lt;p&gt;This post covers five things that caught me off guard: TFE's hard refusal to&lt;br&gt;
run mixed versions during a rolling update, why one Postgres cluster ended up&lt;br&gt;
with two different Vault-managed role designs, a missing trust anchor for the&lt;br&gt;
in-cluster S3 endpoint, the VSO stuck-reconciliation bug recurring across two&lt;br&gt;
more secrets, and what it takes to get TFE's job agent running under&lt;br&gt;
OpenShift's restricted security model.&lt;/p&gt;


&lt;h2&gt;
  
  
  Challenge 1: Active-Active Means Lockstep Versions
&lt;/h2&gt;
&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;

&lt;p&gt;TFE's active-active mode replicates its Rails processes across multiple pods&lt;br&gt;
that all have to agree on the running version. I found this out during what&lt;br&gt;
should have been a routine chart and image bump. The new pod came up as&lt;br&gt;
&lt;code&gt;CrashLoopBackOff&lt;/code&gt; while the two existing pods stayed healthy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;startup check failed: check=upgrade ... TFE version (2.0.4) is not
inter-compatible with system version (2.0.2), to upgrade to this version
active TFE versions must be stopped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The root cause is the Helm chart's default &lt;code&gt;RollingUpdate&lt;/code&gt; strategy with&lt;br&gt;
&lt;code&gt;maxSurge: 25%&lt;/code&gt; — it starts the new-version pod &lt;em&gt;alongside&lt;/em&gt; the two&lt;br&gt;
still-running old-version pods to keep capacity up during the rollout. TFE's&lt;br&gt;
own startup check treats that mixed-version state as invalid and refuses to&lt;br&gt;
come up, so the new pod crash-loops forever while the old pods sit there&lt;br&gt;
looking healthy.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Solution
&lt;/h3&gt;

&lt;p&gt;The fix is to pin the Deployment to &lt;code&gt;Recreate&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# values.yaml&lt;/span&gt;
&lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Recreate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Recreate&lt;/code&gt; tears down all pods before bringing any of them back up on the new&lt;br&gt;
image, so there's never a moment where two versions coexist. The trade-off is&lt;br&gt;
real and worth stating plainly: &lt;strong&gt;every deploy that touches the pod template —&lt;br&gt;
image bump or not — causes a brief full TFE outage while pods restart&lt;/strong&gt;&lt;br&gt;
(around 3 minutes, observed). For an active-active system, that's a step back&lt;br&gt;
from zero-downtime rollouts, but it's the only option that matches TFE's&lt;br&gt;
lockstep version requirement.&lt;/p&gt;

&lt;p&gt;If you ever hit the crash-loop mid-upgrade (say, the strategy setting got&lt;br&gt;
reverted by mistake), the recovery is a coordinated restart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc scale deployment terraform-enterprise &lt;span class="nt"&gt;-n&lt;/span&gt; tfe &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
oc scale deployment terraform-enterprise &lt;span class="nt"&gt;-n&lt;/span&gt; tfe &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One subtlety if you're running ArgoCD with &lt;code&gt;selfHeal: true&lt;/code&gt;: it will fight the&lt;br&gt;
scale-to-0 and start restoring replicas almost immediately. That's fine here —&lt;br&gt;
the new ReplicaSet on the new image brings both pods up together, which is&lt;br&gt;
exactly what you want. Both pods reached &lt;code&gt;1/1 Running&lt;/code&gt; within a few minutes.&lt;/p&gt;


&lt;h2&gt;
  
  
  Challenge 2: One Postgres Cluster, Two Vault Role Designs
&lt;/h2&gt;
&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;

&lt;p&gt;Vault's database secrets engine has an obvious, idiomatic pattern: a dynamic&lt;br&gt;
role that creates a brand-new Postgres user on every lease, with a short TTL.&lt;br&gt;
That's what I set up first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vault write tfe-psql/roles/tfe-psql-role &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;db_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tfe &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;creation_statements&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"
    CREATE ROLE &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;{{name}}&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';
    GRANT ALL PRIVILEGES ON DATABASE tfe TO &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;{{name}}&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;;
  "&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;default_ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"1h"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;max_ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"24h"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works — Vault happily mints a fresh, uniquely-named Postgres role every&lt;br&gt;
hour. The problem is that TFE doesn't want that. It expects to connect as one&lt;br&gt;
stable database user across restarts, and its migrations need that user to&lt;br&gt;
&lt;em&gt;own&lt;/em&gt; the database and the &lt;code&gt;public&lt;/code&gt; schema — not just have grants on them.&lt;br&gt;
Dynamic per-lease roles with randomized names don't fit that model at all.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Solution
&lt;/h3&gt;

&lt;p&gt;The fix was to layer Vault's &lt;strong&gt;static role&lt;/strong&gt; feature on top of the same&lt;br&gt;
database connection, rather than replacing the dynamic role. A static role&lt;br&gt;
manages the password for an existing, stable-named Postgres user on a fixed&lt;br&gt;
rotation schedule instead of creating new ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Allow both role types on the same connection&lt;/span&gt;
vault write tfe-psql/config/tfe-psql &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;allowed_roles&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"tfe-psql-role,tfe-static"&lt;/span&gt;

&lt;span class="c"&gt;# Register the static role&lt;/span&gt;
vault write tfe-psql/static-roles/tfe-static &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;db_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tfe-psql &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;username&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tfe_app &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;rotation_period&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;604800
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the &lt;code&gt;tfe_app&lt;/code&gt; user has to exist and own the right objects before Vault can&lt;br&gt;
manage its password. That's a one-time manual bootstrap: create the role, then&lt;br&gt;
hand over ownership as the Postgres superuser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create the role using the cluster's admin user&lt;/span&gt;
psql &lt;span class="s2"&gt;"host=tfe-psql-primary.tfe.svc.cluster.local user=tfe dbname=tfe sslmode=require"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"CREATE ROLE tfe_app WITH LOGIN PASSWORD '&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;INIT_PW&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;';"&lt;/span&gt;

&lt;span class="c"&gt;# As the postgres superuser, transfer ownership&lt;/span&gt;
psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; tfe &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
  ALTER DATABASE tfe OWNER TO tfe_app;
  GRANT ALL ON SCHEMA public TO tfe_app;
  ALTER SCHEMA public OWNER TO tfe_app;
"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, Vault rotates &lt;code&gt;tfe_app&lt;/code&gt;'s password every 7 days without ever&lt;br&gt;
changing the username, which is exactly what TFE needs. The dynamic role&lt;br&gt;
stays registered too — it's a fine pattern for ad hoc admin access, just not&lt;br&gt;
for the application's own connection.&lt;/p&gt;


&lt;h2&gt;
  
  
  Challenge 3: NooBaa S3 and a Missing Trust Anchor
&lt;/h2&gt;
&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;

&lt;p&gt;TFE uses S3-compatible object storage for run logs and state, and this&lt;br&gt;
cluster provides that in-cluster via NooBaa (part of OpenShift Data&lt;br&gt;
Foundation) rather than a cloud S3 bucket. Pointing TFE at NooBaa's internal&lt;br&gt;
endpoint seemed straightforward — until TFE crashed on startup with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;x509: certificate signed by unknown authority
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The NooBaa S3 endpoint's certificate is signed by OpenShift's own internal&lt;br&gt;
service-CA, and TFE's container doesn't trust that CA by default. Nothing&lt;br&gt;
about the bucket configuration was wrong; TFE just couldn't verify the TLS&lt;br&gt;
certificate on the connection.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Solution
&lt;/h3&gt;

&lt;p&gt;Every OpenShift namespace gets an auto-populated ConfigMap&lt;br&gt;
(&lt;code&gt;openshift-service-ca.crt&lt;/code&gt;) containing exactly that CA certificate. The fix&lt;br&gt;
is to mount it into the path TFE's chart already exposes for a custom CA&lt;br&gt;
bundle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# values.yaml&lt;/span&gt;
&lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caCertBaseDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/ssl/certs&lt;/span&gt;
  &lt;span class="na"&gt;caCertFileName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom_ca_certs.pem&lt;/span&gt;

&lt;span class="na"&gt;extraVolumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openshift-service-ca&lt;/span&gt;
    &lt;span class="na"&gt;configMap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openshift-service-ca.crt&lt;/span&gt;
      &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service-ca.crt&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom_ca_certs.pem&lt;/span&gt;

&lt;span class="na"&gt;extraVolumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openshift-service-ca&lt;/span&gt;
    &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/ssl/certs/custom_ca_certs.pem&lt;/span&gt;
    &lt;span class="na"&gt;subPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom_ca_certs.pem&lt;/span&gt;
    &lt;span class="na"&gt;readOnly&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once that ConfigMap is mounted and referenced by &lt;code&gt;caCertBaseDir&lt;/code&gt;/&lt;br&gt;
&lt;code&gt;caCertFileName&lt;/code&gt;, TFE trusts the NooBaa endpoint's certificate and connects&lt;br&gt;
cleanly.&lt;/p&gt;

&lt;p&gt;Worth noting on the bucket side: the &lt;code&gt;ObjectBucketClaim&lt;/code&gt; that provisions the&lt;br&gt;
bucket also produces a Secret with the S3 access keys, but TFE never reads&lt;br&gt;
that Secret directly. Those keys get copied into Vault, and VSO syncs them&lt;br&gt;
back out into the Secret TFE actually consumes — keeping VSO/Vault as the&lt;br&gt;
single source of truth for every credential, not just the ones that&lt;br&gt;
originate there.&lt;/p&gt;


&lt;h2&gt;
  
  
  Challenge 4: The VSO Stuck-Reconciliation Bug, Again
&lt;/h2&gt;

&lt;p&gt;If you read the Vault post, you'll remember VSO 1.4.0's bug where a&lt;br&gt;
&lt;code&gt;VaultStaticSecret&lt;/code&gt; can stop reconciling after a transient Vault HA error —&lt;br&gt;
and doesn't recover on its own even if you restart the VSO controller. That&lt;br&gt;
bug wasn't a one-off. It hit twice more here, on two of TFE's secrets, each&lt;br&gt;
time triggered by an unrelated Vault rollout causing a brief Raft leader&lt;br&gt;
election:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;local node not active but active cluster node not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once on the &lt;code&gt;VaultStaticSecret&lt;/code&gt; backing TFE's S3 credentials, and once on the&lt;br&gt;
one backing its Redis password — both during Vault maintenance that had&lt;br&gt;
nothing to do with TFE. Same symptom both times: &lt;code&gt;SecretSynced=False&lt;/code&gt;, a&lt;br&gt;
&lt;code&gt;lastTransitionTime&lt;/code&gt; stuck days in the past, and zero new controller log&lt;br&gt;
lines for the resource.&lt;/p&gt;

&lt;p&gt;The fix is the same spec-patch workaround from the Vault post — touch the&lt;br&gt;
resource to force VSO to requeue it, then revert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;oc &lt;span class="nt"&gt;-n&lt;/span&gt; tfe patch vaultstaticsecret tfe-s3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec":{"refreshAfter":"6m"}}'&lt;/span&gt;

&lt;span class="c"&gt;# confirm SecretSynced=True and a fresh lastTransitionTime, then revert&lt;/span&gt;
oc &lt;span class="nt"&gt;-n&lt;/span&gt; tfe patch vaultstaticsecret tfe-s3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec":{"refreshAfter":"5m"}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a related but distinct issue on TFE's database credentials, which&lt;br&gt;
come from a &lt;code&gt;VaultDynamicSecret&lt;/code&gt; rather than a &lt;code&gt;VaultStaticSecret&lt;/code&gt;. VSO 1.4.0&lt;br&gt;
doesn't treat a static-role database response as renewable unless told&lt;br&gt;
otherwise, so without one extra field it syncs once and never refetches —&lt;br&gt;
meaning the next scheduled password rotation leaves TFE with a stale&lt;br&gt;
password and failing Postgres logins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secrets.hashicorp.com/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;VaultDynamicSecret&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tfe-database&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tfe&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;vaultAuthRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tfe-auth&lt;/span&gt;
  &lt;span class="na"&gt;mount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tfe-psql&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;static-creds/tfe-static&lt;/span&gt;
  &lt;span class="na"&gt;allowStaticCreds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;refreshAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;24h&lt;/span&gt;
  &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tfe-database&lt;/span&gt;
    &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;rolloutRestartTargets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform-enterprise&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two different VSO resource types, two different failure modes, two different&lt;br&gt;
fixes — but both are now a standard part of the runbook rather than a&lt;br&gt;
surprise, since between the Vault deployment and this one, the same bug&lt;br&gt;
family has now hit three separate secrets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update (2026):&lt;/strong&gt; Digging in later reframed this. The stuck-reconciliation&lt;br&gt;
part is a &lt;em&gt;low-probability race&lt;/em&gt; — it needs a VSO reconcile in flight during&lt;br&gt;
the brief leader-election window — not a frequent recurrence, and it isn't&lt;br&gt;
fixed by upgrading VSO (the release after 1.4.0 is dependency/build-only, and&lt;br&gt;
1.4.0 already ships the relevant &lt;code&gt;VaultStaticSecret&lt;/code&gt; client-callback handler).&lt;br&gt;
I couldn't reproduce it in six back-to-back forced elections. Full write-up in&lt;br&gt;
the &lt;a href="https://www.404-code-not-found.com/posts/vault-on-openshift-hcp-autounseal/#bug-2-vso-stops-reconciling-after-a-transient-vault-ha-event" rel="noopener noreferrer"&gt;Vault post's Bug 2 update&lt;/a&gt;.&lt;br&gt;
The spec-patch recovery above still applies.&lt;/p&gt;


&lt;h2&gt;
  
  
  Challenge 5: A Custom Job-Agent Image for OpenShift
&lt;/h2&gt;
&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;

&lt;p&gt;TFE runs each Terraform run in a short-lived "agent" container. The&lt;br&gt;
stock &lt;code&gt;hashicorp/tfc-agent&lt;/code&gt; image fails admission under OpenShift's&lt;br&gt;
restricted security model — it expects to write to a working directory owned&lt;br&gt;
by a fixed UID, which doesn't hold up once OpenShift assigns the container an&lt;br&gt;
arbitrary UID from the namespace's allowed range.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Solution
&lt;/h3&gt;

&lt;p&gt;HashiCorp's own OpenShift deployment guide covers this: build a thin wrapper&lt;br&gt;
image that opens up the permissions the arbitrary UID needs. I wired it up as&lt;br&gt;
an OpenShift &lt;code&gt;BuildConfig&lt;/code&gt; that layers a couple of &lt;code&gt;chmod&lt;/code&gt; calls onto the&lt;br&gt;
upstream image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build.openshift.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BuildConfig&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tfc-agent&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tfe-agents&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ImageStreamTag&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tfc-agent:latest&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Dockerfile&lt;/span&gt;
    &lt;span class="na"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;FROM docker.io/hashicorp/tfc-agent:latest&lt;/span&gt;
      &lt;span class="s"&gt;USER root&lt;/span&gt;
      &lt;span class="s"&gt;RUN mkdir /.tfc-agent &amp;amp;&amp;amp; \&lt;/span&gt;
          &lt;span class="s"&gt;chmod og+rw /.tfc-agent &amp;amp;&amp;amp; \&lt;/span&gt;
          &lt;span class="s"&gt;chmod o+rx /home/tfc-agent&lt;/span&gt;
      &lt;span class="s"&gt;USER tfc-agent&lt;/span&gt;
  &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Docker&lt;/span&gt;
    &lt;span class="na"&gt;dockerStrategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DockerImage&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker.io/hashicorp/tfc-agent:latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TFE points at the built image via &lt;code&gt;TFE_RUN_PIPELINE_IMAGE&lt;/code&gt;, referencing the&lt;br&gt;
in-cluster image registry rather than Docker Hub directly. Re-running&lt;br&gt;
&lt;code&gt;oc start-build tfc-agent&lt;/code&gt; picks up new upstream releases of &lt;code&gt;tfc-agent&lt;/code&gt;&lt;br&gt;
without needing to restart TFE itself — the new image is used the next time&lt;br&gt;
an agent pod spawns.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Few Smaller Landmines
&lt;/h2&gt;

&lt;p&gt;Two more things that didn't warrant a full section but are worth knowing&lt;br&gt;
before you deploy TFE on OpenShift yourself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The chart hard-codes its own resource names.&lt;/strong&gt; The Deployment and Service
are both named the literal string &lt;code&gt;terraform-enterprise&lt;/code&gt;, regardless of
your Helm release name — &lt;code&gt;nameOverride&lt;/code&gt;/&lt;code&gt;fullnameOverride&lt;/code&gt; exist in the
chart's helpers but the main templates never use them. Anything that needs
to reference TFE by name (VSO's &lt;code&gt;rolloutRestartTargets&lt;/code&gt;, a Route's
&lt;code&gt;to.name&lt;/code&gt;) has to use &lt;code&gt;terraform-enterprise&lt;/code&gt;, not your release name. The
Service's port is also named &lt;code&gt;https-port&lt;/code&gt;, not the more conventional
&lt;code&gt;https&lt;/code&gt; — a Route with the wrong &lt;code&gt;targetPort&lt;/code&gt; gives a silent 503 rather
than an obvious error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The chart defaults to a &lt;code&gt;LoadBalancer&lt;/code&gt; Service.&lt;/strong&gt; On a bare-metal cluster
with no LoadBalancer provider, that sits at &lt;code&gt;EXTERNAL-IP: &amp;lt;pending&amp;gt;&lt;/code&gt;
forever and keeps ArgoCD's health check stuck on &lt;code&gt;Progressing&lt;/code&gt;. Overriding
to &lt;code&gt;service.type: ClusterIP&lt;/code&gt; and fronting it with an OpenShift Route
clears this up immediately.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Running TFE active-active on OpenShift is straightforward once you've hit&lt;br&gt;
each of these once, but the documentation doesn't cover any of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Active-active means lockstep versions&lt;/strong&gt; — &lt;code&gt;RollingUpdate&lt;/code&gt; will crash-loop
a mixed-version rollout every time; &lt;code&gt;strategy: Recreate&lt;/code&gt; is the only
correct answer, at the cost of a brief outage on every deploy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic Vault roles don't fit every app&lt;/strong&gt; — TFE's stable-username,
owns-the-schema requirement needed a static role layered onto the same
database connection, with a one-time manual ownership handoff&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-cluster S3 needs in-cluster trust&lt;/strong&gt; — mount OpenShift's own
&lt;code&gt;openshift-service-ca.crt&lt;/code&gt; ConfigMap if you're pointing TFE at NooBaa
instead of a cloud S3 endpoint&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The VSO stuck-reconciliation bug isn't Vault-specific&lt;/strong&gt; — it hit TFE's S3
and Redis secrets independently, both times triggered by unrelated Vault
HA events; the spec-patch workaround is now a standing runbook step&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenShift's restricted SCC reaches into the job agent, too&lt;/strong&gt; — not just
the TFE pods themselves; budget time for the custom &lt;code&gt;tfc-agent&lt;/code&gt; image build&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Between this post and the Vault one, that's the full HashiCorp-on-OpenShift&lt;br&gt;
stack this lab runs — Vault as the secrets backend, VSO as the sync layer,&lt;br&gt;
and TFE consuming both. Every failure mode above came from the same root&lt;br&gt;
cause in different clothes: OpenShift's stricter defaults (SCCs, arbitrary&lt;br&gt;
UIDs, in-cluster CAs) and TFE's own strict operational assumptions (lockstep&lt;br&gt;
versions, stable database identity) both push back against defaults that&lt;br&gt;
work fine on vanilla Kubernetes.&lt;/p&gt;

</description>
      <category>terraformenterprise</category>
      <category>openshift</category>
      <category>kubernetes</category>
      <category>postgres</category>
    </item>
    <item>
      <title>How I Structure a Terraform Module</title>
      <dc:creator>Michael Ethridge</dc:creator>
      <pubDate>Fri, 21 Aug 2026 18:15:32 +0000</pubDate>
      <link>https://dev.to/methridge/how-i-structure-a-terraform-module-2gl2</link>
      <guid>https://dev.to/methridge/how-i-structure-a-terraform-module-2gl2</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Open a Terraform repository you have never seen before. There is a good chance&lt;br&gt;
it looks like this: one &lt;code&gt;main.tf&lt;/code&gt; with eight hundred lines in it, a&lt;br&gt;
&lt;code&gt;variables.tf&lt;/code&gt; where required and optional inputs are shuffled together in&lt;br&gt;
alphabetical order, a provider block with no version constraint at all, and a&lt;br&gt;
&lt;code&gt;README.md&lt;/code&gt; that documented the inputs accurately about four months ago.&lt;/p&gt;

&lt;p&gt;None of that is anybody's fault. Terraform doesn't care how you arrange your&lt;br&gt;
files. Every file ending in &lt;code&gt;.tf&lt;/code&gt; in a directory gets concatenated before&lt;br&gt;
anything is evaluated, so the language gives you no reason to prefer one layout&lt;br&gt;
over another. The tutorials all put everything in &lt;code&gt;main.tf&lt;/code&gt; because they're&lt;br&gt;
teaching one resource at a time, and then that becomes the shape of the repo&lt;br&gt;
forever.&lt;/p&gt;

&lt;p&gt;I'm not a developer by trade. I don't have the muscle memory that lets someone&lt;br&gt;
navigate an unfamiliar codebase by feel. What I have instead is a layout I use&lt;br&gt;
every single time, so that six months from now, when I come back to a module I&lt;br&gt;
half remember, I know where things are before I open anything.&lt;/p&gt;

&lt;p&gt;This post is that layout, and the reasoning behind each piece of it. Every&lt;br&gt;
convention here is a response to a specific way I have watched a module go bad.&lt;br&gt;
It's all based on the&lt;br&gt;
&lt;a href="https://developer.hashicorp.com/terraform/language/style" rel="noopener noreferrer"&gt;HashiCorp style guide&lt;/a&gt;,&lt;br&gt;
with opinions layered on where the style guide leaves room.&lt;/p&gt;

&lt;p&gt;Everything below is scaffolded in&lt;br&gt;
&lt;a href="https://github.com/404-code-not-found-com/terraform-module-template" rel="noopener noreferrer"&gt;terraform-module-template&lt;/a&gt;,&lt;br&gt;
which I use for modules and root modules alike.&lt;/p&gt;
&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;data.tf&lt;/code&gt;, &lt;code&gt;locals.tf&lt;/code&gt;, &lt;code&gt;providers.tf&lt;/code&gt;, &lt;code&gt;terraform.tf&lt;/code&gt;, &lt;code&gt;backend.tf&lt;/code&gt;,
&lt;code&gt;variables.tf&lt;/code&gt;, and &lt;code&gt;outputs.tf&lt;/code&gt; always exist, even when empty.&lt;/li&gt;
&lt;li&gt;Resources live in &lt;code&gt;main.tf&lt;/code&gt; until there are roughly twenty of them, then split
by service or function.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;variables.tf&lt;/code&gt; is two sections, required before optional, alphabetized inside
each. &lt;code&gt;outputs.tf&lt;/code&gt; is one alphabetized list.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terraform.tf&lt;/code&gt; holds &lt;code&gt;required_version&lt;/code&gt; and nothing else. &lt;code&gt;required_providers&lt;/code&gt;
belongs in &lt;code&gt;providers.tf&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Pin everything pinnable with &lt;code&gt;~&amp;gt; Maj.Min&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Generate the README with &lt;code&gt;terraform-docs&lt;/code&gt;, do not write it.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;fmt&lt;/code&gt;, &lt;code&gt;validate&lt;/code&gt;, &lt;code&gt;docs&lt;/code&gt;, &lt;code&gt;tflint&lt;/code&gt;, and &lt;code&gt;trivy&lt;/code&gt; in pre-commit, so you find
problems before a plan does.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Problem 1: Everything Ends Up in main.tf
&lt;/h2&gt;

&lt;p&gt;A module starts with three resources, so of course they go in &lt;code&gt;main.tf&lt;/code&gt;. Then it&lt;br&gt;
grows. Nobody ever decides to put eight hundred lines in one file. It happens&lt;br&gt;
because the alternative requires a decision, and there's never a good moment to&lt;br&gt;
make it.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Fix: Files by Role First, Size Second
&lt;/h3&gt;

&lt;p&gt;Some file names are reserved for a role, and those files always exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;backend.tf      state backend configuration
data.tf         data sources
locals.tf       local values
main.tf         resources
outputs.tf      module outputs
providers.tf    required_providers, plus provider configuration
terraform.tf    required_version
variables.tf    input variables
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resources stay in &lt;code&gt;main.tf&lt;/code&gt; until there are around twenty of them. Past that,&lt;br&gt;
&lt;code&gt;main.tf&lt;/code&gt; splits by service or function: &lt;code&gt;network.tf&lt;/code&gt;, &lt;code&gt;iam.tf&lt;/code&gt;, &lt;code&gt;database.tf&lt;/code&gt;.&lt;br&gt;
The number isn't sacred. The point is that the split is triggered by size, and&lt;br&gt;
only resources ever get split. Data sources, locals, and provider configuration&lt;br&gt;
stay in their own files no matter how few or how many there are.&lt;/p&gt;

&lt;p&gt;That asymmetry is deliberate. Resource count varies enormously between modules,&lt;br&gt;
so where resources live has to scale. The other categories are small and&lt;br&gt;
bounded, and their value comes from being findable at a fixed address rather&lt;br&gt;
than from being organized well.&lt;/p&gt;
&lt;h3&gt;
  
  
  Why Empty Files Stay
&lt;/h3&gt;

&lt;p&gt;In a fresh module, &lt;code&gt;data.tf&lt;/code&gt; and &lt;code&gt;locals.tf&lt;/code&gt; are empty except for a header&lt;br&gt;
comment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;###############################################################################&lt;/span&gt;
&lt;span class="c1"&gt;# data.tf&lt;/span&gt;
&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;span class="c1"&gt;# Contains any data sources&lt;/span&gt;
&lt;span class="c1"&gt;###############################################################################&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the convention people push back on most, and I'll keep defending it. An&lt;br&gt;
absent file carries no information. You can't tell whether this module has no&lt;br&gt;
data sources or whether someone put them in &lt;code&gt;main.tf&lt;/code&gt; without looking. A&lt;br&gt;
present, empty file answers both questions at once: there are no data sources&lt;br&gt;
yet, and here is where the next one goes.&lt;/p&gt;

&lt;p&gt;Most structural decay in a repo doesn't come from someone disagreeing with the&lt;br&gt;
layout. It comes from someone adding a data source at 5pm, not seeing an obvious&lt;br&gt;
home for it, and dropping it at the bottom of &lt;code&gt;main.tf&lt;/code&gt;. An empty &lt;code&gt;data.tf&lt;/code&gt;&lt;br&gt;
removes the decision, which is the only reliable way to make a convention&lt;br&gt;
survive contact with a deadline.&lt;/p&gt;
&lt;h2&gt;
  
  
  Problem 2: You Can't Tell Required From Optional
&lt;/h2&gt;

&lt;p&gt;Alphabetical ordering in &lt;code&gt;variables.tf&lt;/code&gt; sounds obviously correct, and it's the&lt;br&gt;
default advice. It also means that the first thing you want to know about a&lt;br&gt;
module - what do I have to give it? - is the one thing the file won't tell you.&lt;br&gt;
You have to read every block and check each one for a &lt;code&gt;default&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Fix: Two Sections, Alphabetized Inside Each
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;variables.tf&lt;/code&gt; is split into required variables, meaning no &lt;code&gt;default&lt;/code&gt;, followed&lt;br&gt;
by optional variables, meaning there is one. Each section is alphabetized&lt;br&gt;
independently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;###############################################################################&lt;/span&gt;
&lt;span class="c1"&gt;# Required Variables (no default values)&lt;/span&gt;
&lt;span class="c1"&gt;###############################################################################&lt;/span&gt;
&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"image_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"The id of the machine image (AMI) to use for the server."&lt;/span&gt;

  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="err"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"ami-"&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"The image_id value must be a valid AMI id, starting with &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;ami-&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;."&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;###############################################################################&lt;/span&gt;
&lt;span class="c1"&gt;# Optional Variables (has a default value)&lt;/span&gt;
&lt;span class="c1"&gt;###############################################################################&lt;/span&gt;
&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"instance_type"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Instance Type"&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"t3.micro"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the top of the file is the module's contract. Everything above the second&lt;br&gt;
banner is what a caller must supply. Everything below it is a knob they may&lt;br&gt;
choose to turn. Sorting alphabetically inside each section keeps lookup fast&lt;br&gt;
without giving up that distinction.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;outputs.tf&lt;/code&gt; gets plain alphabetical ordering, because outputs have no&lt;br&gt;
equivalent of the required/optional split. There is only one axis to sort on, so&lt;br&gt;
sort on it.&lt;/p&gt;
&lt;h3&gt;
  
  
  Make the Generated Docs Agree
&lt;/h3&gt;

&lt;p&gt;A convention that only lives in the source file will drift the moment something&lt;br&gt;
else renders the same information in a different order. &lt;code&gt;terraform-docs&lt;/code&gt;&lt;br&gt;
produces the inputs table in the README, and by default it sorts by name, which&lt;br&gt;
would immediately contradict &lt;code&gt;variables.tf&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;.terraform-docs.yml&lt;/code&gt; is configured to sort the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;sort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;by&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;required&lt;/span&gt;

&lt;span class="na"&gt;settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;anchor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Required inputs come first in the generated table, exactly as they do in the&lt;br&gt;
file that generated it. It's a small thing, but it is the difference between a&lt;br&gt;
convention that is asserted once in a style guide and one that is enforced in&lt;br&gt;
two places and therefore holds.&lt;/p&gt;
&lt;h2&gt;
  
  
  Problem 3: One File for Every Version Constraint
&lt;/h2&gt;

&lt;p&gt;The common layout is a single &lt;code&gt;versions.tf&lt;/code&gt; holding both &lt;code&gt;required_version&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;required_providers&lt;/code&gt;, or a &lt;code&gt;terraform&lt;/code&gt; block sitting at the top of &lt;code&gt;main.tf&lt;/code&gt;&lt;br&gt;
with everything in it. Both work. Both bury two unrelated decisions in the same&lt;br&gt;
place.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Fix: Three Files, Three Concerns
&lt;/h3&gt;

&lt;p&gt;The version of Terraform itself, alone in its own file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# terraform.tf&lt;/span&gt;
&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 1.0"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The providers, their versions, and their configuration, together in another:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# providers.tf&lt;/span&gt;
&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;google&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/google"&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 6.28"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"google"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# Configuration options&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And where state lives, in a third:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# backend.tf&lt;/span&gt;
&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cloud&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;organization&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"example_corp"&lt;/span&gt;
    &lt;span class="nx"&gt;hostname&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app.terraform.io"&lt;/span&gt;

    &lt;span class="nx"&gt;workspaces&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"app"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes, that's three separate &lt;code&gt;terraform&lt;/code&gt; blocks across three files. Terraform&lt;br&gt;
merges them, so this costs nothing at evaluation time and buys a clean&lt;br&gt;
separation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;terraform.tf&lt;/code&gt;&lt;/strong&gt; is about the tool. It changes when I decide to adopt a new
Terraform version, which is a deliberate, infrequent, repo-wide decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;providers.tf&lt;/code&gt;&lt;/strong&gt; is about dependencies. It changes when a provider ships
something I need, which happens on somebody else's schedule and much more
often. It also holds provider configuration, which is what I'm editing most of
the time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;backend.tf&lt;/code&gt;&lt;/strong&gt; is about where state lives. It's the one file most likely to
differ between environments or to be supplied at init time, and the one most
likely to be the reason a &lt;code&gt;terraform init&lt;/code&gt; behaves differently on someone
else's machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keeping them apart means a diff tells you which kind of change you are looking&lt;br&gt;
at before you read a single line of it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Problem 4: Pins That Are Too Loose or Too Tight
&lt;/h2&gt;

&lt;p&gt;There are two failure modes here and they look nothing alike.&lt;/p&gt;

&lt;p&gt;Too loose, usually meaning no constraint at all, gives you a plan that worked&lt;br&gt;
yesterday and doesn't work today, because a provider released a major version&lt;br&gt;
overnight and &lt;code&gt;terraform init&lt;/code&gt; on a clean checkout happily took it.&lt;/p&gt;

&lt;p&gt;Too tight, meaning an exact pin like &lt;code&gt;version = "6.28.0"&lt;/code&gt;, gives you a module&lt;br&gt;
that never picks up a bug fix unless someone edits it, and a fleet of repos&lt;br&gt;
pinned to a scatter of slightly different patch releases.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Fix: Pessimistic Constraints on Everything Pinnable
&lt;/h3&gt;

&lt;p&gt;Everything that can be pinned gets pinned with the pessimistic operator at the&lt;br&gt;
major-minor level: Terraform core, every provider, every module call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 6.28"&lt;/span&gt;    &lt;span class="c1"&gt;# &amp;gt;= 6.28.0, &amp;lt; 7.0.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Minor and patch releases flow in. A major version, which is where the breaking&lt;br&gt;
changes live by convention, does not. That's the tradeoff I want almost&lt;br&gt;
everywhere: I get fixes without being asked, and I get to schedule the upgrades&lt;br&gt;
that cost me something.&lt;/p&gt;

&lt;p&gt;One edge case. For a provider still on &lt;code&gt;0.x&lt;/code&gt;, &lt;code&gt;~&amp;gt; 0.104&lt;/code&gt; expands to&lt;br&gt;
&lt;code&gt;&amp;gt;= 0.104.0, &amp;lt; 1.0.0&lt;/code&gt;, and pre-1.0 providers routinely make breaking changes on&lt;br&gt;
a minor bump. If a dependency matters and is still pre-1.0, tighten it to&lt;br&gt;
&lt;code&gt;~&amp;gt; 0.104.0&lt;/code&gt; and accept the manual bumps.&lt;/p&gt;
&lt;h3&gt;
  
  
  Commit the Lock File
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;~&amp;gt; 6.28&lt;/code&gt; is a statement about what you will tolerate. &lt;code&gt;.terraform.lock.hcl&lt;/code&gt; is&lt;br&gt;
a record of what you actually got, and it belongs in version control.&lt;/p&gt;

&lt;p&gt;The lock file holds on to a provider entry as long as state still references&lt;br&gt;
that provider, even after you've deleted every last resource for it from your&lt;br&gt;
configuration. The instinct is to open the lock file and delete the stanza.&lt;br&gt;
Don't. Apply first so the resources leave state, and the next &lt;code&gt;terraform init&lt;/code&gt;&lt;br&gt;
will prune the entry on its own. Hand-editing a lock file to fix a problem that&lt;br&gt;
an apply would have fixed tends to produce a second, stranger problem.&lt;/p&gt;
&lt;h2&gt;
  
  
  Problem 5: The README Drifts
&lt;/h2&gt;

&lt;p&gt;Every Terraform README starts with an accurate inputs table. Then someone adds a&lt;br&gt;
variable in a hurry. The code is right and the documentation is wrong, and now&lt;br&gt;
the README is worse than no README, because people trust it.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Fix: Generate It
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;terraform-docs&lt;/code&gt; reads the actual variable and output blocks and writes the&lt;br&gt;
tables into the README between marker comments. It runs as a pre-commit hook, so&lt;br&gt;
it is not something anyone has to remember:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform_docs&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--args=--lockfile=false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one thing that catches everybody the first time: the hook rewrites&lt;br&gt;
&lt;code&gt;README.md&lt;/code&gt; during the commit, which means the commit fails, because the file it&lt;br&gt;
just changed was not part of what you staged. This is correct behavior and it&lt;br&gt;
looks like a broken hook. Re-stage and commit again and it passes. It only&lt;br&gt;
happens when the generated content actually changed, so after the first time it&lt;br&gt;
mostly disappears.&lt;/p&gt;
&lt;h2&gt;
  
  
  Problem 6: You Find the Error at Apply Time
&lt;/h2&gt;

&lt;p&gt;The expensive version of every mistake in this post is the one you discover&lt;br&gt;
after &lt;code&gt;terraform apply&lt;/code&gt; has already created four of seven resources.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Fix: A Pre-Commit Chain, in Order
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/antonbabenko/pre-commit-terraform&lt;/span&gt;
  &lt;span class="na"&gt;rev&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1.108.1&lt;/span&gt;
  &lt;span class="na"&gt;hooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform_fmt&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform_validate&lt;/span&gt;
      &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--hook-config=--retry-once-with-cleanup=true&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform_docs&lt;/span&gt;
      &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--args=--lockfile=false&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform_tflint&lt;/span&gt;
      &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--args=--config=__GIT_WORKING_DIR__/.tflint.hcl&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;terraform_trivy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The order is cheapest and most mechanical first, so that later hooks are reading&lt;br&gt;
code that is already well formed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;terraform_fmt&lt;/code&gt;&lt;/strong&gt; normalizes whitespace and alignment. Running it first
means no other hook, and no future diff, is ever about formatting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;terraform_validate&lt;/code&gt;&lt;/strong&gt; catches syntax and internal consistency errors.
&lt;code&gt;--retry-once-with-cleanup=true&lt;/code&gt; handles the common case where validation
fails only because of a stale &lt;code&gt;.terraform&lt;/code&gt; directory: it clears it and tries
once more instead of reporting a problem you don't have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;terraform_docs&lt;/code&gt;&lt;/strong&gt; regenerates the README, now that the code it is
documenting is known to be valid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;terraform_tflint&lt;/code&gt;&lt;/strong&gt; catches the things &lt;code&gt;validate&lt;/code&gt; structurally cannot -
deprecated syntax, unused declarations, provider-specific mistakes such as an
instance type that doesn't exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;terraform_trivy&lt;/code&gt;&lt;/strong&gt; scans for security misconfigurations. It goes last
because it is the slowest and the most likely to need a human judgment call
about whether a finding applies.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Alongside those, the standard hygiene hooks run on everything, and one of them&lt;br&gt;
earns a special mention:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;no-commit-to-branch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That blocks commits directly to &lt;code&gt;main&lt;/code&gt;. It's saved me from myself more than any&lt;br&gt;
of the Terraform-specific hooks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Directories You Don't Need Yet
&lt;/h2&gt;

&lt;p&gt;The template ships five directories that are empty apart from a &lt;code&gt;README.md&lt;/code&gt;:&lt;br&gt;
&lt;code&gt;examples/&lt;/code&gt;, &lt;code&gt;modules/&lt;/code&gt;, &lt;code&gt;tests/&lt;/code&gt;, &lt;code&gt;files/&lt;/code&gt;, and &lt;code&gt;templates/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The placeholder README exists because git won't track an empty directory, so&lt;br&gt;
without a file in it the directory would not survive a clone. But the reason to&lt;br&gt;
scaffold them at all is the same reason the empty &lt;code&gt;data.tf&lt;/code&gt; earns its place.&lt;br&gt;
When it's time to write the first test, there is no decision to make about where&lt;br&gt;
tests go. When someone needs a usage example, &lt;code&gt;examples/&lt;/code&gt; is already there with&lt;br&gt;
a note explaining what belongs in it.&lt;/p&gt;

&lt;p&gt;Scaffolding an empty directory costs nothing today. Adding one to a repo that&lt;br&gt;
has already grown around its absence means moving files and updating every path&lt;br&gt;
that pointed at them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;None of this is clever, and that's the point. It's a set of decisions made once&lt;br&gt;
so that they do not have to be made again while tired:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reserved file names, always present&lt;/strong&gt; - &lt;code&gt;data.tf&lt;/code&gt;, &lt;code&gt;locals.tf&lt;/code&gt;,
&lt;code&gt;providers.tf&lt;/code&gt;, &lt;code&gt;terraform.tf&lt;/code&gt;, &lt;code&gt;backend.tf&lt;/code&gt;, &lt;code&gt;variables.tf&lt;/code&gt;, &lt;code&gt;outputs.tf&lt;/code&gt;.
Empty is fine, and an empty file still tells you something.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only resources scale&lt;/strong&gt; - &lt;code&gt;main.tf&lt;/code&gt; splits by service past roughly twenty
resources. Nothing else splits, ever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Required before optional&lt;/strong&gt; in &lt;code&gt;variables.tf&lt;/code&gt;, alphabetized within each
section, with &lt;code&gt;terraform-docs&lt;/code&gt; configured to sort the same way so the
generated table cannot contradict the source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three files for three kinds of version decision&lt;/strong&gt; - the tool, its
dependencies, and where state lives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;~&amp;gt; Maj.Min&lt;/code&gt; on everything pinnable&lt;/strong&gt;, with the pre-1.0 caveat, and the lock
file committed as the record of what you actually resolved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate the README&lt;/strong&gt;, never hand-maintain it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fmt&lt;/code&gt;, &lt;code&gt;validate&lt;/code&gt;, &lt;code&gt;docs&lt;/code&gt;, &lt;code&gt;tflint&lt;/code&gt;, &lt;code&gt;trivy&lt;/code&gt;&lt;/strong&gt; in that order in pre-commit,
because the cheapest place to find any of these problems is before the commit,
and the most expensive is halfway through an apply.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole thing is in&lt;br&gt;
&lt;a href="https://github.com/404-code-not-found-com/terraform-module-template" rel="noopener noreferrer"&gt;terraform-module-template&lt;/a&gt;&lt;br&gt;
if you would rather clone it than assemble it.&lt;/p&gt;

&lt;p&gt;If you disagree with a specific convention here, that's fine and probably&lt;br&gt;
healthy. What matters far more than which layout you pick is that you pick one&lt;br&gt;
and it's the same in every repo. Decide once, and you stop spending attention on&lt;br&gt;
the question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Up Next: Terraform in Practice
&lt;/h2&gt;

&lt;p&gt;This kicks off a series on the practices that sit around the Terraform code&lt;br&gt;
rather than in it. Next up: getting static credentials out of your&lt;br&gt;
infrastructure entirely - a Vault SSH certificate authority instead of&lt;br&gt;
distributing key pairs, OIDC instead of long-lived AWS access keys, and dynamic&lt;br&gt;
database credentials instead of a shared application user. Three substitutions,&lt;br&gt;
and the gotcha that bites on each one.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>iac</category>
      <category>precommit</category>
      <category>terraformdocs</category>
    </item>
  </channel>
</rss>
