DEV Community

Ipadeola Taiwo
Ipadeola Taiwo

Posted on

Building and automating my portfolio website on Azure with Terraform and GitHub Actions

I wanted a portfolio site that actually shows what I can do as a cloud engineer, not just tells people. So instead of clicking through the Azure Portal and calling it done, I built the whole thing with Terraform, put a real CDN in front of it, attached a custom domain with HTTPS, then automated the entire deployment with GitHub Actions.

Before I get into it, I want to be upfront about something. Nobody handed me this code. Every resource type, every argument, every fix in this post came from reading the Terraform registry documentation, reading Microsoft Learn, and searching Google whenever something broke. I would hit an error, read it slowly, search for the exact wording, read whatever official doc came up, try something, and if it did not work I would go back and read again. That process is the entire reason I understand what every line in this project actually does. This post is me walking you through that same process, mistakes included, so if you want to build something similar you can follow the same path instead of just copying a finished repo.

GitHub repo: https://github.com/highpee1991/azure-portfolio-platform
Live site: https://taiwoipadeola.space

What I was building

A static portfolio (HTML, CSS, JS, nothing fancy) that lives on Azure, sits behind a CDN so it loads fast anywhere in the world, answers on my own domain with a real HTTPS certificate, and updates itself the moment I push a change to GitHub. No manual uploads, no clicking around the portal after the first setup.

The real architecture, from my own Azure portal

Rather than draw a made up diagram, here is what is actually sitting in my subscription right now, resource by resource, taken straight from the portal.

Two resource groups exist for this project. static-web-production-rg holds everything that actually serves the live site. portfolio-tfstate-rg holds nothing except the storage account that Terraform uses to keep its own state file. Keeping these separate matters. If I ever destroy the production resource group to rebuild it, my Terraform state itself stays completely safe in the other one.

Inside static-web-production-rg, there is a storage account called productioncontainerweb. Inside that storage account is a container named $web, which is the special container name Azure uses for static website hosting. Right now that container holds six items: 404.html, README.md, index.html, script.js, style.css, and an assets folder. Every single one of those was uploaded by GitHub Actions, not by me clicking upload in the portal.

Inside portfolio-tfstate-rg sits a second storage account, portfoliotfstatestorage, with a container called portfolio-tfstate-container. That container holds exactly one file, production.tfstate, which is the entire memory of what Terraform has built for me. If that file gets corrupted or lost, Terraform effectively forgets everything it created and thinks it needs to build it all again, so keeping it in its own isolated backend was a deliberate choice from day one.

Also sitting in static-web-production-rg is portfolio-frontdoor-profile, my Azure Front Door instance. Its overview page shows an endpoint hostname that Azure generated for me automatically, something like portfolio-frontdoor-endpoint-xxxxxxxx.z01.azurefd.net, and under Custom domains it lists both taiwoipadeola.space and www.taiwoipadeola.space, each showing Provision succeeded and Validation approved. Under Routes there is a single route called portfolio-route pointing back at that endpoint.

Here is the traffic flow as an actual diagram.

                        Visitor's Browser
                                |
                             HTTPS request
                                |
                         Azure Front Door
                  portfolio-frontdoor-profile
              (2 custom domains, managed TLS certs,
                    caching, compression)
                                |
                         forwarded to origin
                                |
                Azure Storage Static Website
              productioncontainerweb / $web container
                 index.html, style.css, script.js,
                       404.html, assets/
Enter fullscreen mode Exit fullscreen mode

