Posted on :: 2605 Words :: Tags: , ,

Core Terraform Workflow

You've learned HCL syntax, variables, and state management. Now let's talk about the actual workflow - the daily cycle you'll use every time you touch infrastructure.

This is Part 6 of 12 in our Terraform tutorial series. By the end, you'll know the commands you need, when to use them, and how to debug when things go sideways.

📦 Code Examples

Repository: terraform-hcl-tutorial-series This Part: Part 6 - Workflow Examples

Get the working example:

git clone https://github.com/khuongdo/terraform-hcl-tutorial-series.git
cd terraform-hcl-tutorial-series
git checkout part-06
cd examples/part-06-workflow/

# Practice the workflow
terraform init
terraform fmt
terraform validate
terraform plan

The Terraform Lifecycle

Every infrastructure change follows this pattern:

Write → Init → Format → Validate → Plan → Apply → Destroy
   ↑______________________________________________|
              (Repeat for changes)

Each step has a job. Skip one and you'll find out why it exists the hard way.

StepWhat It DoesWhat Breaks If You Skip It
WriteDefine what you wantNothing to deploy
InitDownload providers, set up backendCommands fail with "not initialized"
FormatClean up spacing and indentationMessy diffs, annoying code reviews
ValidateCheck syntax before runningErrors during plan/apply
PlanPreview changesSurprises, accidental deletions
ApplyActually create stuffNo infrastructure
DestroyTear it all downOrphaned resources, surprise bills

Let's walk through each one.


Step 1: Write Configuration

Start by writing what you want. Here's a basic EC2 instance setup:

# main.tf
terraform {
  required_version = ">= 1.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = var.instance_type

  tags = {
    Name        = "${var.project_name}-web-server"
    Environment = var.environment
  }
}
# variables.tf
variable "aws_region" {
  description = "AWS region for resources"
  type        = string
  default     = "us-east-1"
}

variable "ami_id" {
  description = "AMI ID for EC2 instance"
  type        = string
}

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.micro"
}

variable "project_name" {
  description = "Project name for resource tagging"
  type        = string
}

variable "environment" {
  description = "Environment (dev/staging/prod)"
  type        = string
}
# outputs.tf
output "instance_id" {
  description = "ID of the EC2 instance"
  value       = aws_instance.web.id
}

output "public_ip" {
  description = "Public IP address of the instance"
  value       = aws_instance.web.public_ip
}

Step 2: Initialize the Project

Command: terraform init

