I created a script to batch delete unused HCP Terraform Workspaces

I created a script to batch delete unused HCP Terraform Workspaces

2025.08.25

I often create temporary verification environments using HCP Terraform.

HCP Terraform has a feature called ephemeral workspaces that removes resources that have not been used for a certain period of time.

https://dev.classmethod.jp/articles/tfc-ephemeral-workspaces/

While it does delete resources, the Workspaces themselves remain.

For HCP Terraform, the number of Workspaces itself doesn't affect billing, so it's not problematic.

However, having many unused Workspaces bothers me.

This time, I created a script to bulk delete unused Workspaces (with 0 resources) within a Project.## Created Script

Here is the script I created.

It uses the HCP Terraform API.

The script retrieves and displays Workspaces with 0 resources within the specified Project.

When run with the --force option, it will additionally delete the Workspaces that have 0 resources.

delete_empty_workspaces.sh
			
			#!/bin/bash

# Script to bulk delete workspaces with 0 resources in a specific HCP Terraform project

set -euo pipefail

# Configuration (environment variables or default values)
ORGANIZATION="${HCPTF_ORGANIZATION:-default-org}"
PROJECT="${HCPTF_PROJECT:-default-project}"
API_BASE_URL="https://app.terraform.io/api/v2"

# Color settings
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

# API token check
if [[ -z "${TFE_TOKEN:-}" ]]; then
  echo -e "${RED}Error: TFE_TOKEN environment variable is not set${NC}"
  echo "Please set your HCP Terraform API token:"
  echo "export TFE_TOKEN=your-api-token"
  exit 1
fi

# Argument check
DRY_RUN=true
if [[ "${1:-}" == "--force" ]]; then
  DRY_RUN=false
  echo -e "${YELLOW}Warning: --force option specified. Deletion will be executed.${NC}"
elif [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "-h" ]]; then
  echo "Usage: $0 [--force]"
  echo ""
  echo "Options:"
  echo "  --force    Execute actual deletion (default is dry-run)"
  echo "  --help     Display this help"
  echo ""
  echo "Environment variables:"
  echo "  TFE_TOKEN            HCP Terraform API token (required)"
  echo "  HCPTF_ORGANIZATION   Organization name (default: default-org)"
  echo "  HCPTF_PROJECT        Project name (default: default-project)"
  exit 0
fi

if [[ "$DRY_RUN" == true ]]; then
  echo -e "${BLUE}=== DRY RUN MODE ===${NC}"
  echo "Checking workspaces to delete (no actual deletion)"
  echo "Use the --force option to perform actual deletion"
  echo ""
fi

# API call function
api_call() {
  local endpoint="$1"
  local method="${2:-GET}"
  local data="${3:-}"

  local curl_args=(
    -s
    -H "Authorization: Bearer ${TFE_TOKEN}"
    -H "Content-Type: application/vnd.api+json"
    -X "$method"
  )

  if [[ -n "$data" ]]; then
    curl_args+=(-d "$data")
  fi

  curl "${curl_args[@]}" "${API_BASE_URL}${endpoint}"
}

# Get project ID (search with q parameter)
echo -e "${BLUE}Retrieving project information...${NC}"
projects_response=$(api_call "/organizations/${ORGANIZATION}/projects?q=${PROJECT}")
project_id=$(echo "$projects_response" | jq -r --arg name "$PROJECT" '.data[] | select(.attributes.name == $name) | .id')

if [[ -z "$project_id" ]] || [[ "$project_id" == "null" ]]; then
  echo -e "${RED}Error: Project '${PROJECT}' not found${NC}"
  echo "Search results:"
  echo "$projects_response" | jq -r '.data[] | "  - \(.attributes.name) (ID: \(.id))"'
  echo ""
  echo "Debug information:"
  echo "Number of projects retrieved: $(echo "$projects_response" | jq '.data | length')"
  echo "Target project name: '${PROJECT}'"
  exit 1
fi

echo -e "${GREEN}Project ID: ${project_id}${NC}"

