<?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: Jordan Saunders</title>
    <description>The latest articles on DEV Community by Jordan Saunders (@jsaunders).</description>
    <link>https://dev.to/jsaunders</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F630303%2F11ad7b3e-9806-46f1-a795-6cf1bbb06e06.jpeg</url>
      <title>DEV Community: Jordan Saunders</title>
      <link>https://dev.to/jsaunders</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jsaunders"/>
    <language>en</language>
    <item>
      <title>How We Stopped Infrastructure Drift Between Environments — One Module, One Pipeline, No Exceptions</title>
      <dc:creator>Jordan Saunders</dc:creator>
      <pubDate>Thu, 16 Apr 2026 16:18:12 +0000</pubDate>
      <link>https://dev.to/jsaunders/how-we-stopped-infrastructure-drift-between-environments-one-module-one-pipeline-no-exceptions-of2</link>
      <guid>https://dev.to/jsaunders/how-we-stopped-infrastructure-drift-between-environments-one-module-one-pipeline-no-exceptions-of2</guid>
      <description>&lt;p&gt;Every new client engagement starts the same way.&lt;/p&gt;

&lt;p&gt;I open the infrastructure repo and find three separate sets of Terraform config — one for dev, one for staging, one for prod. Sometimes they started as copies of each other. By the time I show up, they've diverged completely. Nobody can tell me when or why.&lt;/p&gt;

&lt;p&gt;After 14 years of seeing this pattern, I stopped trying to fix it with discipline. Discipline doesn't scale. The fix is removing the conditions that allow drift in the first place.&lt;/p&gt;

&lt;p&gt;Here's the pattern I landed on.&lt;/p&gt;




&lt;h2&gt;
  
  
  The core idea: one module, one workspace convention
&lt;/h2&gt;

&lt;p&gt;A Terraform workspace is just a named state file. Same configuration, three environments, zero separate codebases to keep in sync.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Environment-specific differences — instance sizes, replica counts, feature flags — live in &lt;code&gt;.tfvars&lt;/code&gt; files, not scattered through the module itself. The only legitimate differences between environments are the ones I explicitly write down.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# environments/prod.tfvars&lt;/span&gt;
&lt;span class="nx"&gt;instance_type&lt;/span&gt;    &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"t3.medium"&lt;/span&gt;
&lt;span class="nx"&gt;db_replica_count&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="nx"&gt;min_capacity&lt;/span&gt;     &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;

&lt;span class="c1"&gt;# environments/dev.tfvars&lt;/span&gt;
&lt;span class="nx"&gt;instance_type&lt;/span&gt;    &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"t3.micro"&lt;/span&gt;
&lt;span class="nx"&gt;db_replica_count&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="nx"&gt;min_capacity&lt;/span&gt;     &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The pipeline enforces promotion order
&lt;/h2&gt;

&lt;p&gt;Changes flow in sequence: dev → staging → prod. If something breaks in dev, it never reaches prod.&lt;/p&gt;

&lt;p&gt;Nobody on my team runs &lt;code&gt;terraform apply&lt;/code&gt; locally. Everything goes through GitLab CI/CD with a manual gate before every apply.&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;# .gitlab-ci.yml (simplified)&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="s"&gt;plan&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;apply-dev&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;apply-staging&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;apply-prod&lt;/span&gt;

&lt;span class="na"&gt;apply-prod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apply-prod&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;terraform workspace select prod&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;terraform apply -var-file=environments/prod.tfvars -auto-approve&lt;/span&gt;
  &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;manual&lt;/span&gt;
  &lt;span class="na"&gt;needs&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;apply-staging"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;when: manual&lt;/code&gt; + &lt;code&gt;needs&lt;/code&gt; combination means prod can only be triggered after staging succeeds, and only by a human explicitly approving it.&lt;/p&gt;




&lt;h2&gt;
  
  
  A few things that made this actually work
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tag every resource with the workspace name.&lt;/strong&gt; Costs me nothing. Saves hours when debugging cost anomalies or access issues later.&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;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;common_tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Environment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;terraform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workspace&lt;/span&gt;
    &lt;span class="nx"&gt;ManagedBy&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform"&lt;/span&gt;
    &lt;span class="nx"&gt;Team&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;team_name&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Keep &lt;code&gt;.tfvars&lt;/code&gt; files in the repo, reviewed like code.&lt;/strong&gt; Environment differences are visible, diffable, and part of the change history. No more mystery configs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;State backend per workspace, not per environment folder.&lt;/strong&gt; One S3 bucket, workspace-namespaced keys. Clean, simple, auditable.&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;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"your-terraform-state"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&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;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;When a change is approved in dev, I know exactly what's going to happen in prod. Not approximately — exactly. The only question is whether the variable values are right, and those are in the PR diff.&lt;/p&gt;




&lt;p&gt;Full writeup on the NextLink blog: &lt;a href="https://nextlinklabs.com/resources/insights/using-terraform-workspaces-to-keep-infrastructure-consistent-across-environments" rel="noopener noreferrer"&gt;https://nextlinklabs.com/resources/insights/using-terraform-workspaces-to-keep-infrastructure-consistent-across-environments&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you want more of this — practical DevOps, IaC patterns, AI-assisted engineering — I publish it monthly in the NextLink newsletter: &lt;a href="https://nextlinklabs.com/newsletter" rel="noopener noreferrer"&gt;https://nextlinklabs.com/newsletter&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer questions in the comments — always curious what patterns others are using for environment consistency.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>devops</category>
      <category>infrastructure</category>
      <category>gitlab</category>
    </item>
  </channel>
</rss>