This is always the first command you run. It:

  1. Downloads provider plugins (AWS, GCP, whatever you're using)
  2. Sets up the backend (where state gets stored)
  3. Creates the .terraform directory
$ terraform init

Initializing the backend...

Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.31.0...
- Installed hashicorp/aws v5.31.0 (signed by HashiCorp)

Terraform has been successfully initialized!

When to run init again:

  • Added a new provider
  • Changed backend configuration
  • Fresh Git clone
  • Deleted .terraform/ directory

Checkpoint!

terraform init is safe to run multiple times. If you're not sure whether you need it, just run it.


Step 3: Format Your Code

Command: terraform fmt

This auto-formats your .tf files to Terraform's standard style:

$ terraform fmt
main.tf
variables.tf

What it fixes:

  • Indentation (2 spaces)
  • Spacing around =
  • Alignment
  • Trailing whitespace

Before formatting:

resource "aws_instance" "web"{
ami="ami-12345"
  instance_type =    "t3.micro"
    tags={
Name="web-server"
  }
}

After terraform fmt:

resource "aws_instance" "web" {
  ami           = "ami-12345"
  instance_type = "t3.micro"
  tags = {
    Name = "web-server"
  }
}

You can add this to a Git pre-commit hook so nobody pushes messy code:

#!/bin/bash
# .git/hooks/pre-commit
terraform fmt -check -recursive
if [ $? -ne 0 ]; then
  echo "Error: Terraform files not formatted. Run 'terraform fmt'"
  exit 1
fi

Step 4: Validate Configuration

Command: terraform validate

This checks for syntax errors and missing required fields without touching cloud APIs:

$ terraform validate
Success! The configuration is valid.

What it catches:

  • Bad HCL syntax
  • Missing required arguments
  • Invalid resource references
  • Type mismatches

Example validation failure:

resource "aws_instance" "web" {
  ami = var.ami_id
  # Oops, forgot instance_type
}
$ terraform validate

Error: Missing required argument

  on main.tf line 10, in resource "aws_instance" "web":
  10: resource "aws_instance" "web" {

The argument "instance_type" is required, but no definition was found.

Tip!

terraform validate runs entirely locally - it never makes API calls. Use it early and often.


Step 5: Preview Changes (Plan)

Command: terraform plan

This is the most important step. It shows exactly what will happen before you change anything:

$ terraform plan -var-file="dev.tfvars" -out=tfplan

Terraform used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami                          = "ami-0c55b159cbfafe1f0"
      + instance_type                = "t3.micro"
      + id                           = (known after apply)
      + public_ip                    = (known after apply)
      + tags                         = {
          + "Environment" = "dev"
          + "Name"        = "myapp-web-server"
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + instance_id = (known after apply)
  + public_ip   = (known after apply)

────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

Understanding plan symbols:

SymbolMeaningRisk Level
+Resource will be createdLow
~Resource will be modified in-placeMedium
-/+Resource will be destroyed and recreatedHigh
-Resource will be destroyedCritical

Save plans for safer applies:

# Generate plan file
terraform plan -out=tfplan

# Review the plan
terraform show tfplan

# Apply the exact plan
terraform apply tfplan

Why save plans?

Without -out, running terraform apply generates a new plan at apply time. If something changed between plan and apply (drift, another person's change), you might apply something different than what you reviewed.

Production Best Practice

Always use terraform plan -out=tfplan followed by terraform apply tfplan in production. Never run terraform apply without reviewing a saved plan first.


Step 6: Apply Changes

Command: terraform apply

This executes the plan and creates/modifies/destroys infrastructure:

# Interactive apply (asks for confirmation)
$ terraform apply -var-file="dev.tfvars"

# Auto-approve (use in CI/CD only)
$ terraform apply -var-file="dev.tfvars" -auto-approve

# Apply a saved plan (recommended)
$ terraform apply tfplan

Output:

aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Still creating... [20s elapsed]
aws_instance.web: Creation complete after 25s [id=i-0abcd1234efgh5678]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

instance_id = "i-0abcd1234efgh5678"
public_ip = "54.123.45.67"

What happens during apply:

  1. Terraform locks the state file (prevents other runs)
  2. Makes API calls to your cloud provider
  3. Updates terraform.tfstate with new resource IDs
  4. Unlocks state file
  5. Shows outputs

When apply fails mid-execution:

If it crashes (network error, API limit, timeout), the state file reflects what actually got created. Fix the issue and re-run terraform apply - Terraform picks up where it left off.


Step 7: Inspect Your Infrastructure

After applying, check what you created:

# List all resources in state
$ terraform state list
aws_instance.web

# Show detailed resource attributes
$ terraform state show aws_instance.web
# aws_instance.web:
resource "aws_instance" "web" {
    ami                          = "ami-0c55b159cbfafe1f0"
    instance_type                = "t3.micro"
    id                           = "i-0abcd1234efgh5678"
    public_ip                    = "54.123.45.67"
    tags                         = {
        "Environment" = "dev"
        "Name"        = "myapp-web-server"
    }
}

# View outputs again
$ terraform output
instance_id = "i-0abcd1234efgh5678"
public_ip = "54.123.45.67"

# Get specific output value (for scripts)
$ terraform output -raw public_ip
54.123.45.67

Step 8: Destroy Infrastructure

Command: terraform destroy

When you're done (dev environment, testing, etc.), tear it down:

$ terraform destroy -var-file="dev.tfvars"

Terraform will perform the following actions:

  # aws_instance.web will be destroyed
  - resource "aws_instance" "web" {
      - ami           = "ami-0c55b159cbfafe1f0" -> null
      - instance_type = "t3.micro" -> null
      - id            = "i-0abcd1234efgh5678" -> null
    }

Plan: 0 to add, 0 to change, 1 to destroy.

Do you really want to destroy all resources?
  Terraform will destroy all your managed infrastructure.
  There is no undo. Only 'yes' will be accepted to confirm.

  Enter a value: yes

aws_instance.web: Destroying... [id=i-0abcd1234efgh5678]
aws_instance.web: Destruction complete after 35s

Destroy complete! Resources: 1 destroyed.

Targeted destruction (specific resource):

# Destroy only the web instance
terraform destroy -target=aws_instance.web

Warning!

terraform destroy is irreversible. Always run terraform plan -destroy first to preview what gets deleted. In production, protect critical resources with lifecycle { prevent_destroy = true }.


Debugging Terraform

When things break, Terraform provides detailed logging:

Enable Debug Logging

# Set log level (TRACE, DEBUG, INFO, WARN, ERROR)
export TF_LOG=DEBUG

# Save logs to file
export TF_LOG_PATH=terraform-debug.log

# Run Terraform command
terraform apply

# Disable logging
unset TF_LOG TF_LOG_PATH

Log levels:

LevelUse Case
TRACEMaximum verbosity - shows every API call
DEBUGDetailed debugging info
INFOStandard operational messages
WARNWarning messages only
ERRORErrors only

Common Debugging Scenarios

Problem: "Error: Provider configuration not present"

# Solution: Run init
terraform init

Problem: "Error: Inconsistent dependency lock file"

# Solution: Regenerate lock file
terraform init -upgrade

Problem: "Error: Backend initialization required"

# Solution: Migrate backend
terraform init -migrate-state

Problem: "Error: Resource already exists"

# Solution: Import existing resource
terraform import aws_instance.web i-1234567890abcdef0

Importing Existing Infrastructure

Got manually created resources? Import them into Terraform:

Step 1: Write the resource configuration

# main.tf
resource "aws_instance" "legacy" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"

  tags = {
    Name = "legacy-server"
  }
}

Step 2: Import the resource

# Syntax: terraform import <resource_type>.<name> <cloud_resource_id>
$ terraform import aws_instance.legacy i-1234567890abcdef0

aws_instance.legacy: Importing from ID "i-1234567890abcdef0"...
aws_instance.legacy: Import prepared!
  Prepared aws_instance for import
aws_instance.legacy: Refreshing state... [id=i-1234567890abcdef0]

Import successful!

Step 3: Verify the import

$ terraform plan

No changes. Your infrastructure matches the configuration.

If the plan shows changes, update your configuration to match the actual resource.

Tip!

Use terraform show after import to see the full resource configuration, then copy it into your .tf files.


State Operations

Terraform state commands let you inspect and manipulate state without touching cloud resources:

List Resources

$ terraform state list
aws_instance.web
aws_security_group.web_sg
aws_vpc.main

Show Resource Details

$ terraform state show aws_instance.web
# aws_instance.web:
resource "aws_instance" "web" {
    ami           = "ami-0c55b159cbfafe1f0"
    id            = "i-0abcd1234efgh5678"
    instance_type = "t3.micro"
}

Move Resources (Refactoring)

When you rename resources in code, update state to match:

# Renamed in code: aws_instance.web → aws_instance.app_server
$ terraform state mv aws_instance.web aws_instance.app_server

Move "aws_instance.web" to "aws_instance.app_server"
Successfully moved 1 object(s).

Remove Resources from State

Remove a resource from Terraform management without destroying it:

$ terraform state rm aws_instance.web

Removed aws_instance.web
Successfully removed 1 resource instance(s).

The instance still exists in AWS, but Terraform no longer manages it.


Advanced CLI Operations

Refresh State

Sync Terraform state with actual cloud resources:

$ terraform refresh

When to use:

  • Someone made manual changes in cloud console
  • Checking for drift between state and reality

Note: Terraform automatically refreshes during plan and apply, so explicit refresh is rarely needed.

Replace Resources

Force replacement of a resource (destroy then recreate):

# Replace specific resource
$ terraform apply -replace=aws_instance.web

# Useful when:
# - Resource is corrupted
# - Need fresh instance ID
# - Testing disaster recovery

Targeted Apply

Apply changes to specific resources only:

# Only create/update the web instance
$ terraform apply -target=aws_instance.web

# Multiple targets
$ terraform apply -target=aws_instance.web -target=aws_security_group.web_sg

Warning!

Using -target can create inconsistent state. Avoid in production unless recovering from partial failures.


The Complete Workflow Script

Here's a production-ready workflow script:

#!/bin/bash
# deploy.sh - Safe Terraform deployment script

set -e  # Exit on error

ENVIRONMENT=${1:-dev}
VAR_FILE="${ENVIRONMENT}.tfvars"

echo "========================================="
echo "Deploying to: $ENVIRONMENT"
echo "========================================="

# Step 1: Initialize
echo "→ Initializing Terraform..."
terraform init

# Step 2: Format check
echo "→ Checking code formatting..."
terraform fmt -check -recursive
if [ $? -ne 0 ]; then
  echo "❌ Code not formatted. Run: terraform fmt -recursive"
  exit 1
fi

# Step 3: Validate
echo "→ Validating configuration..."
terraform validate

# Step 4: Generate plan
echo "→ Generating plan..."
terraform plan -var-file="$VAR_FILE" -out=tfplan

# Step 5: Show plan
echo "→ Plan generated. Review changes above."
read -p "Apply this plan? (yes/no): " CONFIRM

if [ "$CONFIRM" != "yes" ]; then
  echo "❌ Deployment cancelled."
  rm tfplan
  exit 0
fi

# Step 6: Apply plan
echo "→ Applying changes..."
terraform apply tfplan

# Cleanup
rm tfplan

echo "✅ Deployment complete!"
terraform output

Usage:

chmod +x deploy.sh
./deploy.sh dev      # Deploy to dev
./deploy.sh staging  # Deploy to staging
./deploy.sh prod     # Deploy to production

Checkpoint Questions

Test your understanding:

  1. What does terraform init do? Why must you run it before plan or apply?

  2. Difference between terraform plan and terraform apply? Can you apply without planning?

  3. What happens if you run terraform apply twice with no code changes?

  4. How do you destroy only one specific resource without tearing down everything?

  5. Why save plans with -out=tfplan? What problem does it solve?

  6. When would you use terraform import? Can you import resources that Terraform created?

  7. What's the difference between terraform state rm and terraform destroy?

Click to reveal answers
  1. terraform init downloads provider plugins and initializes the backend. Required before any Terraform operations.

  2. plan previews changes without making them; apply executes changes. You can apply without explicit plan, but it's risky (plan happens inline).

  3. Nothing changes. Terraform detects infrastructure matches desired state and performs no operations.

  4. terraform destroy -target=resource_type.name

  5. Saved plans ensure the changes you reviewed are exactly what gets applied (no drift between plan time and apply time).

  6. Use import to bring manually-created resources under Terraform management. Yes, you can import Terraform-created resources if you lost state.

  7. state rm removes from Terraform tracking but leaves resource intact. destroy deletes the actual cloud resource.


What's Next: Part 7 - Modules for Organization

You've mastered the Terraform workflow, but what happens when your codebase grows to hundreds of resources? Copying the same VPC configuration across 10 projects? Managing dependencies between related resources?

In Part 7, we'll solve these problems with Terraform modules - reusable components that:

  • Eliminate code duplication
  • Enforce organizational standards
  • Enable team collaboration
  • Simplify complex infrastructure

You'll learn to:

  • Create custom modules from scratch
  • Use public modules from the Terraform Registry
  • Pass variables and outputs between modules
  • Version and publish internal modules
  • Structure large Terraform projects

Preview snippet (Part 7):

# Instead of repeating VPC code...
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.1.0"

  name = "${var.project}-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["us-east-1a", "us-east-1b"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24"]

  enable_nat_gateway = true
  enable_vpn_gateway = false
}

# Reference module outputs
resource "aws_instance" "web" {
  subnet_id = module.vpc.public_subnets[0]
}

See you in Part 7.


Series Navigation

  • Part 1: Why Infrastructure as Code?
  • Part 2: Setting Up Terraform (Coming soon)
  • Part 3: Your First Cloud Resource (Coming soon)
  • Part 4: HCL Fundamentals (Coming soon)
  • Part 5: Variables, Outputs & State (Coming soon)
  • Part 6: Core Terraform Workflow (You are here)
  • Part 7: Modules for Organization (Coming soon)
  • Part 8: Multi-Cloud Patterns (Coming soon)
  • Part 9: State Management & Team Workflows (Coming soon)
  • Part 10: Testing & Validation (Coming soon)
  • Part 11: Security & Secrets Management (Coming soon)
  • Part 12: Production Patterns & DevSecOps (Coming soon)

References


Series navigation:


Questions or feedback? Drop a comment below or connect on LinkedIn.