# Get workspace list (filtered by project, with pagination support)
echo -e "${BLUE}Retrieving workspace list...${NC}"
get_all_workspaces() {
  local page=1
  local all_data="[]"

  while true; do
    # URL encoding: [=%5B, ]=%5D
    # filter[project][id] → filter%5Bproject%5D%5Bid%5D
    # page[number] → page%5Bnumber%5D, page[size] → page%5Bsize%5D
    local response=$(api_call "/organizations/${ORGANIZATION}/workspaces?filter%5Bproject%5D%5Bid%5D=${project_id}&page%5Bnumber%5D=${page}&page%5Bsize%5D=100")

    # API error check
    if ! echo "$response" | jq . > /dev/null 2>&1; then
      echo -e "${RED}API call error (page $page): $response${NC}" >&2
      break
    fi

    local current_data=$(echo "$response" | jq '.data')
    local data_count=$(echo "$current_data" | jq 'length')

    if [[ "$data_count" -eq 0 ]]; then
      break
    fi

    # Merge data
    all_data=$(echo "$all_data" | jq ". + $current_data")

    # Check if there's a next page
    local has_next=$(echo "$response" | jq -r '.meta.pagination."next-page" // empty')
    if [[ -z "$has_next" ]] || [[ "$has_next" == "null" ]]; then
      break
    fi

    ((page++))
  done

  # Create final response format
  echo "{\"data\": $all_data}"
}

workspaces_response=$(get_all_workspaces)