And the state backend, which is not in that traffic path at all, sits completely off to the side.

        portfolio-tfstate-rg
                |
        portfoliotfstatestorage
                |
        portfolio-tfstate-container
                |
          production.tfstate
     (Terraform's memory of everything above)
Enter fullscreen mode Exit fullscreen mode

Repo structure

azure-portfolio-platform/
  .github/workflows/
    terraform.yml
    deploy.yml
  infrastructure/
    environments/production/
      backend.tf
      providers.tf
      main.tf
      variables.tf
      outputs.tf
      terraform.tfvars
    modules/
      storage/
        main.tf
        variables.tf
        outputs.tf
      front-door/
        main.tf
        variables.tf
        outputs.tf
      custom-domain/
        main.tf
        variables.tf
        outputs.tf
  portfolio/
    index.html
    style.css
    script.js
    404.html
    README.md
    assets/
Enter fullscreen mode Exit fullscreen mode

I am sharing most of the code below, but not every single file. A few small variable declaration files and minor output wiring are left out on purpose, mainly because they are repetitive and do not add anything new to the explanation. The full thing is in the repo linked above if you want to see literally everything.

Layer one, the storage module

This is where everything started. A resource group, a storage account, and static website hosting turned on.

resource "azurerm_storage_account" "storage_account" {
  name                = var.storage_account_name
  resource_group_name = var.resource_group_name
  location            = var.resource_group_location

  account_tier             = "Standard"
  account_replication_type = "GRS"
}

resource "azurerm_storage_account_static_website" "static-web" {
  storage_account_id = azurerm_storage_account.storage_account.id
  error_404_document  = "404.html"
  index_document      = "index.html"
}
Enter fullscreen mode Exit fullscreen mode

GRS means geo redundant storage, so my files get replicated to a second Azure region automatically. azurerm_storage_account_static_website is the resource that actually flips the switch turning a plain storage account into something that can serve HTML pages over the web, and it is where I tell Azure which file to treat as the homepage and which one to serve when a page is not found.

The outputs from this module are small but important. They expose the resource group name, the storage account name, and primary_web_host, which is the actual hostname Azure gives the static website. That last one gets consumed later by the Front Door module, since Front Door needs to know where to actually send traffic.

output "primary_web_host" {
  value = azurerm_storage_account.storage_account.primary_web_host
}
Enter fullscreen mode Exit fullscreen mode

The part I later removed, and exactly how I removed it

In the beginning, this storage module also had a block that uploaded my actual portfolio files as part of terraform apply. It looked like this.

locals {
  portfolio_files = fileset("${path.module}/../../../portfolio", "**")
}

resource "azurerm_storage_blob" "portfolio" {
  for_each = local.portfolio_files

  name                 = each.value
  storage_container_id = "${azurerm_storage_account.storage_account.id}/blobServices/default/containers/$web"

  type        = "Block"
  source      = "${path.module}/../../../portfolio/${each.value}"
  content_md5 = filemd5("${path.module}/../../../portfolio/${each.value}")
}
Enter fullscreen mode Exit fullscreen mode

fileset scans my local portfolio folder and returns the name of every file inside it. The for_each on the blob resource then loops over that list and creates one blob resource per file. content_md5 calculates a fingerprint of each file's actual bytes, and Terraform stores that fingerprint in state. Every time I ran terraform plan, Terraform would recalculate the fingerprint of my local files and compare it against what it remembered, and if they did not match it would want to replace the file.

This worked, but it caused a real problem once I built GitHub Actions. Terraform running in CI would checkout my repository fresh every time, calculate fresh fingerprints, and constantly disagree with what my local machine had calculated the last time I ran apply from my laptop. I will walk through the exact bug that caused later in this post, but the short version is that this whole setup meant Terraform and GitHub Actions were fighting over who actually owned the content of my portfolio files, and that fight needed to end with one clear owner.

I decided Terraform should stop owning file content entirely. Terraform's job is infrastructure. A CDN caring what my CSS file's bytes look like felt like the wrong tool for that job. So I removed it, step by step, carefully, because doing this wrong would have deleted my live site.

Step one, delete the code. I removed the entire locals block and the entire azurerm_storage_blob resource from main.tf in the storage module. Nothing else in that file changed.

Step two, check for references. Before running anything, I opened outputs.tf and variables.tf in that same module folder and checked whether anything referenced azurerm_storage_blob.portfolio anywhere. If something had, terraform validate would fail immediately with an error about an undefined resource, so this was a quick sanity check before moving forward.

Step three, and this is the part that actually matters, clean up state without touching the real files. Here is the thing I had to understand clearly before doing anything else. Deleting a resource block from a .tf file only changes what my code says. Terraform's state file, sitting in that separate backend I described earlier, still remembered all seven of those blob resources as things it was responsible for. If I had just run terraform apply at that point, Terraform would have looked at my config, seen those resources were no longer defined, and concluded I wanted them deleted. It would have gone ahead and deleted my actual live website files.

The fix is a command called terraform state rm. This tells Terraform to forget about a resource without touching the real infrastructure at all. I ran it once for every file, from inside infrastructure/environments/production.

terraform state rm 'module.storage_account.azurerm_storage_blob.portfolio["index.html"]'
terraform state rm 'module.storage_account.azurerm_storage_blob.portfolio["style.css"]'
terraform state rm 'module.storage_account.azurerm_storage_blob.portfolio["script.js"]'
terraform state rm 'module.storage_account.azurerm_storage_blob.portfolio["404.html"]'
terraform state rm 'module.storage_account.azurerm_storage_blob.portfolio["README.md"]'
terraform state rm 'module.storage_account.azurerm_storage_blob.portfolio["assets/taiwo-photo.jpg"]'
terraform state rm 'module.storage_account.azurerm_storage_blob.portfolio["assets/IPADEOLA_OLUWASEUN_TAIWO.pdf"]'
Enter fullscreen mode Exit fullscreen mode

The path inside the quotes matches exactly how the resource was addressed in state, module name, resource type, resource name, and the specific key from the for_each loop.

Step four, confirm nothing destructive is queued up. After all seven removals, I ran terraform plan again. It came back clean, no changes queued for anything, meaning Terraform now genuinely had no memory of those blobs at all and would not try to touch them on the next apply. The actual files sitting in the $web container were never touched through any of this. They stayed exactly where they were the entire time, because state manipulation only changes what Terraform remembers, never what is actually deployed.

From that point forward, the storage module only manages the storage account and the static website configuration. Nothing else.

The deploy workflow that took over file content

With Terraform stepping back, something needed to take over the job of actually getting files into that container. That became deploy.yml, a completely separate GitHub Actions workflow with nothing to do with Terraform at all.

name: Deploy Portfolio 

on: 
    push:
        branches:
            - main
        paths:
            - "portfolio/**"

jobs:
    deploy:
        runs-on: ubuntu-latest

        steps:
            - name: checkout repository
              uses: actions/checkout@v4

            - name: Azure Login
              uses: azure/login@v3
              with:
                creds: |
                    {
                        "clientId": "${{ secrets.AZURE_CLIENT_ID }}",
                        "clientSecret": "${{ secrets.AZURE_CLIENT_SECRET }}",
                        "subscriptionId": "${{ secrets.AZURE_SUBSCRIPTION_ID }}",
                        "tenantId": "${{ secrets.AZURE_TENANT_ID }}"
                    }
            - name: Sync portfolio files to Blob Storage
              run: |
                az storage blob sync \
                --account-name productioncontainerweb \
                --container '$web' \
                --source portfolio \
                --delete-destination true \

            - name: Purge Front Door cache
              run: |
                az config set extension.use_dynamic_install=yes_without_prompt
                az config set extension.dynamic_install_allow_preview=true
                az afd endpoint purge \
                --resource-group static-web-production-rg \
                --profile-name portfolio-frontdoor-profile \
                --endpoint-name portfolio-frontdoor-endpoint \
                --content-paths "/*"
Enter fullscreen mode Exit fullscreen mode

The paths filter under on.push is the piece doing the real decoupling work. This workflow only fires when at least one changed file lives inside the portfolio folder. Touch a Terraform file, this workflow stays completely silent, and my other workflow handles it instead.

az storage blob sync is not the first command I reached for. My first version used az storage blob upload-batch, which just pushes files up one way with no concept of deletion. When I tried adding a --delete-destination flag to that command, it failed outright with unrecognized arguments, because that flag genuinely does not exist on upload-batch at all. Reading the actual Azure CLI reference for storage blob commands is what led me to sync, which is a completely different command built specifically for this comparison and mirroring behavior, closer in spirit to how rsync works on Linux. It checks each file's content against what is already sitting in the container, uploads anything new or changed, leaves anything unchanged completely alone, and with --delete-destination true it also removes any blob whose source file no longer exists locally. That last part means my container is always an exact mirror of my portfolio folder, which is exactly what I wanted once I understood the tradeoff, meaning an accidental local deletion becomes a real deletion in Azure too, with git history as my only real backup for that scenario.

The purge step exists because of a caching problem I only discovered by accident. The first time I deployed a CSS change through this workflow, the live site did not update at all, even though the raw storage URL showed the new file correctly. Front Door caches responses at the edge, so even after storage has the new file, visitors can keep getting served an old cached copy for a while. az afd endpoint purge tells Front Door to throw away its cached copies immediately instead of waiting for them to expire naturally on their own schedule.

That purge command introduced its own separate problem the first time I ran it in the pipeline. It just hung, no error, no progress, for over four minutes before I gave up and cancelled it. Reading the log carefully showed why. az afd lives inside a CLI extension called cdn, and that extension has no stable release, only a preview version. Azure CLI normally asks a confirmation question before installing any new extension automatically, and there is nobody sitting at a keyboard in a GitHub Actions runner to answer that question, so it just sat there waiting on input that would never come.

Fixing it needed two separate config lines, not one, because preview only extensions have an extra gate on top of the normal one.

az config set extension.use_dynamic_install=yes_without_prompt
az config set extension.dynamic_install_allow_preview=true
Enter fullscreen mode Exit fullscreen mode

The first line tells the CLI to install any missing extension automatically without asking. The second line specifically allows that automatic install to include preview-only extensions, which cdn is. Once both were set before the purge command ran, the extension installed silently and the purge finished in seconds.

The infrastructure pipeline

Separate from all of that, terraform.yml handles anything under infrastructure.

name: Terraform CI

on: 
    push:
        branches:
            - main

    pull_request:
        branches:
            - main
permissions:
    id-token: write

env:
    TF_VERSION: "1.9.0"
    WORKING_DIR: "infrastructure/environments/production"

jobs:
    terraform:
        runs-on: ubuntu-latest

        env:
            ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
            ARM_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
            ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
            ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}

        steps:
            - name: checkout repository
              uses: actions/checkout@v4

            - name: Setup Terraform
              uses: hashicorp/setup-terraform@v3
              with:
               terraform_version: ${{ env.TF_VERSION }}

            - name: Azure Login
              uses: azure/login@v3
              with:
                creds: |
                 {
                    "clientId": "${{ secrets.AZURE_CLIENT_ID }}",
                    "clientSecret": "${{ secrets.AZURE_CLIENT_SECRET }}",
                    "subscriptionId": "${{ secrets.AZURE_SUBSCRIPTION_ID }}",
                    "tenantId": "${{ secrets.AZURE_TENANT_ID }}"
                 } 

            - name: Terraform Init
              run: terraform init
              working-directory: ${{ env.WORKING_DIR }}

            - name: Terraform Format
              run: terraform fmt -check -recursive
              working-directory: ${{ env.WORKING_DIR }}

            - name: Terraform Validate
              run: terraform validate
              working-directory: ${{ env.WORKING_DIR }}

            - name: Terraform Plan
              run: terraform plan -input=false -out=tfplan
              working-directory: ${{ env.WORKING_DIR }} 

            - name: Upload Plan
              uses: actions/upload-artifact@v4
              with:
                name: tfplan
                path: ${{ env.WORKING_DIR }}

            - name: Terraform Apply
              working-directory: ${{ env.WORKING_DIR }}
              if : github.event_name == 'push' && github.ref == 'refs/heads/main'
              run: terraform apply -input=false -auto-approve tfplan
