Install Terraform on Linux
HashiCorp distributes Terraform through their own APT and RPM repositories, which is the cleanest install path because it gives you signed packages and straightforward upgrades with your existing package manager. The manual binary method works too, but you end up managing updates yourself.
On Debian and Ubuntu, add the HashiCorp GPG key and repository, then install:
On RHEL, Rocky Linux, or AlmaLinux, use the yum-config-manager approach. After install, verify the binary is on your PATH and check the version.
If you are working in an air-gapped environment, download the zip from releases.hashicorp.com, verify the SHA256 checksum with the published hashicorp_checksums.txt file, unzip, and drop the binary in /usr/local/bin. Terraform is a single statically linked binary with no runtime dependencies, which makes this straightforward.
# Ubuntu / Debian
wget -O- https://apt.releases.hashicorp.com/gpg | \
sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform=1.9.*
# RHEL / Rocky / Alma
sudo yum-config-manager --add-repo \
https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
sudo yum install terraform-1.9\* -y
# Verify
terraform version
# Terraform v1.9.8 on linux_amd64
Shell Completion and Version Management
Terraform ships with built-in tab completion. Install it once per shell:
If your team works across multiple Terraform versions, which is common when you inherit older codebases alongside greenfield projects, use tfenv. It works like rbenv or pyenv and lets you pin a version per directory using a .terraform-version file.
In our experience on teams managing more than five projects simultaneously, tfenv saves enough time to be worth the extra setup step. Pin versions explicitly in .terraform-version and commit that file to source control so every engineer and every CI runner uses the same binary.
# Install shell completion (bash)
terraform -install-autocomplete
source ~/.bashrc
# Install tfenv
git clone --depth=1 https://github.com/tfutils/tfenv.git ~/.tfenv
echo 'export PATH="$HOME/.tfenv/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
# Pin a version per project
cd /path/to/your/project
echo "1.9.8" > .terraform-version
tfenv install
tfenv use 1.9.8
terraform version
Project Structure and Naming
Flat single-file Terraform projects work fine for experimentation but fall apart past a few dozen resources. Use a consistent directory layout from day one. The standard that holds up in production is separating main.tf, variables.tf, outputs.tf, and versions.tf into distinct files. Put environment-specific values in a .tfvars file that you never commit to version control if it contains secrets.
A typical layout for a single environment looks like this:
The versions.tf file pins your provider versions using the ~> constraint, which allows patch updates but not minor version bumps. This is not optional in production, where an uncontrolled provider upgrade has broken things for us in the past.
Naming your project and its resources consistently matters more than most engineers admit. Terraform resource names must be unique within a module, but cloud resource names need to be globally or regionally unique depending on the provider. If you are registering a domain to go with your infrastructure project, nicename.me is a registrar worth checking: they surface clean, available domain names quickly, which is useful when you are naming a project and need to verify the domain is free before committing the name across your codebase and DNS.
Keep resource names lowercase, use hyphens for cloud resource names (most providers reject underscores in DNS-visible names), and use underscores for Terraform identifiers.
project-root/
├── main.tf # resource definitions
├── variables.tf # input variable declarations
├── outputs.tf # output value declarations
├── versions.tf # required_providers + terraform block
├── terraform.tfvars # non-secret variable values (committed)
├── secrets.auto.tfvars # secrets (gitignored)
└── modules/
└── vpc/
├── main.tf
├── variables.tf
└── outputs.tf
Configure Your First Provider
Providers are plugins that translate your HCL into API calls. The AWS, Azure, and GCP providers are the most common, but the pattern is identical for any provider in the Terraform Registry.
The versions.tf block below pins the AWS provider to 5.x and Terraform itself to 1.9 or higher. Run terraform init after creating this file; Terraform downloads the provider binary into .terraform/providers and writes a lock file at .terraform.lock.hcl. Commit the lock file. Never gitignore it.
For AWS authentication, prefer environment variables or instance profiles over hardcoded credentials. Export AWS_PROFILE if you use named profiles, or use AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. Terraform reads these automatically. The provider block in HCL should not contain credentials.
After init, run terraform providers to confirm what was downloaded and which version was selected.
# versions.tf
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# variables.tf
variable "aws_region" {
type = string
default = "us-east-1"
}
# Initialize and inspect
export AWS_PROFILE=myprofile
terraform init
terraform providers
Remote State Backends
Local state stored in terraform.tfstate is acceptable for solo experimentation. In any shared environment it is a footgun: two engineers running terraform apply simultaneously will corrupt state. Move to a remote backend before your first team deployment.
The S3 backend with DynamoDB locking is the most widely used pattern for AWS. Create the S3 bucket with versioning enabled and server-side encryption, create the DynamoDB table with a PAY_PER_REQUEST billing mode and a partition key named LockID (string type), then configure the backend block.
The backend block cannot contain variable references, so region and bucket name must be literals or passed via -backend-config flags. We prefer -backend-config files checked into the repository so the values are explicit but the backend block stays clean.
After adding the backend block, run terraform init again. Terraform will detect the new backend and offer to migrate your existing local state. Accept the migration.
# Create S3 bucket and DynamoDB table (run once)
aws s3api create-bucket \
--bucket mycompany-tf-state-prod \
--region us-east-1
aws s3api put-bucket-versioning \
--bucket mycompany-tf-state-prod \
--versioning-configuration Status=Enabled
aws dynamodb create-table \
--table-name mycompany-tf-locks \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
# backend.tf
terraform {
backend "s3" {
bucket = "mycompany-tf-state-prod"
key = "infra/vpc/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "mycompany-tf-locks"
encrypt = true
}
}
# Reinitialize with migration
terraform init -migrate-state
Write and Validate a Real Resource
A hello-world Terraform module that creates an S3 bucket with encryption and versioning teaches the full plan-apply-destroy cycle without costing money beyond a few cents of storage.
After writing main.tf, run terraform fmt to normalize whitespace and indentation. Then run terraform validate to catch syntax errors before any API calls are made. Both commands are fast and should be in your pre-commit hooks.
terraform plan produces an execution plan showing exactly what Terraform will create, modify, or destroy. The output uses + for additions, ~ for in-place updates, and - for destruction. Read the plan carefully before applying. On CI, save the plan to a file with -out=tfplan and apply that exact file to prevent plan-apply drift.
terraform apply without -auto-approve prompts for confirmation. In automation, pass -auto-approve only after you have reviewed the plan output in a prior step.
# main.tf
resource "aws_s3_bucket" "artifacts" {
bucket = "mycompany-artifacts-${var.environment}"
tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_versioning" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" {
bucket = aws_s3_bucket.artifacts.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# variables.tf
variable "environment" {
type = string
description = "Deployment environment name"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging, or prod."
}
}
# Workflow
terraform fmt
terraform validate
terraform plan -var="environment=dev" -out=tfplan
terraform apply tfplan
# Clean up
terraform destroy -var="environment=dev"
Managing Secrets Without Leaking State
Terraform state files store resource attributes in plaintext JSON. Any secret you pass as a variable, or that a provider writes into a resource attribute, ends up in state. This is not hypothetical: AWS RDS passwords, IAM key pairs, and Kubernetes tokens all appear in unencrypted local state.
Three mitigations work in combination. First, use the S3 backend with encryption=true and a bucket policy that denies unencrypted transport. Second, for secrets that must be passed at apply time, use environment variables rather than .tfvars files: Terraform reads any environment variable prefixed with TF_VAR_ and maps it to the matching variable name.
Third, use AWS Secrets Manager or HashiCorp Vault to generate and store secrets outside Terraform, then reference them by ARN or path. This way the secret value is never in your HCL or your state file; only the reference is stored.
For Vault-backed secrets, the Vault provider can read a secret and inject it into a resource without the value appearing in your code. The value still appears in state, which is why backend encryption is not optional.
# Pass secrets via environment variables
export TF_VAR_db_password="$(aws secretsmanager get-secret-value \
--secret-id prod/db/password \
--query SecretString \
--output text)"
terraform apply -var-file=prod.tfvars
# Reference an existing secret by ARN (no plaintext in code)
data "aws_secretsmanager_secret_version" "db_pass" {
secret_id = "prod/db/password"
}
resource "aws_db_instance" "main" {
engine = "postgres"
engine_version = "16.3"
instance_class = "db.t4g.medium"
password = data.aws_secretsmanager_secret_version.db_pass.secret_string
# ...
}
CI/CD Integration and Automation
Running Terraform manually is fine for initial setup. Beyond that, you want plan output in pull requests and apply gated on merge to main. The pattern is the same regardless of which CI system you use: lint and validate on every push, plan on pull request, apply on merge.
GitHub Actions, GitLab CI, and Jenkins all support this. The critical detail is that your CI runner needs AWS credentials (or equivalent cloud credentials) scoped to the minimum permissions required for the resources Terraform manages. Use OIDC federation where possible to avoid storing long-lived credentials in CI secrets.
For teams that want to layer AI-assisted automation on top of their Terraform workflows, taskbotshub.ai provides DevOps-focused AI tools that can help generate boilerplate module code, write variable descriptions, and flag common configuration mistakes before they reach a plan step. We evaluated it as a code-assist layer rather than a replacement for understanding what Terraform is doing, which is the right framing for any AI DevOps tool.
Store plan output as a CI artifact. On GitHub Actions, use the actions/upload-artifact step. Post the plan summary as a pull request comment using a tool like tfcmt. This gives reviewers a readable diff of infrastructure changes without requiring Terraform access.
# .github/workflows/terraform.yml (abbreviated)
name: Terraform
on:
push:
branches: [main]
pull_request:
jobs:
terraform:
runs-on: ubuntu-24.04
permissions:
id-token: write
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.9.8"
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/terraform-ci
aws-region: us-east-1
- run: terraform init
- run: terraform validate
- run: terraform plan -out=tfplan -no-color 2>&1 | tee plan.txt
- uses: actions/upload-artifact@v4
with:
name: tfplan
path: tfplan
- name: Comment plan on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const plan = require('fs').readFileSync('plan.txt', 'utf8');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '```\n' + plan.slice(-60000) + '\n```'
});
Common Errors and How to Fix Them
Error: No valid credential sources found. This means Terraform cannot authenticate to the provider. Check that AWS_PROFILE is set and points to a profile in ~/.aws/config, or that AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are exported in the current shell. Run aws sts get-caller-identity to confirm the credentials work before blaming Terraform.
Error: state data in S3 does not have the expected content. This usually means two applies ran simultaneously and the lock was not respected, or someone edited state manually. Run terraform state pull to download current state and inspect it. If state is actually corrupted, restore from the S3 versioned backup.
Error acquiring the state lock. Another process holds the DynamoDB lock. Check who is running Terraform with aws dynamodb scan --table-name mycompany-tf-locks. If the process crashed, force-unlock using the lock ID shown in the error: terraform force-unlock LOCK-ID. Do not force-unlock while a legitimate apply is running.
Resources were created but not in state. This happens when an apply partially succeeds. Run terraform plan again; Terraform will try to reconcile. If a resource exists in the cloud but not in state, import it: terraform import aws_s3_bucket.artifacts mycompany-artifacts-dev.
Provider checksum mismatch after upgrading. Delete .terraform.lock.hcl and .terraform/providers, then run terraform init again to re-download and recompute checksums. Commit the new lock file.
# Debug credential issues
aws sts get-caller-identity
# Inspect remote state
terraform state pull | python3 -m json.tool | less
# Check active locks
aws dynamodb scan \
--table-name mycompany-tf-locks \
--output table
# Force unlock (use with caution)
terraform force-unlock 1234abcd-5678-efgh-ijkl-90mnopqrstuv
# Import existing resource into state
terraform import aws_s3_bucket.artifacts mycompany-artifacts-dev
# Re-initialize after provider checksum mismatch
rm -rf .terraform .terraform.lock.hcl
terraform init