Sitemap

Preventing Terraform Apply Failures with Conftest: Catching deletion_protection Oversights at Plan Time

6 min readJul 31, 2025

Introduction

Have you ever created a PR to delete Terraform resources, forgotten to set deletion_protection = false, had the Plan pass without issues, only to have Apply fail with this frustrating error?

Error: cannot destroy service without setting deletion_protection=false and running `terraform apply`

“Why didn’t terraform plan tell me this?!” This article shows how to catch these issues early using conftest for proactive detection.

The Problem

When deleting resources with Terraform, the following issues commonly occur:

  1. Create a deletion PR while forgetting to set deletion_protection = false (when default is true)
  2. terraform plan shows no errors
  3. Merge the PR and run terraform apply
  4. Apply fails for the first time, causing deployment failure

This particularly happens with GCP resources where deletion_protection is enabled by default, such as Cloud Run, Cloud SQL, and GKE clusters.

Solution: Proactive Checking with conftest

What is conftest?

conftest is a tool that executes policy-based tests against structured data. It uses the Rego language to write policies and can validate various data formats including JSON, YAML, and HCL.

For Terraform, conftest can be used for:

  • Policy checks on Terraform code itself
  • Policy checks on Plan results (our use case)

Why Check Plan Results?

This problem cannot be detected by examining Terraform code alone because:

  • Resources with explicitly set deletion_protection = false can be deleted
  • The issue occurs when the setting is missing (defaulting to true)

Therefore, we need to check the Plan result diffs to verify the deletion_protection state of resources being deleted.

Implementing conftest Policies

Utility File (policy/util.rego)

package terraform.utils

# Utility functions for Terraform plan analysis
# Check if a resource is being deleted
is_delete(resource) if {
resource.change.actions[_] == "delete"
}
# Check if a resource is being created
is_create(resource) if {
resource.change.actions[_] == "create"
}
# Check if a resource is being updated
is_update(resource) if {
resource.change.actions[_] == "update"
}
# Check if a resource is being replaced
is_replace(resource) if {
resource.change.actions == ["delete", "create"]
}
# Get the resource type from address
resource_type(resource) := type if {
parts := split(resource.address, ".")
type := parts[0]
}
# Get the resource name from address
resource_name(resource) := name if {
parts := split(resource.address, ".")
name := parts[1]
}
# Get before values safely
get_before(resource, field) := value if {
value := resource.change.before[field]
}
# Get after values safely
get_after(resource, field) := value if {
value := resource.change.after[field]
}
# Check if field is changing
is_field_changing(resource, field) if {
get_before(resource, field) != get_after(resource, field)
}
# Get all resources of a specific type
resources_of_type(resources, type) := filtered if {
filtered := [resource |
resource := resources[_]
resource_type(resource) == type
]
}
# Get resources that are being deleted
resources_being_deleted(resources) := filtered if {
filtered := [resource |
resource := resources[_]
is_delete(resource)
]
}

Policy File (policy/deletion_protection.rego)

package terraform.gcp.deletion_protection

import rego.v1
import data.terraform.utils
# Resource types that support deletion_protection
deletion_protection_resources := [
"google_active_directory_domain",
"google_bigquery_table",
"google_bigtable_authorized_view",
"google_bigtable_instance",
"google_bigtable_logical_view",
"google_bigtable_materialized_view",
"google_bigtable_table",
"google_cloud_run_v2_job",
"google_cloud_run_v2_service",
"google_cloud_run_v2_worker_pool",
"google_compute_instance",
"google_compute_storage_pool",
"google_container_cluster",
"google_dataproc_metastore_federation",
"google_dataproc_metastore_service",
"google_folder",
"google_oracle_database_autonomous_database",
"google_oracle_database_cloud_exadata_infrastructure",
"google_oracle_database_cloud_vm_cluster",
"google_oracle_database_odb_network",
"google_oracle_database_odb_subnet",
"google_privateca_certificate_authority",
"google_secret_manager_regional_secret",
"google_secret_manager_secret",
"google_spanner_database",
"google_sql_database_instance",
"google_workflows_workflow"
]
# Deny deletion of resources with deletion_protection = true
deny contains msg if {
# Check all resource changes
resource := input.resource_changes[_]
# Check if it's a deletion protection resource type
resource.type == deletion_protection_resources[_]
# Check if it's being deleted
utils.is_delete(resource)
# Check if deletion_protection = true
deletion_protection := utils.get_before(resource, "deletion_protection")
deletion_protection == true
msg := sprintf(
"%s '%s' cannot be deleted because deletion_protection is enabled. Set deletion_protection = false before deletion.",
[resource.type, resource.address]
)
}
# Warn about resources without deletion protection
warn contains msg if {
# Check all resource changes
resource := input.resource_changes[_]
# Check if it's a deletion protection resource type
resource.type == deletion_protection_resources[_]
# Check if it's being created
utils.is_create(resource)
# Check if deletion_protection is not set or false
not resource.change.after.deletion_protection
msg := sprintf(
"%s '%s' is being created without deletion_protection. Consider enabling it for production resources.",
[resource.type, resource.address]
)
}

Test File (policy/deletion_protection_test.rego)

package terraform.gcp.deletion_protection

