Terraform: Eliminating phantom diffs using ignore_changes and replace_triggered_by
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
ignore_changes = [password]eliminates the phantom diff on every plan — Terraform stops comparing the write-only password field against state.terraform_data.nuget_api_key_versionstores the current value of the secret in state via itsinputattribute. It only reports a change whenvar.nuget_api_keyactually changes.replace_triggered_bywatches the trigger resource. When the secret rotates andterraform_datadetects 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_authorizationtied 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 = trueon 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_onlyattribute 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_bymay 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.