Enter fullscreen mode Exit fullscreen mode

That if condition on the last step is doing the important safety work. github.event_name == 'push' is only true on a direct push, never on a pull request. github.ref == 'refs/heads/main' is only true when the target branch is main. Both have to be true together, so opening a pull request against any branch only ever gets as far as plan, and shows the apply step as skipped rather than run. Nothing about infrastructure changes until code is actually merged.

Getting the auth working here was its own separate fight. My very first attempt logged into Azure using the azure/login action and then immediately tried terraform init, which failed with an error saying authentication using the Azure CLI is only supported as a user, not a service principal. I did not understand this at first. azure/login had clearly succeeded, so why was Terraform complaining about authentication at all.

What I eventually understood is that azure/login authenticates the runner's Azure CLI session. The azurerm Terraform provider does not read from that session at all, it does its own completely separate authentication, and logging in as a service principal through the CLI specifically is not a combination that provider knows how to pick up automatically. The fix was giving Terraform its own explicit credentials as environment variables at the job level, which you can see at the top of the job block above. Once ARM_CLIENT_ID and the others were set there, every step in the job, not just one, had what it needed, and Terraform authenticated completely independently of whatever the CLI login had done.

The custom domain and front door modules

Once infrastructure and content were fully separated, the rest of the project was about getting a real domain and HTTPS in front of everything.

