Skip to content

Terraform Getting Started

First steps with Terraform - Complete guide to setting up Terraform for Kavi infrastructure (planned implementation).


Planned Architecture

This documentation describes the planned Terraform setup. Terraform is not yet implemented but this guide shows how to set it up when ready.


Table of Contents


Prerequisites

Required Accounts

Before starting with Terraform, ensure you have:

Service Purpose How to Get
Hetzner Cloud VPS provisioning https://console.hetzner.cloud
Cloudflare DNS management https://dash.cloudflare.com
Hetzner Storage Box Terraform state storage Already have: u522581.your-storagebox.de
Infisical Secret storage Already deployed: https://secrets.kua.cl

Required API Tokens

Store these in Infisical before starting:

Hetzner Cloud API Token:

# Generate at: https://console.hetzner.cloud/projects → Security → API Tokens
# Permissions: Read & Write
# Store in Infisical:
infisical secrets set HCLOUD_TOKEN="<token>" --env=dev --projectId=personal-vault

Cloudflare API Token:

# Generate at: https://dash.cloudflare.com/profile/api-tokens
# Template: "Edit zone DNS"
# Zone Resources: Include → Specific zone → kua.cl
# Store in Infisical:
infisical secrets set CLOUDFLARE_API_TOKEN="<token>" --env=dev --projectId=personal-vault

Storage Box Credentials:

# From Hetzner Robot: https://robot.hetzner.com/storage
# Store in Infisical:
infisical secrets set STORAGEBOX_ACCESS_KEY="<username>" --env=dev --projectId=personal-vault
infisical secrets set STORAGEBOX_SECRET_KEY="<password>" --env=dev --projectId=personal-vault

System Requirements

Terraform CLI: - Version: >= 1.6.0 - Installation: Already in Docker dev environment

Development Environment: - SSH access to Hetzner VPS (100.80.53.55) - dev() container access - Git for version control


Installation

Verify Terraform in Docker Image

Terraform should be pre-installed in the my-dev-env:latest image:

# In dev() container
terraform version

# Expected output:
# Terraform v1.6.5
# on linux_amd64

Install Terraform (if not in image)

If Terraform is not pre-installed:

Option A: Add to Docker Image (recommended)

# In Dockerfile
RUN wget https://releases.hashicorp.com/terraform/1.6.5/terraform_1.6.5_linux_amd64.zip && \
    unzip terraform_1.6.5_linux_amd64.zip && \
    mv terraform /usr/local/bin/ && \
    rm terraform_1.6.5_linux_amd64.zip && \
    terraform version

Option B: Install in Container (temporary)

# In dev() container
wget https://releases.hashicorp.com/terraform/1.6.5/terraform_1.6.5_linux_amd64.zip
unzip terraform_1.6.5_linux_amd64.zip
sudo mv terraform /usr/local/bin/
terraform version

Project Setup

Create Project Directory

# In dev() container
cd /app
mkdir -p terraform
cd terraform

Initialize Git Repository

# Initialize Git
git init

# Create .gitignore
cat > .gitignore << 'EOF'
# Terraform
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.*
*.tfvars
crash.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json

# Secrets
.env
secrets.auto.tfvars

# IDE
.vscode/
.idea/
*.swp
*.swo
EOF

# Initial commit
git add .gitignore
git commit -m "chore: initial Terraform project setup"

Create Basic Structure

# Create core files
touch terraform.tf providers.tf variables.tf outputs.tf README.md

# Create resource files
touch hetzner-vps.tf dns.tf networks.tf

# Verify structure
tree
# .
# ├── .gitignore
# ├── README.md
# ├── dns.tf
# ├── hetzner-vps.tf
# ├── networks.tf
# ├── outputs.tf
# ├── providers.tf
# ├── terraform.tf
# └── variables.tf

Provider Authentication

Define Providers

providers.tf:

# Hetzner Cloud provider
provider "hcloud" {
  token = var.hcloud_token
}

