I created a script to batch delete unused HCP Terraform Workspaces
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.
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.
#!/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
To verify that workspaces managing resources are not targeted for deletion, I created resources in delete-test-3
.
Let's start with a dry run.
./delete_empty_workspaces.sh
=== 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
./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.
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.