resource "azurerm_cdn_frontdoor_profile" "fdprofile" {
  name                = var.frontdoor_profile_name
  resource_group_name = var.resource_group_name
  sku_name            = var.sku_name
}

resource "azurerm_cdn_frontdoor_endpoint" "fdendpoint" {
  name                     = var.frontdoor_endpoint_name
  cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.fdprofile.id
}

resource "azurerm_cdn_frontdoor_origin_group" "fdoriginggroup" {
  name                     = var.frontdoor_origin_group_name
  cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.fdprofile.id
  session_affinity_enabled = false

  load_balancing {
    sample_size                 = 4
    successful_samples_required = 3
  }

  health_probe {
    interval_in_seconds = 240
    path                = "/"
    protocol            = "Https"
    request_type        = "HEAD"
  }
}

resource "azurerm_cdn_frontdoor_origin" "fdorigin" {
  name                          = var.frontdoor_origin_name
  cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.fdoriginggroup.id
  enabled                       = true

  certificate_name_check_enabled = true

  host_name          = var.host_name
  http_port          = 80
  https_port         = 443
  origin_host_header = var.origin_host_header
}

resource "azurerm_cdn_frontdoor_route" "route" {
  name                          = var.route_name
  cdn_frontdoor_endpoint_id     = azurerm_cdn_frontdoor_endpoint.fdendpoint.id
  cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.fdoriginggroup.id
  cdn_frontdoor_origin_ids      = [azurerm_cdn_frontdoor_origin.fdorigin.id]

  cdn_frontdoor_custom_domain_ids = [var.cdn_frontdoor_custom_domain_ids, var.www_cdn_frontdoor_custom_damain_ids]

  patterns_to_match   = ["/*"]
  supported_protocols = ["Http", "Https"]

  cache {
    query_string_caching_behavior = "IgnoreQueryString"
    compression_enabled           = true
    content_types_to_compress     = ["text/html", "text/javascript", "text/xml", "text/css"]
  }
}
Enter fullscreen mode Exit fullscreen mode

