
Opentofu
- 91 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
opentofu is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- opentofu
- AI & Agent Building
- AI-coding skill
Opentofu by the numbers
- 91 all-time installs (skills.sh)
- +3 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,798 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill opentofuAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 91 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
OpenTofu
Overview
OpenTofu is an open-source infrastructure as code tool that uses HCL (HashiCorp Configuration Language) to declaratively manage cloud infrastructure. It is a community-driven fork of Terraform, fully compatible with existing Terraform providers and modules, with exclusive features like native state encryption. Pulumi provides an alternative IaC approach using general-purpose languages (TypeScript, Python, Go) instead of HCL.
When to use: Managing cloud infrastructure declaratively, provisioning multi-cloud resources, enforcing infrastructure consistency across environments, encrypting state at rest (OpenTofu), using familiar programming languages for IaC (Pulumi).
When NOT to use: One-off scripts better suited to CLI tools, application-level configuration management (use Ansible/Chef), container orchestration logic (use Kubernetes manifests), simple static hosting (use platform-native tools).
Quick Reference
| Pattern | Tool / Command | Key Points |
|---|---|---|
| Initialize project | tofu init | Downloads providers, initializes backend |
| Preview changes | tofu plan | Shows diff without applying |
| Apply changes | tofu apply | Provisions/updates resources |
| Destroy resources | tofu destroy | Tears down managed infrastructure |
| Import resource | tofu import <addr> <id> | Brings existing resource under management |
| State encryption | terraform.encryption block | OpenTofu-exclusive, AES-GCM with key providers |
| Remote backend | backend "s3" / backend "gcs" | Store state in cloud storage with locking |
| Workspaces | tofu workspace new <name> | Isolated state per environment |
| Module usage | module "name" { source = "..." } | Reusable infrastructure components |
| Output values | output "name" { value = ... } | Expose values for other configs or CI |
| Variable files | terraform.tfvars / -var-file | Environment-specific variable overrides |
| Pulumi new project | pulumi new typescript | Scaffold TypeScript IaC project |
| Pulumi preview | pulumi preview | Shows planned changes |
| Pulumi deploy | pulumi up | Provisions/updates resources |
| Pulumi config | pulumi config set key value | Stack-scoped configuration |
| Pulumi secrets | pulumi config set --secret key val | Encrypted config values |
| Pulumi stacks | pulumi stack select <name> | Switch between environments |
| Automation API | LocalWorkspace.createOrSelectStack() | Programmatic stack management |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
| Storing state locally in team environments | Configure remote backend (S3, GCS, Azure Blob) with state locking |
| Hardcoding provider credentials in HCL | Use environment variables or provider-specific auth chains |
Using tofu apply without reviewing plan | Run tofu plan -out=plan.tfplan then tofu apply plan.tfplan |
| Editing state manually | Use tofu state mv, tofu state rm, or tofu import |
Ignoring .terraform.lock.hcl | Commit lock file for reproducible provider versions |
Using count for complex conditional resources | Prefer for_each with maps for stable resource addressing |
| Sharing one workspace for all environments | Use separate workspaces or backend config per environment |
Putting secrets in terraform.tfvars | Use sensitive = true variables, vault, or environment variables |
| Pulumi: creating resources outside component classes | Wrap related resources in ComponentResource for reuse |
| Pulumi: not awaiting async operations | Ensure all resource operations complete before stack export |
Skipping tofu plan in CI/CD | Always plan and require approval before apply in pipelines |
Not using -target carefully | Prefer full plans; -target can leave state inconsistent |
Delegation
- Infrastructure pattern discovery: Use
Exploreagent - IaC code review: Use
Taskagent - Drift detection analysis: Use
Taskagent
If the amazon-web-services skill is available, delegate AWS resource patterns to it.If the docker skill is available, delegate container infrastructure patterns to it.If the github-actions skill is available, delegate CI/CD pipeline patterns to it.References
- HCL syntax, resources, data sources, and providers
- Modules, composition, and reusable infrastructure
- State management, remote backends, and locking
- State encryption with OpenTofu-exclusive key providers
- Variables, outputs, and environment configuration
- Workspaces and multi-environment setups
- Import existing infrastructure and migration patterns
- Pulumi TypeScript and Python SDK patterns
- Pulumi stacks, config, secrets, and automation API
- CI/CD integration and drift detection
CI/CD Integration
OpenTofu GitHub Actions Pipeline
Plan on Pull Request
name: OpenTofu Plan
on:
pull_request:
paths:
- 'infra/**'
permissions:
contents: read
pull-requests: write
jobs:
plan:
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v6
- uses: opentofu/setup-opentofu@v1
with:
tofu_version: 1.8.0
- name: Init
run: tofu init -input=false
- name: Validate
run: tofu validate
- name: Plan
id: plan
run: tofu plan -input=false -no-color -out=plan.tfplan
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Comment Plan
uses: actions/github-script@v7
with:
script: |
const output = `${{ steps.plan.outputs.stdout }}`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## OpenTofu Plan\n\`\`\`\n${output}\n\`\`\``
});Apply on Merge
name: OpenTofu Apply
on:
push:
branches: [main]
paths:
- 'infra/**'
permissions:
contents: read
jobs:
apply:
runs-on: ubuntu-latest
environment: production
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v6
- uses: opentofu/setup-opentofu@v1
with:
tofu_version: 1.8.0
- name: Init
run: tofu init -input=false
- name: Apply
run: tofu apply -input=false -auto-approve
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}Drift Detection
Schedule periodic plans to detect infrastructure drift.
name: Drift Detection
on:
schedule:
- cron: '0 6 * * *'
jobs:
detect-drift:
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v6
- uses: opentofu/setup-opentofu@v1
- name: Init
run: tofu init -input=false
- name: Detect Drift
id: drift
run: |
tofu plan -input=false -no-color -detailed-exitcode 2>&1 | tee plan.txt
echo "exitcode=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
continue-on-error: true
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Notify on Drift
if: steps.drift.outputs.exitcode == '2'
run: |
echo "::warning::Infrastructure drift detected! Review plan.txt"Exit codes: 0 = no changes, 1 = error, 2 = changes detected (drift).
Pulumi GitHub Actions Pipeline
name: Pulumi
on:
pull_request:
paths:
- 'infra/**'
push:
branches: [main]
paths:
- 'infra/**'
jobs:
preview:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 20
- run: npm ci
- uses: pulumi/actions@v5
with:
command: preview
stack-name: org/dev
work-dir: infra
comment-on-pr: true
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
deploy:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 20
- run: npm ci
- uses: pulumi/actions@v5
with:
command: up
stack-name: org/production
work-dir: infra
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}Multi-Environment Pipeline Pattern
name: Deploy Infrastructure
on:
push:
branches: [main]
jobs:
deploy:
strategy:
max-parallel: 1
matrix:
environment: [dev, staging, production]
runs-on: ubuntu-latest
environment: ${{ matrix.environment }}
steps:
- uses: actions/checkout@v6
- uses: opentofu/setup-opentofu@v1
- name: Init
run: tofu init -input=false -backend-config="environments/${{ matrix.environment }}/backend.hcl"
- name: Plan
run: tofu plan -input=false -var-file="environments/${{ matrix.environment }}/terraform.tfvars" -out=plan.tfplan
- name: Apply
run: tofu apply -input=false plan.tfplanSecret Management in CI
Avoid hardcoded credentials. Use OIDC for cloud provider authentication:
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/tofu-deploy
aws-region: us-east-1
- run: tofu apply -input=false -auto-approveThis eliminates long-lived access keys by using GitHub's OIDC token exchange.
HCL Fundamentals
Provider Configuration
Providers are plugins that interact with cloud platforms and services.
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
ManagedBy = "opentofu"
Environment = var.environment
}
}
}Multiple Provider Instances
provider "aws" {
alias = "us_west"
region = "us-west-2"
}
provider "aws" {
alias = "eu_west"
region = "eu-west-1"
}
resource "aws_s3_bucket" "replica" {
provider = aws.eu_west
bucket = "my-replica-bucket"
}Resource Blocks
Resources are the primary building block. Each resource belongs to a provider.
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
subnet_id = aws_subnet.public.id
tags = {
Name = "${var.project}-web"
}
}Resource Lifecycle
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
lifecycle {
create_before_destroy = true
prevent_destroy = true
ignore_changes = [tags["UpdatedAt"]]
}
}Resource Dependencies
resource "aws_iam_role_policy" "app" {
role = aws_iam_role.app.name
policy = data.aws_iam_policy_document.app.json
}
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
iam_instance_profile = aws_iam_instance_profile.app.name
depends_on = [aws_iam_role_policy.app]
}Data Sources
Data sources fetch information from providers or external systems.
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}
data "aws_vpc" "main" {
filter {
name = "tag:Name"
values = [var.vpc_name]
}
}Iteration Patterns
for_each with Map
variable "buckets" {
type = map(object({
versioning = bool
acl = string
}))
}
resource "aws_s3_bucket" "this" {
for_each = var.buckets
bucket = each.key
}
resource "aws_s3_bucket_versioning" "this" {
for_each = { for k, v in var.buckets : k => v if v.versioning }
bucket = aws_s3_bucket.this[each.key].id
versioning_configuration {
status = "Enabled"
}
}for_each with Set of Strings
variable "subnet_ids" {
type = set(string)
}
resource "aws_route_table_association" "this" {
for_each = var.subnet_ids
subnet_id = each.value
route_table_id = aws_route_table.main.id
}Dynamic Blocks
variable "ingress_rules" {
type = list(object({
port = number
protocol = string
cidr_blocks = list(string)
}))
}
resource "aws_security_group" "web" {
name = "web-sg"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
}Expressions
Conditional Expression
resource "aws_instance" "this" {
instance_type = var.environment == "production" ? "m5.large" : "t3.micro"
}String Templates
locals {
bucket_name = "${var.project}-${var.environment}-${var.region}"
config = templatefile("${path.module}/config.tpl", {
db_host = aws_db_instance.main.address
db_port = aws_db_instance.main.port
})
}Collection Functions
locals {
public_subnets = [for s in aws_subnet.public : s.id]
private_subnets = { for s in aws_subnet.private : s.availability_zone => s.id }
all_tags = merge(var.common_tags, { Environment = var.environment })
flat_list = flatten([var.list_a, var.list_b])
}Provisioners
Provisioners run scripts on resources after creation. Use as a last resort.
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
provisioner "remote-exec" {
inline = [
"sudo apt-get update -y",
"sudo apt-get install -y nginx",
]
connection {
type = "ssh"
user = "ubuntu"
private_key = file(var.ssh_key_path)
host = self.public_ip
}
}
}Import and Migration
Importing Existing Resources
CLI Import
tofu import aws_instance.web i-1234567890abcdef0
tofu import 'aws_security_group.web' sg-0123456789abcdef0
tofu import 'module.vpc.aws_vpc.main' vpc-0123456789abcdef0Write the matching resource block first, then import:
resource "aws_instance" "web" {
ami = "ami-0123456789abcdef0"
instance_type = "t3.micro"
}After import, run tofu plan to verify the configuration matches the imported resource. Adjust attributes until the plan shows no changes.
Import Block (Declarative)
import {
to = aws_instance.web
id = "i-1234567890abcdef0"
}
resource "aws_instance" "web" {
ami = "ami-0123456789abcdef0"
instance_type = "t3.micro"
subnet_id = "subnet-0123456789abcdef0"
tags = {
Name = "web-server"
}
}Import blocks are processed during tofu plan and tofu apply. Remove the import block after the resource is successfully imported.
Generate Configuration from Import
tofu plan -generate-config-out=generated.tfThis generates HCL for imported resources, reducing manual configuration writing.
Bulk Import Pattern
locals {
existing_buckets = {
logs = "mycompany-logs-bucket"
backups = "mycompany-backups-bucket"
assets = "mycompany-assets-bucket"
}
}
import {
for_each = local.existing_buckets
to = aws_s3_bucket.imported[each.key]
id = each.value
}
resource "aws_s3_bucket" "imported" {
for_each = local.existing_buckets
bucket = each.value
}Moved Blocks for Refactoring
When renaming or restructuring resources without destroying and recreating:
moved {
from = aws_instance.web
to = aws_instance.app
}
moved {
from = module.old_vpc
to = module.networking
}
moved {
from = aws_instance.web
to = module.compute.aws_instance.web
}Moved blocks can be removed after all team members have applied the change.
Terraform to OpenTofu Migration
Step 1: Install OpenTofu
brew install opentofuStep 2: Replace CLI Commands
| Terraform | OpenTofu |
|---|---|
terraform init | tofu init |
terraform plan | tofu plan |
terraform apply | tofu apply |
terraform destroy | tofu destroy |
Step 3: Initialize with Existing State
tofu initOpenTofu reads existing .terraform.lock.hcl and state files. No state migration needed for most backends.
Step 4: Verify
tofu planA clean plan (no changes) confirms successful migration.
Key Differences
- The
terraform {}block name is retained for compatibility - Provider registry defaults to
registry.opentofu.org(mirrors mostregistry.terraform.ioproviders) - State encryption is available (OpenTofu-exclusive)
- Some provider-defined functions may differ
State Migration Between Backends
tofu init -migrate-stateUpdate the backend configuration, then run init with -migrate-state to copy state to the new backend.
tofu init -reconfigureUse -reconfigure to reset backend configuration without migrating state.
Modules
Module Structure
A module is a directory containing .tf files. Every OpenTofu configuration is a root module.
modules/
vpc/
main.tf
variables.tf
outputs.tf
database/
main.tf
variables.tf
outputs.tfBasic Module Usage
module "vpc" {
source = "./modules/vpc"
cidr_block = "10.0.0.0/16"
environment = var.environment
project = var.project
}
module "database" {
source = "./modules/database"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
db_name = var.db_name
}Module Sources
module "from_local" {
source = "./modules/vpc"
}
module "from_registry" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
}
module "from_github" {
source = "github.com/org/terraform-modules//vpc?ref=v1.2.0"
}
module "from_s3" {
source = "s3::https://s3-eu-west-1.amazonaws.com/bucket/vpc.zip"
}Module Variables with Validation
variable "environment" {
type = string
description = "Deployment environment"
validation {
condition = contains(["dev", "staging", "production"], var.environment)
error_message = "Environment must be dev, staging, or production."
}
}
variable "cidr_block" {
type = string
description = "VPC CIDR block"
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "Must be a valid CIDR block."
}
}
variable "instance_count" {
type = number
default = 1
validation {
condition = var.instance_count > 0 && var.instance_count <= 10
error_message = "Instance count must be between 1 and 10."
}
}Module Outputs
output "vpc_id" {
value = aws_vpc.main.id
description = "ID of the created VPC"
}
output "private_subnet_ids" {
value = [for s in aws_subnet.private : s.id]
description = "IDs of private subnets"
}
output "database_endpoint" {
value = aws_db_instance.main.endpoint
description = "RDS instance endpoint"
sensitive = true
}Module Composition Pattern
Root Module Composing Child Modules
module "networking" {
source = "./modules/networking"
environment = var.environment
vpc_cidr = var.vpc_cidr
azs = var.availability_zones
public_subnets = var.public_subnet_cidrs
}
module "compute" {
source = "./modules/compute"
vpc_id = module.networking.vpc_id
subnet_ids = module.networking.private_subnet_ids
security_group_id = module.networking.app_sg_id
instance_type = var.instance_type
min_size = var.environment == "production" ? 3 : 1
max_size = var.environment == "production" ? 10 : 3
}
module "monitoring" {
source = "./modules/monitoring"
asg_name = module.compute.asg_name
lb_arn_suffix = module.compute.lb_arn_suffix
alarm_sns_topic = var.alarm_sns_topic
}for_each with Modules
variable "services" {
type = map(object({
port = number
instance_type = string
replicas = number
}))
}
module "service" {
source = "./modules/ecs-service"
for_each = var.services
name = each.key
port = each.value.port
instance_type = each.value.instance_type
replicas = each.value.replicas
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
}Passing Providers to Modules
provider "aws" {
alias = "us_east"
region = "us-east-1"
}
provider "aws" {
alias = "eu_west"
region = "eu-west-1"
}
module "cdn" {
source = "./modules/cdn"
providers = {
aws = aws.us_east
aws.secondary = aws.eu_west
}
domain_name = var.domain_name
}Module Testing
OpenTofu supports built-in test files (.tftest.hcl):
run "create_vpc" {
command = plan
variables {
cidr_block = "10.0.0.0/16"
environment = "dev"
project = "test"
}
assert {
condition = aws_vpc.main.cidr_block == "10.0.0.0/16"
error_message = "VPC CIDR block did not match expected value."
}
}
run "validate_outputs" {
command = apply
variables {
cidr_block = "10.0.0.0/16"
environment = "dev"
project = "test"
}
assert {
condition = output.vpc_id != ""
error_message = "VPC ID output must not be empty."
}
}Pulumi SDK Patterns
TypeScript Project Setup
pulumi new typescriptBasic Resource Creation
import * as aws from '@pulumi/aws';
import * as pulumi from '@pulumi/pulumi';
const bucket = new aws.s3.Bucket('my-bucket', {
versioning: {
enabled: true,
},
tags: {
Environment: pulumi.getStack(),
ManagedBy: 'pulumi',
},
});
export const bucketName = bucket.id;
export const bucketArn = bucket.arn;Working with Outputs
Outputs are values that resolve asynchronously after resource creation.
import * as aws from '@pulumi/aws';
import * as pulumi from '@pulumi/pulumi';
const vpc = new aws.ec2.Vpc('main', {
cidrBlock: '10.0.0.0/16',
});
const subnet = new aws.ec2.Subnet('public', {
vpcId: vpc.id,
cidrBlock: '10.0.1.0/24',
mapPublicIpOnLaunch: true,
});
const instanceName = pulumi.interpolate`web-${vpc.id}`;
export const vpcId = vpc.id;
export const subnetId = subnet.id;apply and interpolate
import * as pulumi from '@pulumi/pulumi';
const bucket = new aws.s3.Bucket('data');
const bucketUrl = bucket.bucket.apply(
(name) => `https://${name}.s3.amazonaws.com`,
);
const combined = pulumi
.all([bucket.bucket, bucket.arn])
.apply(([name, arn]) => `Bucket ${name} has ARN ${arn}`);Component Resources
Group related resources into reusable components.
import * as aws from '@pulumi/aws';
import * as pulumi from '@pulumi/pulumi';
interface VpcArgs {
cidrBlock: string;
azCount: number;
tags?: Record<string, string>;
}
class Vpc extends pulumi.ComponentResource {
public readonly vpcId: pulumi.Output<string>;
public readonly publicSubnetIds: pulumi.Output<string>[];
public readonly privateSubnetIds: pulumi.Output<string>[];
constructor(
name: string,
args: VpcArgs,
opts?: pulumi.ComponentResourceOptions,
) {
super('custom:networking:Vpc', name, {}, opts);
const vpc = new aws.ec2.Vpc(
`${name}-vpc`,
{
cidrBlock: args.cidrBlock,
enableDnsHostnames: true,
tags: { ...args.tags, Name: name },
},
{ parent: this },
);
this.vpcId = vpc.id;
this.publicSubnetIds = [];
this.privateSubnetIds = [];
const azs = aws.getAvailabilityZonesOutput({ state: 'available' });
for (let i = 0; i < args.azCount; i++) {
const az = azs.names[i];
const publicSubnet = new aws.ec2.Subnet(
`${name}-public-${i}`,
{
vpcId: vpc.id,
cidrBlock: `10.0.${i}.0/24`,
availabilityZone: az,
mapPublicIpOnLaunch: true,
},
{ parent: this },
);
this.publicSubnetIds.push(publicSubnet.id);
const privateSubnet = new aws.ec2.Subnet(
`${name}-private-${i}`,
{
vpcId: vpc.id,
cidrBlock: `10.0.${i + 100}.0/24`,
availabilityZone: az,
},
{ parent: this },
);
this.privateSubnetIds.push(privateSubnet.id);
}
this.registerOutputs({
vpcId: this.vpcId,
publicSubnetIds: this.publicSubnetIds,
privateSubnetIds: this.privateSubnetIds,
});
}
}
const network = new Vpc('main', { cidrBlock: '10.0.0.0/16', azCount: 2 });
export const vpcId = network.vpcId;Python SDK
pulumi new pythonimport pulumi
import pulumi_aws as aws
bucket = aws.s3.Bucket("my-bucket",
versioning=aws.s3.BucketVersioningArgs(
enabled=True,
),
tags={
"Environment": pulumi.get_stack(),
"ManagedBy": "pulumi",
},
)
pulumi.export("bucket_name", bucket.id)
pulumi.export("bucket_arn", bucket.arn)Python Component Resource
import pulumi
import pulumi_aws as aws
from typing import Optional
class VpcArgs:
def __init__(self, cidr_block: str, az_count: int,
tags: Optional[dict] = None):
self.cidr_block = cidr_block
self.az_count = az_count
self.tags = tags or {}
class Vpc(pulumi.ComponentResource):
vpc_id: pulumi.Output[str]
def __init__(self, name: str, args: VpcArgs,
opts: Optional[pulumi.ResourceOptions] = None):
super().__init__("custom:networking:Vpc", name, {}, opts)
self.vpc = aws.ec2.Vpc(f"{name}-vpc",
cidr_block=args.cidr_block,
enable_dns_hostnames=True,
tags={**args.tags, "Name": name},
opts=pulumi.ResourceOptions(parent=self),
)
self.vpc_id = self.vpc.id
self.register_outputs({"vpc_id": self.vpc_id})Resource Options
const bucket = new aws.s3.Bucket(
'protected',
{},
{
protect: true,
retainOnDelete: true,
ignoreChanges: ['tags'],
dependsOn: [otherResource],
parent: parentComponent,
provider: customProvider,
aliases: [{ name: 'old-bucket-name' }],
},
);| Option | Purpose |
|---|---|
protect | Prevent accidental deletion |
retainOnDelete | Keep cloud resource when removed from code |
ignoreChanges | Skip drift on specific properties |
dependsOn | Explicit dependency ordering |
parent | Organize in component tree |
provider | Use specific provider instance |
aliases | Rename without replacement |
deleteBeforeReplace | Delete old before creating new |
Pulumi Stacks and Configuration
Stack Management
Stacks represent isolated instances of a Pulumi program (similar to OpenTofu workspaces).
pulumi stack init dev
pulumi stack init staging
pulumi stack init production
pulumi stack select dev
pulumi stack lsConfiguration
pulumi config set aws:region us-east-1
pulumi config set instanceType t3.micro
pulumi config set --secret databasePassword s3cret!Reading Config in Code
import * as pulumi from '@pulumi/pulumi';
const config = new pulumi.Config();
const instanceType = config.get('instanceType') || 't3.micro';
const dbPassword = config.requireSecret('databasePassword');
const port = config.getNumber('port') || 8080;import pulumi
config = pulumi.Config()
instance_type = config.get("instanceType") or "t3.micro"
db_password = config.require_secret("databasePassword")
port = config.get_int("port") or 8080Structured Configuration
pulumi config set --path 'database.host' db.example.com
pulumi config set --path 'database.port' 5432
pulumi config set --path --secret 'database.password' s3cretconst config = new pulumi.Config();
interface DatabaseConfig {
host: string;
port: number;
password: pulumi.Output<string>;
}
const dbConfig = config.requireObject<DatabaseConfig>('database');Secrets Management
Pulumi encrypts secrets in the stack config file (Pulumi.<stack>.yaml).
Secrets Providers
pulumi stack init dev --secrets-provider="awskms://alias/pulumi-secrets?region=us-east-1"
pulumi stack init dev --secrets-provider="gcpkms://projects/my-project/locations/global/keyRings/pulumi/cryptoKeys/secrets"
pulumi stack init dev --secrets-provider="passphrase"Programmatic Secrets
const secret = pulumi.secret('my-secret-value');
const dbPassword = config.requireSecret('databasePassword');
const instance = new aws.ec2.Instance('app', {
userData: pulumi.interpolate`#!/bin/bash\nexport DB_PASS=${dbPassword}`,
});State Backends
pulumi login
pulumi login s3://my-pulumi-state
pulumi login gs://my-pulumi-state
pulumi login azblob://my-pulumi-state
pulumi login file://~/.pulumi-stateAutomation API
Embed Pulumi operations inside application code for programmatic infrastructure management.
import * as automation from '@pulumi/pulumi/automation';
import * as aws from '@pulumi/aws';
async function deploy() {
const program = async () => {
const bucket = new aws.s3.Bucket('auto-bucket');
return { bucketName: bucket.id };
};
const stack = await automation.LocalWorkspace.createOrSelectStack({
stackName: 'dev',
projectName: 'automation-example',
program,
});
await stack.setConfig('aws:region', { value: 'us-east-1' });
const upResult = await stack.up({ onOutput: console.log });
console.log('Outputs:', upResult.outputs);
const previewResult = await stack.preview();
console.log('Changes:', previewResult.changeSummary);
}
deploy().catch(console.error);Automation API: Destroy and Remove
async function teardown(stackName: string) {
const stack = await automation.LocalWorkspace.selectStack({
stackName,
projectName: 'automation-example',
program: async () => {},
});
await stack.destroy({ onOutput: console.log });
await stack.workspace.removeStack(stackName);
}Policy as Code (CrossGuard)
Define compliance rules that run during pulumi preview and pulumi up.
import * as policy from '@pulumi/policy';
new policy.PolicyPack('aws-policies', {
policies: [
{
name: 'no-public-s3',
description: 'S3 buckets must not have public ACLs',
enforcementLevel: 'mandatory',
validateResource: policy.validateResourceOfType(
aws.s3.Bucket,
(bucket, args, reportViolation) => {
if (
bucket.acl === 'public-read' ||
bucket.acl === 'public-read-write'
) {
reportViolation('S3 buckets must not use public ACLs.');
}
},
),
},
{
name: 'required-tags',
description: 'All resources must have required tags',
enforcementLevel: 'mandatory',
validateResource: (args, reportViolation) => {
const tags = (args.props as Record<string, unknown>).tags as
| Record<string, string>
| undefined;
if (tags && !tags['Environment']) {
reportViolation('All resources must have an Environment tag.');
}
},
},
],
});pulumi preview --policy-pack ./policies
pulumi up --policy-pack ./policiesStack References
Read outputs from other stacks:
const networkStack = new pulumi.StackReference('org/networking/production');
const vpcId = networkStack.getOutput('vpcId');
const subnetIds = networkStack.getOutput('privateSubnetIds');
const instance = new aws.ec2.Instance('app', {
subnetId: subnetIds.apply((ids) => (ids as string[])[0]),
vpcSecurityGroupIds: [
networkStack.getOutput('appSecurityGroupId') as pulumi.Output<string>,
],
});State Encryption
State encryption is an OpenTofu-exclusive feature that encrypts state and plan files at rest. This protects sensitive data stored in state from unauthorized access.
Encryption Architecture
OpenTofu encryption uses two components:
1. Key provider -- generates or retrieves encryption keys (PBKDF2, AWS KMS, GCP KMS, OpenBao) 2. Encryption method -- uses those keys to encrypt/decrypt (AES-GCM)
PBKDF2 Key Provider
Derives encryption keys from a passphrase. Suitable for individual use or when a KMS is unavailable.
terraform {
encryption {
key_provider "pbkdf2" "main" {
passphrase = var.state_passphrase
}
method "aes_gcm" "main" {
keys = key_provider.pbkdf2.main
}
state {
method = method.aes_gcm.main
}
plan {
method = method.aes_gcm.main
}
}
}
variable "state_passphrase" {
type = string
sensitive = true
}AWS KMS Key Provider
Uses AWS Key Management Service for enterprise-grade key management.
terraform {
encryption {
key_provider "aws_kms" "main" {
kms_key_id = "alias/tofu-state-key"
region = "us-east-1"
key_spec = "AES_256"
}
method "aes_gcm" "main" {
keys = key_provider.aws_kms.main
}
state {
method = method.aes_gcm.main
}
plan {
method = method.aes_gcm.main
}
}
}GCP KMS Key Provider
terraform {
encryption {
key_provider "gcp_kms" "main" {
kms_encryption_key = "projects/my-project/locations/global/keyRings/tofu/cryptoKeys/state"
key_length = 32
}
method "aes_gcm" "main" {
keys = key_provider.gcp_kms.main
}
state {
method = method.aes_gcm.main
}
plan {
method = method.aes_gcm.main
}
}
}Encrypting Remote State Data Sources
When reading encrypted state from another configuration:
terraform {
encryption {
key_provider "pbkdf2" "remote" {
passphrase = var.remote_state_passphrase
}
method "aes_gcm" "remote" {
keys = key_provider.pbkdf2.remote
}
remote_state_data_sources {
default {
method = method.aes_gcm.remote
}
}
}
}
data "terraform_remote_state" "networking" {
backend = "s3"
config = {
bucket = "state-bucket"
key = "networking/terraform.tfstate"
region = "us-east-1"
}
}Migration: Unencrypted to Encrypted
Use the fallback block to read existing unencrypted state while writing encrypted:
terraform {
encryption {
method "unencrypted" "migrate" {}
key_provider "pbkdf2" "main" {
passphrase = var.state_passphrase
}
method "aes_gcm" "main" {
keys = key_provider.pbkdf2.main
}
state {
method = method.aes_gcm.main
fallback {
method = method.unencrypted.migrate
}
}
plan {
method = method.aes_gcm.main
fallback {
method = method.unencrypted.migrate
}
}
}
}After running tofu apply once with the fallback, the state is re-written encrypted. Remove the fallback block afterward to prevent accidental unencrypted reads.
Key Rotation
Rotate keys by adding the old key as a fallback:
terraform {
encryption {
key_provider "pbkdf2" "new" {
passphrase = var.new_passphrase
}
key_provider "pbkdf2" "old" {
passphrase = var.old_passphrase
}
method "aes_gcm" "new" {
keys = key_provider.pbkdf2.new
}
method "aes_gcm" "old" {
keys = key_provider.pbkdf2.old
}
state {
method = method.aes_gcm.new
fallback {
method = method.aes_gcm.old
}
}
plan {
method = method.aes_gcm.new
fallback {
method = method.aes_gcm.old
}
}
}
}Run tofu apply to re-encrypt state with the new key, then remove the fallback.
State Management
State Purpose
OpenTofu state maps real-world resources to configuration. It tracks metadata, dependencies, and current resource attributes. State must be stored securely and accessed with locking in team environments.
Remote Backend Configuration
S3 Backend (AWS)
terraform {
backend "s3" {
bucket = "mycompany-tofu-state"
key = "projects/myapp/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "tofu-state-locks"
encrypt = true
}
}GCS Backend (Google Cloud)
terraform {
backend "gcs" {
bucket = "mycompany-tofu-state"
prefix = "projects/myapp"
}
}Azure Blob Backend
terraform {
backend "azurerm" {
resource_group_name = "tfstate-rg"
storage_account_name = "tfstateaccount"
container_name = "tfstate"
key = "projects/myapp/terraform.tfstate"
}
}HTTP Backend
terraform {
backend "http" {
address = "https://state.example.com/myapp"
lock_address = "https://state.example.com/myapp/lock"
unlock_address = "https://state.example.com/myapp/lock"
}
}Backend Configuration with Partial Config
Keep sensitive values out of HCL files by using partial configuration.
terraform {
backend "s3" {
key = "projects/myapp/terraform.tfstate"
}
}tofu init \
-backend-config="bucket=mycompany-tofu-state" \
-backend-config="region=us-east-1" \
-backend-config="dynamodb_table=tofu-state-locks"Or use a backend config file:
tofu init -backend-config=backend.hclbucket = "mycompany-tofu-state"
region = "us-east-1"
dynamodb_table = "tofu-state-locks"
encrypt = trueState Locking
State locking prevents concurrent operations from corrupting state.
| Backend | Locking Mechanism |
|---|---|
| S3 | DynamoDB table |
| GCS | Built-in |
| Azure Blob | Built-in (lease) |
| Consul | Built-in |
| HTTP | Lock/Unlock endpoints |
Force Unlock (Emergency Only)
tofu force-unlock <lock-id>State Operations
List Resources in State
tofu state listShow Resource Details
tofu state show aws_instance.webMove Resource Address
tofu state mv aws_instance.web aws_instance.app
tofu state mv 'module.old_name' 'module.new_name'Remove Resource from State
tofu state rm aws_instance.legacyPull and Push State
tofu state pull > backup.tfstate
tofu state push backup.tfstateReading Remote State
Access outputs from other configurations using terraform_remote_state:
data "terraform_remote_state" "networking" {
backend = "s3"
config = {
bucket = "mycompany-tofu-state"
key = "networking/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_instance" "app" {
subnet_id = data.terraform_remote_state.networking.outputs.private_subnet_id
}State Bootstrap Pattern
Create the state backend resources before using them.
resource "aws_s3_bucket" "state" {
bucket = "mycompany-tofu-state"
}
resource "aws_s3_bucket_versioning" "state" {
bucket = aws_s3_bucket.state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_dynamodb_table" "locks" {
name = "tofu-state-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}After initial apply with local state, migrate to the S3 backend:
tofu init -migrate-stateVariables and Outputs
Variable Types
variable "name" {
type = string
default = "myapp"
}
variable "instance_count" {
type = number
default = 2
}
variable "enable_monitoring" {
type = bool
default = true
}
variable "tags" {
type = map(string)
default = {
ManagedBy = "opentofu"
}
}
variable "availability_zones" {
type = list(string)
default = ["us-east-1a", "us-east-1b"]
}
variable "service_config" {
type = object({
name = string
port = number
replicas = number
env = map(string)
})
}
variable "services" {
type = list(object({
name = string
port = number
}))
}Variable Validation
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "production"], var.environment)
error_message = "Environment must be dev, staging, or production."
}
}
variable "ami_id" {
type = string
validation {
condition = startswith(var.ami_id, "ami-")
error_message = "AMI ID must start with 'ami-'."
}
}
variable "cidr_block" {
type = string
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "Must be a valid CIDR block."
}
}Sensitive Variables
variable "database_password" {
type = string
sensitive = true
}
variable "api_key" {
type = string
sensitive = true
}Sensitive values are redacted in plan and apply output but stored in state. Combine with state encryption for full protection.
Setting Variable Values
terraform.tfvars (Auto-Loaded)
environment = "production"
instance_type = "m5.large"
tags = {
Team = "platform"
Project = "myapp"
}Environment-Specific Files
tofu plan -var-file="environments/production.tfvars"Environment Variables
export TF_VAR_database_password="secret123"
export TF_VAR_environment="production"
tofu planCommand Line
tofu plan -var="instance_count=3" -var="environment=staging"Variable Precedence (Highest to Lowest)
1. -var and -var-file flags 2. *.auto.tfvars files (alphabetical) 3. terraform.tfvars 4. Environment variables (TF_VAR_*) 5. Default values
Outputs
output "vpc_id" {
value = aws_vpc.main.id
description = "ID of the VPC"
}
output "public_ip" {
value = aws_eip.web.public_ip
description = "Public IP of the web server"
}
output "database_endpoint" {
value = aws_db_instance.main.endpoint
description = "RDS endpoint"
sensitive = true
}
output "instance_ids" {
value = { for k, v in aws_instance.app : k => v.id }
}Conditional Outputs
output "cdn_domain" {
value = var.enable_cdn ? aws_cloudfront_distribution.main[0].domain_name : null
description = "CloudFront domain name (null if CDN disabled)"
}Locals
Locals compute intermediate values to reduce repetition.
locals {
name_prefix = "${var.project}-${var.environment}"
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "opentofu"
}
private_subnet_cidrs = [for i, az in var.availability_zones :
cidrsubnet(var.vpc_cidr, 8, i)
]
public_subnet_cidrs = [for i, az in var.availability_zones :
cidrsubnet(var.vpc_cidr, 8, i + 100)
]
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
tags = merge(local.common_tags, { Name = "${local.name_prefix}-vpc" })
}Type Constraints
variable "optional_config" {
type = object({
name = string
port = optional(number, 8080)
enabled = optional(bool, true)
})
}The optional() modifier allows omitting fields with defaults, reducing boilerplate in variable definitions.
Workspaces
Workspace Basics
Workspaces provide isolated state within the same configuration. Each workspace has its own state file.
tofu workspace list
tofu workspace new staging
tofu workspace new production
tofu workspace select staging
tofu workspace showWorkspace-Aware Configuration
locals {
environment = terraform.workspace
instance_types = {
dev = "t3.micro"
staging = "t3.small"
production = "m5.large"
}
instance_counts = {
dev = 1
staging = 2
production = 3
}
}
resource "aws_instance" "app" {
count = local.instance_counts[local.environment]
instance_type = local.instance_types[local.environment]
ami = data.aws_ami.ubuntu.id
tags = {
Name = "app-${local.environment}-${count.index}"
Environment = local.environment
}
}Workspace vs Directory-Based Environments
Workspace Approach
Single configuration, multiple workspaces:
tofu workspace select production
tofu apply -var-file="envs/production.tfvars"Pros: Less code duplication, single source of truth. Cons: All environments share same provider config, harder to diverge.
Directory-Based Approach
Separate directories per environment:
environments/
dev/
main.tf
backend.tf
terraform.tfvars
staging/
main.tf
backend.tf
terraform.tfvars
production/
main.tf
backend.tf
terraform.tfvars
modules/
vpc/
compute/Pros: Full isolation, independent provider versions, different backends. Cons: More files to maintain, risk of drift between environments.
Hybrid Approach (Recommended)
Shared modules with per-environment root configurations:
module "infrastructure" {
source = "../../modules/infrastructure"
environment = "production"
instance_type = "m5.large"
instance_count = 3
vpc_cidr = "10.1.0.0/16"
}Workspace-Aware Backend Keys
terraform {
backend "s3" {
bucket = "mycompany-tofu-state"
key = "myapp/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "tofu-state-locks"
workspace_key_prefix = "environments"
}
}State paths become: environments/<workspace>/myapp/terraform.tfstate
Workspace Delete
tofu workspace select default
tofu workspace delete stagingA workspace must have empty state before deletion. Destroy resources first:
tofu workspace select staging
tofu destroy
tofu workspace select default
tofu workspace delete staging