import rego.v1
# Test: Cloud Run service with deletion protection enabled being deleted (should deny)
test_cloud_run_deletion_protection_deny if {
deny[_] with input as {
"resource_changes": [{
"address": "google_cloud_run_v2_service.test_service",
"type": "google_cloud_run_v2_service",
"change": {
"actions": ["delete"],
"before": {"deletion_protection": true}
}
}]
}
}
# Test: Resource with deletion protection disabled being deleted (should allow)
test_deletion_protection_allow if {
count(deny) == 0 with input as {
"resource_changes": [{
"address": "google_cloud_run_v2_service.test_service",
"type": "google_cloud_run_v2_service",
"change": {
"actions": ["delete"],
"before": {"deletion_protection": false}
}
}]
}
}
# Test: Creating Cloud Run service without deletion protection (should warn)
test_cloud_run_warn_no_protection if {
warn[_] with input as {
"resource_changes": [{
"address": "google_cloud_run_v2_service.test_service",
"type": "google_cloud_run_v2_service",
"change": {
"actions": ["create"],
"after": {"deletion_protection": false}
}
}]
}
}
# Test: Creating resource with deletion protection enabled (should not warn)
test_with_protection_no_warn if {
count(warn) == 0 with input as {
"resource_changes": [{
"address": "google_cloud_run_v2_service.test_service",
"type": "google_cloud_run_v2_service",
"change": {
"actions": ["create"],
"after": {"deletion_protection": true}
}
}]
}
}

Configuration File (conftest.toml)

policy = ["policy"]
namespace = "terraform.gcp.deletion_protection"

Execution Steps

1. Execute Terraform Plan and Generate JSON Output

# Output plan in binary format
terraform plan -no-color -out=tfplan.binary

# Convert to JSON format
terraform show -json tfplan.binary > tfplan.json

2. Policy Check with conftest

# Execute policy check
conftest test tfplan.json

Success output:

0 tests, 0 passed, 0 warnings, 0 failures, 0 exceptions

Failure output:

FAIL - tfplan.json - terraform.gcp.deletion_protection - google_cloud_run_v2_service 'google_cloud_run_v2_service.example' cannot be deleted because deletion_protection is enabled. Set deletion_protection = false before deletion.
2 tests, 1 passed, 0 warnings, 1 failure, 0 exceptions

GitHub Actions Automation

Automatically run checks on PRs with the following workflow:

# .github/workflows/conftest.yaml
name: conftest

on:
pull_request:
paths:
- '**/*.tf'
- '**/*.tfvars'
jobs:
policy-check:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/<project number>/locations/global/workloadIdentityPools/<pool>/providers/<provider>
service_account: <your sa>@<your project>.iam.gserviceaccount.com

- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.12.2

- name: Setup aqua
uses: aquaproj/aqua-installer@v4.0.2
with:
aqua_version: v2.53.7
- name: Install tools with aqua
run: |
aqua install

- name: Terraform Init
run: terraform init

- name: Terraform Plan
run: |
terraform plan -no-color -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json

- name: Run Conftest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
github-comment exec -k conftest -- conftest test --no-color -c conftest.toml tfplan.json
  • Using github-comment makes it easy to leave conftest results as comments on PRs
  • We use aqua for CLI tools like conftest and github-comment

aqua configuration file:

# aqua.yaml
---
# aqua - Declarative CLI Version Manager
# https://aquaproj.github.io/
registries:
- type: standard
ref: v4.396.0 # renovate: depName=aquaproj/aqua-registry
packages:
- name: suzuki-shunsuke/tfcmt@v4.14.9
- name: open-policy-agent/conftest@v0.62.0
- name: suzuki-shunsuke/github-comment@v6.3.4

github-comment configuration file:

# github-comment.yaml
---
# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/github-comment/main/json-schema/github-comment.json
skip_no_token: false
vars:
templates:
hide:
conftest: 'Comment.HasMeta && Comment.Meta.TemplateKey == "conftest" && Comment.Meta.SHA1 != Commit.SHA1'
exec:
conftest:
- when: ExitCode != 0
template: |
## Conftest Results

❌ Policy Violations Found
```
{{.CombinedOutput | AvoidHTMLEscape}}
```

Real-World Usage Examples

Problematic Code Example

resource "google_cloud_run_v2_service" "example" {
name = "example-service"
location = "us-central1"

# deletion_protection = false ← Forgot this (Defaults to true.)

template {
containers {
image = "gcr.io/cloudrun/hello"
}
}
}

When trying to delete this resource:

# Comment out or delete the resource
# resource "google_cloud_run_v2_service" "example" { ... }
terraform plan  # Succeeds
conftest test --no-color -c conftest.toml tfplan.json # Fails!

Correct Code Example

resource "google_cloud_run_v2_service" "example" {
name = "example-service"
location = "us-central1"

deletion_protection = false # Explicitly set this

template {
containers {
image = "gcr.io/cloudrun/hello"
}
}
}

Important Note: To delete resources, you must first complete an apply with deletion_protection = false. The deletion process requires two separate steps:

  1. First Apply: Set deletion_protection = false and apply
  2. Second Apply: Remove the resource definition and apply

Summary

Using conftest allows you to prevent unexpected failures during Terraform Apply by catching issues early.

Benefits

  • Early Detection: Catch issues at the Plan stage
  • Automation: Can be integrated into CI/CD pipelines
  • Extensibility: Applicable to other resource types
  • Team Sharing: Manage and share policies as code

Extension Examples

  • Extend to AWS or Azure resources
  • Check other Terraform best practices

If you’ve been struggling with forgotten deletion_protection settings, give this approach a try!

References

--

--

Masato Naka
Masato Naka

Written by Masato Naka

An SRE, mainly working on Kubernetes. CKA (Feb 2021). His Interests include Cloud-Native application development, and machine learning.

No responses yet