Skip to main content

Command Palette

Search for a command to run...

Terraform: Eliminating phantom diffs using ignore_changes and replace_triggered_by

Updated
6 min read

The Problem: Write-Only Fields and Perpetual Diffs

A particularly common source of phantom diffs is Azure DevOps service endpoint resources. Passwords and API keys are write-only — the API accepts them on create/update but never returns them on read. Every terraform plan sees a gap in state and reports a diff.

resource "azuredevops_serviceendpoint_nuget" "this" {
  project_id            = var.project_id
  service_endpoint_name = "nuget-feed"
  url                   = "https://artifacts.corp.example.com/repository/nuget-hosted/"
  username              = "deployer"
  password              = var.nuget_api_key
}

Every terraform plan, without fail:

# azuredevops_serviceendpoint_nuget.this will be updated in-place
~ resource "azuredevops_serviceendpoint_nuget" "this" {
      id                    = "..."
    ~ password              = (sensitive value)
      # (4 unchanged attributes hidden)
  }

The Naive Fix (and Its Blind Spot)

The obvious answer is ignore_changes:

resource "azuredevops_serviceendpoint_nuget" "this" {
  # ...
  password = var.nuget_api_key

  lifecycle {
    ignore_changes = [password]
  }
}

This kills the phantom diff, but creates a new problem: when you actually rotate the API key, Terraform silently ignores the change. You'd have to remember to manually run:

terraform apply -replace="azuredevops_serviceendpoint_nuget.this"

That's fragile, easy to forget, and doesn't scale across dozens of service connections.

The Better Fix: ignore_changes + replace_triggered_by

Combine both lifecycle features to get the best of both worlds — no phantom diffs during normal operation, but automatic recreation when the secret actually changes.

# A trigger resource that tracks the secret value.
# Only changes when the actual secret value changes.
resource "terraform_data" "nuget_api_key_version" {
  input = var.nuget_api_key
}

resource "azuredevops_serviceendpoint_nuget" "this" {
  project_id            = var.project_id
  service_endpoint_name = "nuget-feed"
  url                   = "https://artifacts.corp.example.com/repository/nuget-hosted/"
  username              = "deployer"
  password              = var.nuget_api_key

  lifecycle {
    ignore_changes = [password]
    replace_triggered_by = [
      terraform_data.nuget_api_key_version
    ]
  }
}

How This Works

  1. ignore_changes = [password] eliminates the phantom diff on every plan — Terraform stops comparing the write-only password field against state.

  2. terraform_data.nuget_api_key_version stores the current value of the secret in state via its input attribute. It only reports a change when var.nuget_api_key actually changes.

  3. replace_triggered_by watches the trigger resource. When the secret rotates and terraform_data detects a real change, it forces a full replacement of the service endpoint — ensuring the new credential is applied.

Behavior Matrix

Scenario Behavior
Normal plan, no changes Clean plan, zero diffs
Secret rotated in var.nuget_api_key terraform_data changes → service endpoint is replaced with new password
Other attributes changed (URL, name) Normal in-place update, no interference
terraform plan run repeatedly Consistently clean — no phantom diff

Scaling the Pattern

This pattern works for any write-only field: Docker registry passwords, Maven deploy tokens, PyPI API keys, generic service connection secrets, etc.

# --- Trigger resources (one per secret) ---

resource "terraform_data" "docker_password_version" {
  input = var.docker_password
}

resource "terraform_data" "maven_password_version" {
  input = var.maven_deploy_token
}

resource "terraform_data" "pypi_password_version" {
  input = var.pypi_api_token
}

resource "terraform_data" "npm_password_version" {
  input = var.npm_auth_token
}

# --- Service connections ---

resource "azuredevops_serviceendpoint_dockerregistry" "this" {
  project_id            = var.project_id
  service_endpoint_name = "docker-registry"
  docker_registry       = "https://registry.corp.example.com"
  docker_username       = "deployer"
  docker_password       = var.docker_password
  registry_type         = "Others"

  lifecycle {
    ignore_changes       = [docker_password]
    replace_triggered_by = [terraform_data.docker_password_version]
  }
}