A profile is the top level Front Door resource. An endpoint is the actual address traffic arrives at. An origin group and origin describe where Front Door should actually forward that traffic to, which in my case is primary_web_host from the storage module, passed in as host_name and origin_host_header. A route ties all of it together and is also where the custom domains and caching rules attach.

The custom domain module sits right alongside it.

resource "azurerm_cdn_frontdoor_custom_domain" "custom_domain" {
  name                     = var.frontdoor_custom_domain
  cdn_frontdoor_profile_id = var.cdn_frontdoor_profile_id
  host_name                = var.custom_domain_host_name

  tls {
    certificate_type = "ManagedCertificate"
  }
}

resource "azurerm_cdn_frontdoor_custom_domain" "www_custom_domain" {
  name                     = "${var.frontdoor_custom_domain}-www"
  cdn_frontdoor_profile_id = var.cdn_frontdoor_profile_id
  host_name                = var.www_custom_domain_host_name

  tls {
    certificate_type = "ManagedCertificate"
  }
}
Enter fullscreen mode Exit fullscreen mode

certificate_type = "ManagedCertificate" is what tells Azure to issue and renew the TLS certificate automatically, rather than me needing to bring my own. Each custom domain resource also produces a validation_token output, and that token is the piece I had to manually copy into a TXT record at my domain registrar to prove ownership before Azure would actually issue the certificate.