# Cloudflare provider
provider "cloudflare" {
  api_token = var.cloudflare_api_token
}

# Docker provider (for networks)
provider "docker" {
  host = "unix:///var/run/docker.sock"
}

Define Variables

variables.tf:

# Hetzner Cloud API token
variable "hcloud_token" {
  description = "Hetzner Cloud API token (from Infisical)"
  type        = string
  sensitive   = true
}

# Cloudflare API token
variable "cloudflare_api_token" {
  description = "Cloudflare API token (from Infisical)"
  type        = string
  sensitive   = true
}

# Cloudflare Zone ID
variable "cloudflare_zone_id" {
  description = "Cloudflare zone ID for kua.cl"
  type        = string
}

# Storage Box credentials
variable "storagebox_access_key" {
  description = "Storage Box S3 access key (username)"
  type        = string
  sensitive   = true
}

variable "storagebox_secret_key" {
  description = "Storage Box S3 secret key (password)"
  type        = string
  sensitive   = true
}

Inject Variables from Infisical

Option A: Environment Variables (recommended)

# In dev() container (secrets already loaded from Infisical)

# Terraform reads from TF_VAR_* environment variables
export TF_VAR_hcloud_token="$HCLOUD_TOKEN"
export TF_VAR_cloudflare_api_token="$CLOUDFLARE_API_TOKEN"
export TF_VAR_cloudflare_zone_id="$CLOUDFLARE_ZONE_ID"
export TF_VAR_storagebox_access_key="$STORAGEBOX_ACCESS_KEY"
export TF_VAR_storagebox_secret_key="$STORAGEBOX_SECRET_KEY"

# Or create a helper script
cat > load-terraform-vars.sh << 'EOF'
#!/bin/bash
export TF_VAR_hcloud_token="$HCLOUD_TOKEN"
export TF_VAR_cloudflare_api_token="$CLOUDFLARE_API_TOKEN"
export TF_VAR_cloudflare_zone_id="$CLOUDFLARE_ZONE_ID"
export TF_VAR_storagebox_access_key="$STORAGEBOX_ACCESS_KEY"
export TF_VAR_storagebox_secret_key="$STORAGEBOX_SECRET_KEY"
EOF

chmod +x load-terraform-vars.sh
source ./load-terraform-vars.sh

Option B: terraform.tfvars File (less secure, not recommended)

# terraform.tfvars (gitignored)
hcloud_token           = "..."  # Don't hardcode, defeats Infisical purpose
cloudflare_api_token   = "..."  # Don't do this

Recommended: Use environment variables so secrets stay in Infisical only.


Backend Configuration

Configure S3 Backend

terraform.tf:

terraform {
  # Terraform version
  required_version = ">= 1.6.0"

  # Remote state on Hetzner Storage Box (S3-compatible)
  backend "s3" {
    bucket   = "terraform-state"
    key      = "infrastructure/terraform.tfstate"
    region   = "us-east-1"  # Dummy value for compatibility

    # Storage Box S3 endpoint
    endpoint = "https://u522581.your-storagebox.de"

    # Credentials (from variables, set via environment)
    # Note: Can't use var.* in backend config, must use -backend-config flags

    # S3 compatibility flags
    skip_credentials_validation = true
    skip_region_validation      = true
    skip_metadata_api_check     = true
    force_path_style            = true
  }

  # Required providers
  required_providers {
    hcloud = {
      source  = "hetznercloud/hcloud"
      version = "~> 1.45.0"
    }
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 4.20.0"
    }
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.0.0"
    }
  }
}

Initialize with Backend

# Initialize Terraform with backend credentials
terraform init \
  -backend-config="access_key=$STORAGEBOX_ACCESS_KEY" \
  -backend-config="secret_key=$STORAGEBOX_SECRET_KEY"

# Expected output:
# Initializing the backend...
# Successfully configured the backend "s3"!
#
# Initializing provider plugins...
# - Finding hetznercloud/hcloud versions matching "~> 1.45.0"...
# - Finding cloudflare/cloudflare versions matching "~> 4.20.0"...
# - Installing hetznercloud/hcloud v1.45.0...
# - Installing cloudflare/cloudflare v4.20.0...
#
# Terraform has been successfully initialized!