# Filter workspaces with 0 resources
empty_workspaces=$(echo "$workspaces_response" | jq -r '
  .data[]
  | select(.attributes["resource-count"] == 0)
  | "\(.id)|\(.attributes.name)|\(.attributes["resource-count"])"
')

if [[ -z "$empty_workspaces" ]]; then
  echo -e "${GREEN}No workspaces with 0 resources were found${NC}"
  exit 0
fi

echo -e "${YELLOW}Workspaces to delete:${NC}"
echo "$empty_workspaces" | while IFS='|' read -r ws_id ws_name resource_count; do
  echo "  - ${ws_name} (ID: ${ws_id}, Resource count: ${resource_count})"
done

workspace_count=$(echo "$empty_workspaces" | wc -l)
echo -e "${YELLOW}Total ${workspace_count} workspaces targeted for deletion${NC}"

if [[ "$DRY_RUN" == true ]]; then
  echo ""
  echo -e "${BLUE}=== DRY RUN COMPLETE ===${NC}"
  echo "To perform actual deletion, run with the --force option:"
  echo "$0 --force"
  exit 0
fi

# Confirmation for actual deletion
echo ""
echo -e "${RED}Warning: Deleted workspaces cannot be restored${NC}"
read -p "Are you sure you want to delete? [y/N]: " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
  echo "Canceled"
  exit 0
fi

# Execute workspace deletion
echo -e "${BLUE}Starting workspace deletion...${NC}"
deleted_count=0
failed_count=0

# Use process substitution to avoid subshell issues
while IFS='|' read -r ws_id ws_name resource_count; do
  echo -n "Deleting: ${ws_name} ... "

  # Try safe delete
  delete_response=$(api_call "/workspaces/${ws_id}/actions/safe-delete" "POST" "" 2>&1)
  delete_status=$?

  if [[ $delete_status -eq 0 ]]; then
    echo -e "${GREEN}Success${NC}"
    ((deleted_count++))
  else
    echo -e "${RED}Failed${NC}"
    echo "  Error: $delete_response"
    ((failed_count++))
  fi
done <<< "$empty_workspaces"

echo ""
echo -e "${BLUE}=== Deletion Complete ===${NC}"
echo -e "${GREEN}Success: ${deleted_count} workspaces${NC}"
if [[ $failed_count -gt 0 ]]; then
  echo -e "${RED}Failed: ${failed_count} workspaces${NC}"
fi
```## Usage

### Prerequisites

The following tools need to be installed:

- curl
- jq

### Preparation

Grant execution permission to the script.

```bash
chmod +x delete_empty_workspaces.sh

		

Set the respective environment variables.

			
			export HCPTF_ORGANIZATION="your-organization"
export HCPTF_PROJECT="your-project"
export TFE_TOKEN="your-api-token"

		

For TFE_TOKEN, set a token that has permission to delete workspaces.

If you're using a Mac, tokens are stored by default in ~/.terraform.d/credentials.tfrc.json.

			
			cat ~/.terraform.d/credentials.tfrc.json

		

Besides passing HCPTF_ORGANIZATION and HCPTF_PROJECT as environment variables, you can also set default values in the script.

To set default values, modify the default-* parts:

			
			# Configuration (environment variables or default values)
ORGANIZATION="${HCPTF_ORGANIZATION:-default-org}"
PROJECT="${HCPTF_PROJECT:-default-project}"

		

Dry Run and Execution

Without options, the script performs a dry run without actually deleting anything.

			
			./delete_empty_workspaces.sh

		

Adding the --force option will perform the actual deletion.

			
			./delete_empty_workspaces.sh --force
```## Verification

Let's actually delete empty Workspaces using the script.

The verification Workspace was created as follows.

```hcl: main.tf
terraform {
  required_providers {
    tfe = {
      source  = "hashicorp/tfe"
      version = "0.68.2"
    }
  }
}

provider "tfe" {}

locals {
  organization = "hoge" # modify to match your environment
}

# Project for workspace deletion testing
resource "tfe_project" "delete_test" {
  organization = local.organization
  name         = "sato-masaki-ws-delete-test"
  description  = "Project for testing workspace bulk deletion"
}

# Workspaces for deletion testing
resource "tfe_workspace" "test_1" {
  name         = "delete-test-1"
  organization = local.organization
  project_id   = tfe_project.delete_test.id
  description  = "Test workspace 1 for bulk deletion"
}

resource "tfe_workspace" "test_2" {
  name         = "delete-test-2"
  organization = local.organization
  project_id   = tfe_project.delete_test.id
  description  = "Test workspace 2 for bulk deletion"
}

resource "tfe_workspace" "test_3" {
  name         = "delete-test-3"
  organization = local.organization
  project_id   = tfe_project.delete_test.id
  description  = "Test workspace 3 for bulk deletion"
}

		

Let's create the test resources.

			
			terraform init
terraform apply

		

sato-masaki-ws-delete-test___classmethod-sandbox___HCP_Terraform.png

To verify that workspaces managing resources are not targeted for deletion, I created resources in delete-test-3.

Cursor_と_Overview___classmethod-sandbox___HCP_Terraform.png

Let's start with a dry run.

			
			./delete_empty_workspaces.sh

		
Output example
			
			=== DRY RUN mode ===
Checking workspaces to be deleted (no actual deletion will occur)
To perform actual deletion, use the --force option

Retrieving project information...
Project ID: prj-XXXXX
Retrieving workspace list...
Workspaces to be deleted:
  - delete-test-1 (ID: ws-XXXXX, Resource count: 0)
  - delete-test-2 (ID: ws-XXXXX, Resource count: 0)
Total of 2 workspaces targeted for deletion

=== DRY RUN completed ===
To perform actual deletion, run with the --force option:
./delete_empty_workspaces.sh --force
```I confirmed that only Workspaces with 0 resources are targeted for deletion.

I will execute the deletion.

```bash
./delete_empty_workspaces.sh --force

		
Output example
			
			./delete_empty_workspaces.sh --force

Warning: --force option specified. Actual deletion will be executed.
Getting project information...
Project ID: prj-XXXXX
Getting workspace list...
Workspaces to be deleted:
  - delete-test-1 (ID: ws-XXXXXX, Resource count: 0)
  - delete-test-2 (ID: ws-XXXXXX, Resource count: 0)
Total: 2 workspaces targeted for deletion

Warning: Deleted workspaces cannot be restored
Do you really want to delete? [y/N]: y
Starting workspace deletion...
Deleting: delete-test-1 ...success
Deleting: delete-test-2 ... success

=== Deletion Complete ===
Success: 2

		

The deletion has been completed.

I also confirmed from the HCP Terraform console that only workspaces with 0 resources were deleted.

Cursor_and_sato-masaki-ws-delete-test___classmethod-sandbox___HCP_Terraform.png

Conclusion

This was about a batch deletion script for unused Workspaces.

Since I often create Workspaces for testing, organizing them with a script has made things easier.

For now, I implemented it with a shell script.

I would like to try implementing it using go-tfe, the HCP Terraform SDK maintained by HashiCorp.

Share this article

FacebookHatena blogX

Related articles