resource "azuredevops_serviceendpoint_maven" "this" {
  project_id            = var.project_id
  service_endpoint_name = "maven-repo"
  url                   = "https://artifacts.corp.example.com/repository/maven-releases/"
  username              = "deployer"
  password              = var.maven_deploy_token

  lifecycle {
    ignore_changes       = [password]
    replace_triggered_by = [terraform_data.maven_password_version]
  }
}

resource "azuredevops_serviceendpoint_npm" "this" {
  project_id            = var.project_id
  service_endpoint_name = "npm-registry"
  url                   = "https://artifacts.corp.example.com/repository/npm-hosted/"
  access_token          = var.npm_auth_token

  lifecycle {
    ignore_changes       = [access_token]
    replace_triggered_by = [terraform_data.npm_password_version]
  }
}

resource "azuredevops_serviceendpoint_generic" "pypi" {
  project_id            = var.project_id
  service_endpoint_name = "pypi-repo"
  server_url            = "https://artifacts.corp.example.com/repository/pypi-hosted/"
  password              = var.pypi_api_token

  lifecycle {
    ignore_changes       = [password]
    replace_triggered_by = [terraform_data.pypi_password_version]
  }
}

Modularizing the Pattern

If you manage many service connections through a module, you can encapsulate the pattern:

# modules/service_connection_with_secret/main.tf

variable "project_id" {}
variable "endpoint_name" {}
variable "server_url" {}
variable "username" { default = "" }
variable "password" { sensitive = true }

resource "terraform_data" "password_version" {
  input = var.password
}

resource "azuredevops_serviceendpoint_generic" "this" {
  project_id            = var.project_id
  service_endpoint_name = var.endpoint_name
  server_url            = var.server_url
  username              = var.username
  password              = var.password

  lifecycle {
    ignore_changes       = [password]
    replace_triggered_by = [terraform_data.password_version]
  }
}

output "endpoint_id" {
  value = azuredevops_serviceendpoint_generic.this.id
}

Usage:

module "pypi_connection" {
  source        = "./modules/service_connection_with_secret"
  project_id    = azuredevops_project.this.id
  endpoint_name = "pypi-repo"
  server_url    = "https://artifacts.corp.example.com/repository/pypi-hosted/"
  password      = var.pypi_api_token
}

Important Caveats

Replacement is Destructive

replace_triggered_by forces a destroy + create, not an in-place update. For service connections this is usually fine, but be aware that:

  • The service endpoint ID changes on replacement
  • Pipelines referencing the endpoint by ID (not name) may briefly fail during apply
  • Any azuredevops_resource_authorization tied to the old endpoint ID needs to be recreated

If zero-downtime is critical, add create_before_destroy:

  lifecycle {
    ignore_changes         = [password]
    replace_triggered_by   = [terraform_data.nuget_api_key_version]
    create_before_destroy  = true
  }

Sensitive Values in terraform_data

The terraform_data resource stores its input in state. If your state backend isn't encrypted, the secret will be visible in the state file. Ensure you:

  • Use an encrypted state backend (Azure Blob with encryption, S3 with SSE, etc.)
  • Restrict state file access with proper IAM/RBAC policies
  • Consider using sensitive = true on the variable to suppress console output

When NOT to Use This Pattern

  • The provider correctly handles the field. Some providers do read secrets back from the API — no phantom diff means no need for this pattern.
  • Terraform v1.11+ write-only attributes. If your provider supports the new write_only attribute modifier, use that instead — it's the native solution to this problem.
  • You want in-place updates, not replacements. If the resource is expensive to recreate (e.g., databases with data), the replacement semantics of replace_triggered_by may be too aggressive.

Summary

Approach Phantom Diff? Detects Secret Rotation? Automation
No lifecycle rules Yes (every plan) Yes Automatic
ignore_changes only No No Manual -replace needed
ignore_changes + replace_triggered_by No Yes Fully automatic

The ignore_changes + replace_triggered_by pattern gives you clean plans and automatic secret rotation handling. It's the practical middle ground until write-only attributes become widespread across providers.