My domain is registered at Namecheap, not Azure, and this mattered a lot. I briefly considered creating an Azure DNS zone in Terraform, then realized that only actually does anything if you delegate your domain's nameservers to Azure. Since mine stayed at Namecheap, an Azure DNS zone would have been a completely disconnected setup that the real internet never consults when resolving my domain. So I made a decision early on, keep DNS management entirely at Namecheap, and only use Terraform for the Front Door side of things, manually copying whatever validation tokens and hostnames it outputs into Namecheap's Advanced DNS panel by hand.

Wiring it all together at the root

module "storage_account" {
  source = "../../modules/storage"

  resource_group_name     = azurerm_resource_group.prod-rg.name
  resource_group_location = azurerm_resource_group.prod-rg.location
  storage_account_name    = var.storage_account_name
}

module "frontdoor" {
  source = "../../modules/front-door"

  resource_group_name                 = azurerm_resource_group.prod-rg.name
  sku_name                            = var.sku_name
  frontdoor_profile_name              = var.frontdoor_profile_name
  frontdoor_endpoint_name             = var.frontdoor_endpoint_name
  frontdoor_origin_group_name         = var.frontdoor_origin_group_name
  frontdoor_origin_name               = var.frontdoor_origin_name
  host_name                           = module.storage_account.primary_web_host
  origin_host_header                  = module.storage_account.primary_web_host
  route_name                          = var.route_name
  cdn_frontdoor_custom_domain_ids     = module.custom-domain.cdn_frontdoor_custom_domain_ids
  www_cdn_frontdoor_custom_damain_ids = module.custom-domain.www_cdn_frontdoor_custom_domain_ids
}

module "custom-domain" {
  source = "../../modules/custom-domain"

  frontdoor_custom_domain     = var.frontdoor_custom_domain
  cdn_frontdoor_profile_id    = module.frontdoor.cdn_frontdoor_profile_id
  custom_domain_host_name     = var.custom_domain_host_name
  www_custom_domain_host_name = var.www_custom_domain_host_name
}
Enter fullscreen mode Exit fullscreen mode

This is where you can actually see the dependency chain. The storage module produces primary_web_host, which feeds into the front door module as the origin. The front door module produces cdn_frontdoor_profile_id, which the custom domain module needs to attach domains to the right profile. The custom domain module then produces domain IDs that feed back into the front door module's route. Terraform figures out the correct order to create all of this on its own just by tracing these references, I never had to tell it what order to do things in.

State itself lives in its own backend, completely separate from any of this.

terraform {
  backend "azurerm" {
    resource_group_name  = "portfolio-tfstate-rg"
    storage_account_name = "portfoliotfstatestorage"
    container_name       = "portfolio-tfstate-container"
    key                  = "production.tfstate"
  }
}
Enter fullscreen mode Exit fullscreen mode