Troubleshooting init:

If initialization fails:

# Check Storage Box credentials
echo $STORAGEBOX_ACCESS_KEY  # Should show username
echo $STORAGEBOX_SECRET_KEY  # Should show password (or *** if hidden)

# Test S3 access manually
s3cmd --access_key=$STORAGEBOX_ACCESS_KEY \
      --secret_key=$STORAGEBOX_SECRET_KEY \
      --host=u522581.your-storagebox.de \
      --host-bucket=u522581.your-storagebox.de \
      ls s3://terraform-state/

# If bucket doesn't exist, create it
s3cmd --access_key=$STORAGEBOX_ACCESS_KEY \
      --secret_key=$STORAGEBOX_SECRET_KEY \
      --host=u522581.your-storagebox.de \
      mb s3://terraform-state


First Resource

Create SSH Key Resource

Let's create our first Terraform-managed resource: an SSH key in Hetzner Cloud.

hetzner-vps.tf:

# Upload MacBook SSH public key to Hetzner Cloud
resource "hcloud_ssh_key" "macbook" {
  name       = "macbook"
  public_key = file("/root/.ssh/id_ed25519_macbook.pub")
}

# Output the SSH key ID
output "macbook_ssh_key_id" {
  description = "Hetzner Cloud SSH key ID for MacBook"
  value       = hcloud_ssh_key.macbook.id
}

Plan the Change

# Preview what Terraform will do
terraform plan

# Expected output:
# Terraform will perform the following actions:
#
#   # hcloud_ssh_key.macbook will be created
#   + resource "hcloud_ssh_key" "macbook" {
#       + fingerprint = (known after apply)
#       + id          = (known after apply)
#       + labels      = (known after apply)
#       + name        = "macbook"
#       + public_key  = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... kavi@macbook"
#     }
#
# Plan: 1 to add, 0 to change, 0 to destroy.

Review carefully: - ✅ + resource means CREATE (green) - ⚠️ ~ resource means UPDATE (yellow) - ❌ - resource means DESTROY (red)

Apply the Change

# Apply the plan
terraform apply

# Terraform shows plan again and prompts:
# Do you want to perform these actions?
#   Terraform will perform the actions described above.
#   Only 'yes' will be accepted to approve.
#
#   Enter a value: yes

# Type: yes

# Expected output:
# hcloud_ssh_key.macbook: Creating...
# hcloud_ssh_key.macbook: Creation complete after 1s [id=12345678]
#
# Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
#
# Outputs:
# macbook_ssh_key_id = 12345678

Verify in Hetzner Console

# Via CLI
hcloud ssh-key list

# Expected:
# ID         NAME      FINGERPRINT
# 12345678   macbook   aa:bb:cc:dd:...

# Or check Hetzner Console:
# https://console.hetzner.cloud/projects → Security → SSH Keys
# Should see "macbook" key

Congratulations! You've created your first Terraform-managed resource.


Essential Commands

terraform init

Purpose: Initialize working directory, download providers, configure backend

terraform init

# When to run:
# - First time in new project
# - After adding new providers
# - After changing backend configuration
# - After cloning repository

terraform plan

Purpose: Preview changes before applying

terraform plan

# Shows:
# + Resources to create
# ~ Resources to update
# - Resources to destroy

# Always run before apply!

Save plan to file:

# Save plan
terraform plan -out=tfplan

# Apply saved plan (no confirmation needed)
terraform apply tfplan

terraform apply

Purpose: Apply changes to infrastructure

terraform apply

# Interactive: Shows plan, prompts for confirmation
# Enter: yes

# Auto-approve (dangerous, use with caution):
terraform apply -auto-approve

terraform destroy

Purpose: Destroy all managed infrastructure

terraform destroy

# Shows what will be destroyed, prompts for confirmation
# Enter: yes

# ⚠️ WARNING: This destroys EVERYTHING
# Use with extreme caution

Destroy specific resource:

terraform destroy -target=hcloud_ssh_key.macbook

terraform show

Purpose: Show current state

terraform show

# Displays all managed resources and their attributes

terraform state list

Purpose: List all managed resources

terraform state list

# Example output:
# hcloud_ssh_key.macbook
# hcloud_server.hetzner_vps
# cloudflare_record.root

terraform output

Purpose: Display output values

terraform output

# Shows all outputs

terraform output macbook_ssh_key_id

# Shows specific output
# 12345678

terraform fmt

Purpose: Format Terraform files

# Format all .tf files
terraform fmt

# Check formatting (returns non-zero if not formatted)
terraform fmt -check

terraform validate

Purpose: Validate configuration syntax

terraform validate

# Expected:
# Success! The configuration is valid.

# Or if errors:
# Error: Missing required argument
#   on hetzner-vps.tf line 5:
#   ...

Troubleshooting

"Error: Backend configuration changed"

Symptom:

Error: Backend configuration changed

A change in the backend configuration has been detected, which may require
migrating existing state.

Solution:

# Reinitialize with migration
terraform init -migrate-state

# Or reconfigure without migration
terraform init -reconfigure

"Error: Failed to get existing workspaces"

Symptom:

Error: Failed to get existing workspaces: AccessDenied: Access Denied

Cause: Wrong Storage Box credentials

Solution:

# Check credentials
echo $STORAGEBOX_ACCESS_KEY
echo $STORAGEBOX_SECRET_KEY

# Reinitialize with correct credentials
terraform init \
  -backend-config="access_key=$STORAGEBOX_ACCESS_KEY" \
  -backend-config="secret_key=$STORAGEBOX_SECRET_KEY" \
  -reconfigure

"Error: Invalid provider configuration"

Symptom:

Error: Invalid provider configuration

Provider "hcloud" requires configuration.

Cause: Missing TF_VAR_hcloud_token environment variable

Solution:

# Load variables from Infisical
source ./load-terraform-vars.sh

# Or export manually
export TF_VAR_hcloud_token="$HCLOUD_TOKEN"

# Verify
env | grep TF_VAR

"Error: Error creating server"

Symptom:

Error: Error creating server: permission denied

Cause: Invalid or expired Hetzner API token

Solution:

# Check token in Infisical
infisical secrets get HCLOUD_TOKEN --env=dev --projectId=personal-vault

# Generate new token at Hetzner Console
# Update in Infisical
infisical secrets set HCLOUD_TOKEN="new-token" --env=dev --projectId=personal-vault

# Reload dev() to get new token
exit
dev terraform

State Lock Errors

Symptom:

Error: Error acquiring the state lock

Lock Info:
  ID:        abc-123
  Path:      terraform-state/infrastructure/terraform.tfstate

Cause: Another terraform process is running, or previous run crashed

Solution:

# If you're sure no other process is running:
terraform force-unlock abc-123

# Use lock ID from error message


Summary

Terraform Setup Steps: 1. ✅ Store API tokens in Infisical 2. ✅ Create project directory structure 3. ✅ Configure providers and backend 4. ✅ Initialize with terraform init 5. ✅ Create first resource 6. ✅ Plan with terraform plan 7. ✅ Apply with terraform apply

Essential Commands: - terraform init - Initialize project - terraform plan - Preview changes - terraform apply - Apply changes - terraform destroy - Destroy resources - terraform show - Show current state - terraform state list - List resources

Security: - ✅ Store tokens in Infisical (not .tfvars files) - ✅ Use environment variables (TF_VAR_) - ✅ Gitignore .tfstate and *.tfvars - ✅ Remote state on Storage Box (encrypted at rest)

What's Next: - Learn State Management - critical state concepts - Learn Providers - Hetzner, Cloudflare, Docker setup - Learn VPS Management - provision Hetzner VPS - Learn Workflow - daily operations and best practices