This is the exact backend shown in the storage account screenshots earlier, portfoliotfstatestorage, holding one file, production.tfstate, inside portfolio-tfstate-container. Every terraform init, whether run from my laptop or from a GitHub Actions runner, points at this exact same backend, which is the entire reason local runs and CI runs were ever able to see the same state and conflict with each other in the first place, and also the entire reason the fix for that conflict was possible without me manually copying files around.

The bug that genuinely confused me for hours

Once both pipelines were technically working, I noticed something strange in the infrastructure pipeline specifically. Every single run reported that five of my portfolio files needed to be destroyed and recreated. Not changed, destroyed and recreated. Locally, on my own machine, running the exact same plan showed no changes at all. Same code, same backend, same state file, two completely different answers depending on where I ran it.

I chased the wrong theory first. I added a .gitattributes file specifying LF line endings and ran git add --renormalize ., expecting that to fix things. It found nothing to fix, because my repository's actual Git index already stored everything as LF. That theory was wrong, but ruling it out cleanly is what pointed me toward looking one layer deeper.

The real answer was a Windows specific Git setting called core.autocrlf, which silently converts files back to CRLF line endings every time they get checked out locally, even though the repository itself stores LF. So my local disk had CRLF versions of my files sitting there this whole time, while GitHub's Linux runners checked out the real LF versions straight from the repository, since Linux never does that conversion at all.

This mattered because Terraform reads whatever bytes are physically sitting on disk when it calculates a file's content hash. My local machine had been hashing CRLF bytes the entire time, and that CRLF based hash is what got saved into state the very first time I ran apply from my laptop. GitHub's runner was hashing the real LF bytes every single run, which never matched what state remembered, hence the constant plan wanting to replace them.

git config core.autocrlf false
git rm -r --cached .
git reset --hard HEAD
Enter fullscreen mode Exit fullscreen mode

The first line stops Windows from doing that conversion for this repository going forward. The second and third lines force a completely fresh checkout of every file straight from what the repository actually stores, which is LF, overwriting whatever CRLF versions were sitting on my disk before. After running these, my local terraform plan finally showed the exact same five replacements CI had been showing the whole time, meaning local and CI were finally looking at the same bytes and agreeing with each other, they just both disagreed with the old CRLF era state. I ran terraform apply once locally to resync state to the correct LF based hashes, and from that point on both environments matched cleanly.

This whole investigation is also part of why I eventually decided Terraform should stop tracking blob content at all. Even after fixing the line ending issue, having two systems both capable of writing to the same files felt fragile. Removing Terraform's ownership of content entirely, the way I described earlier in this post, removed this entire category of bug permanently rather than just patching this one instance of it.

What I would tell someone starting this themselves

Read the actual error message slowly before searching for it. In almost every bug in this post, the real cause was sitting right there in the log, just not in the first line I looked at. The CRLF issue only made sense once I stopped assuming it was a Git problem and started asking what Terraform was actually reading off disk at the moment it calculated a hash.

Run things locally specifically to compare against what CI is doing. The moment I ran terraform plan on my own machine and got a different answer than CI gave me, that difference became the actual clue that cracked the whole line ending mystery open. If both had shown the same thing, I would have had no way to know where to even start looking.

And genuinely, read the official docs before searching for someone else's blog post about your exact error. The Terraform registry page for azurerm_cdn_frontdoor_route told me exactly what cdn_frontdoor_custom_domain_ids expected as a type. Microsoft Learn's Azure CLI reference told me exactly what flags az storage blob sync actually supports versus what upload-batch supports. Almost every fix in this post came from one of those two places, not from a tutorial.

If you build something similar, or hit your own version of any of these bugs, I would genuinely like to hear about it.

Repo: https://github.com/highpee1991/azure-portfolio-platform
Live site: https://taiwoipadeola.space

Top comments (0)