Provisioners Without Resources for Terraform and OpenTofu
Provisioners without resources? Learn how Scalr enables standalone provisioners for Terraform & OpenTofu—no dummy resources, cleaner code, safer runs.
Introduction
Infrastructure as Code (IaC) has revolutionized how organizations manage their cloud infrastructure, with Terraform and OpenTofu leading the charge in declarative infrastructure management. However, even the most comprehensive declarative tools sometimes require imperative operations—running scripts, coordinating resources, or performing actions that don't map directly to physical infrastructure.
This gap between declarative infrastructure and imperative operations has led to the development of "provisioners without resources"—a pattern that enables essential orchestration and configuration management within Terraform and OpenTofu workflows. Understanding when and how to use these tools effectively is crucial for platform engineers managing complex infrastructure deployments.
Understanding Provisioners Without Resources
Terraform and OpenTofu are fundamentally declarative tools, designed to define what infrastructure should exist rather than how to create it. However, real-world infrastructure management often requires imperative actions like:
- Running initialization scripts after resource creation
- Coordinating multiple resources that lack built-in dependencies
- Integrating with external systems that don't have native provider support
- Performing cleanup operations during resource destruction
Provisioners without resources solve this challenge by creating execution hooks within Terraform's apply phase. These special resources participate in the dependency graph and state management but don't represent actual infrastructure—they exist solely to trigger provisioner execution.
The Two Approaches: null_resource vs terraform_data
null_resource: The Traditional Approach
The null_resource
comes from the null provider and has been the go-to solution for resourceless provisioning:
resource "null_resource" "database_setup" {
triggers = {
db_instance_id = aws_db_instance.main.id
schema_version = var.schema_version
}
provisioner "local-exec" {
command = <<-EOT
export DB_HOST=${aws_db_instance.main.address}
export DB_NAME=${aws_db_instance.main.name}
./scripts/setup-database.sh
EOT
}
}
terraform_data: The Modern Built-in Alternative
Introduced in Terraform 1.4, terraform_data
eliminates the need for an external provider:
resource "terraform_data" "database_setup" {
triggers_replace = [
aws_db_instance.main.id,
var.schema_version
]
provisioner "local-exec" {
command = <<-EOT
export DB_HOST=${aws_db_instance.main.address}
export DB_NAME=${aws_db_instance.main.name}
./scripts/setup-database.sh
EOT
}
}
Key Differences Comparison
Feature | null_resource | terraform_data |
---|---|---|
Provider Source | External null provider | Built-in (terraform.io/builtin/terraform) |
Trigger Mechanism | triggers map |
triggers_replace list/value |
Data Storage | No built-in storage | input /output attributes for value storage |
First Available | Early Terraform versions | Terraform 1.4+ (March 2023) |
OpenTofu Support | Full support | Full support in OpenTofu 1.6+ |
Future Direction | Maintenance mode | Preferred approach |
Configuration Complexity | Slightly more verbose | Cleaner, more intuitive syntax |
Key Use Cases for Resourceless Provisioners
1. Multi-Resource Orchestration
When you need to coordinate actions across multiple resources that lack natural dependencies:
resource "terraform_data" "cluster_initialization" {
triggers_replace = aws_instance.cluster[*].id
connection {
type = "ssh"
host = aws_instance.cluster[0].public_ip
user = "ec2-user"
private_key = file("~/.ssh/cluster-key.pem")
}
provisioner "remote-exec" {
inline = [
"sudo /opt/cluster/init.sh ${join(" ", aws_instance.cluster[*].private_ip)}"
]
}
}
2. External System Integration
Integrating with systems that don't have native Terraform providers:
resource "null_resource" "load_balancer_registration" {
triggers = {
server_ip = aws_instance.web.private_ip
server_id = aws_instance.web.id
}
provisioner "local-exec" {
command = "python scripts/register_server.py --ip=${aws_instance.web.private_ip} --id=${aws_instance.web.id}"
}
provisioner "local-exec" {
when = destroy
command = "python scripts/deregister_server.py --id=${self.triggers.server_id}"
}
}
3. Conditional Operations
Running operations only when specific conditions are met:
resource "terraform_data" "backup_creation" {
count = var.create_backup ? 1 : 0
triggers_replace = aws_db_instance.main.id
provisioner "local-exec" {
command = "aws rds create-db-snapshot --db-instance-identifier ${aws_db_instance.main.id} --db-snapshot-identifier ${aws_db_instance.main.id}-$(date +%Y%m%d%H%M%S)"
}
}
4. Configuration File Generation
Generating configuration files based on infrastructure state:
resource "null_resource" "config_generator" {
triggers = {
cluster_endpoint = aws_eks_cluster.main.endpoint
cluster_ca = aws_eks_cluster.main.certificate_authority[0].data
}
provisioner "local-exec" {
command = <<-EOT
cat > kubeconfig.yaml <<EOF
apiVersion: v1
kind: Config
clusters:
- cluster:
certificate-authority-data: ${aws_eks_cluster.main.certificate_authority[0].data}
server: ${aws_eks_cluster.main.endpoint}
name: ${aws_eks_cluster.main.name}
EOF
EOT
}
}
Implementation Patterns and Best Practices
Trigger Design Patterns
Effective trigger design is crucial for reliable provisioner execution:
# Good: Precise triggering based on relevant changes
resource "terraform_data" "app_deployment" {
triggers_replace = [
aws_instance.app.id,
var.app_version,
md5(file("${path.module}/deploy.sh"))
]
provisioner "local-exec" {
command = "./deploy.sh ${aws_instance.app.public_ip} ${var.app_version}"
}
}
# Avoid: Overly broad triggers that cause unnecessary executions
resource "null_resource" "deployment_bad_example" {
triggers = {
# This will trigger on ANY instance attribute change
instance_data = jsonencode(aws_instance.app)
}
}
Error Handling and Retry Logic
Implement robust error handling in your provisioner scripts:
resource "terraform_data" "resilient_operation" {
triggers_replace = aws_instance.web.id
provisioner "local-exec" {
command = <<-EOT
#!/bin/bash
set -e
# Retry logic for network-dependent operations
for i in {1..5}; do
if curl -f http://${aws_instance.web.public_ip}/health; then
echo "Server is ready"
break
else
echo "Attempt $i: Server not ready, waiting..."
sleep 30
fi
if [ $i -eq 5 ]; then
echo "Server failed to become ready after 5 attempts"
exit 1
fi
done
# Proceed with actual operation
./configure-server.sh ${aws_instance.web.public_ip}
EOT
on_failure = continue # Don't fail entire apply for non-critical operations
}
}
State Management with terraform_data
Leverage terraform_data
for storing and tracking values:
resource "terraform_data" "deployment_info" {
input = {
timestamp = timestamp()
version = var.app_version
environment = var.environment
deployment_id = uuidv4()
}
}
# Use the stored data in other resources
resource "aws_ssm_parameter" "deployment_metadata" {
name = "/app/deployment/current"
type = "String"
value = jsonencode(terraform_data.deployment_info.output)
}
Common Pitfalls and Solutions
1. Dependency Race Conditions
Problem: Provisioners executing before dependencies are ready.
Solution: Use explicit dependencies and readiness checks:
resource "terraform_data" "database_migration" {
depends_on = [aws_db_instance.main, aws_security_group_rule.db_access]
triggers_replace = var.db_schema_version
provisioner "local-exec" {
command = <<-EOT
# Wait for database to be ready
until pg_isready -h ${aws_db_instance.main.address} -p ${aws_db_instance.main.port}; do
echo "Waiting for database..."
sleep 5
done
# Run migrations
./migrate.sh
EOT
}
}
2. Poor Trigger Management
Problem: Triggers that are too broad or too narrow.
Solution: Design triggers based on actual dependencies:
# Good: Specific, relevant triggers
resource "null_resource" "ssl_certificate_update" {
triggers = {
domain_name = var.domain_name
cert_arn = aws_acm_certificate.main.arn
lb_arn = aws_lb.main.arn
}
provisioner "local-exec" {
command = "aws elbv2 modify-listener --listener-arn ${aws_lb_listener.https.arn} --certificates CertificateArn=${aws_acm_certificate.main.arn}"
}
}
3. Inadequate Error Handling
Problem: Scripts failing silently or with unclear errors.
Solution: Implement comprehensive error handling:
resource "terraform_data" "application_setup" {
triggers_replace = aws_instance.app.id
provisioner "local-exec" {
command = <<-EOT
#!/bin/bash
set -euo pipefail # Exit on error, undefined vars, pipe failures
# Logging
exec > >(tee -a /tmp/terraform-setup.log) 2>&1
echo "Starting setup at $(date)"
# Function for cleanup on error
cleanup() {
echo "Setup failed, cleaning up..."
# Add cleanup logic here
}
trap cleanup ERR
# Main setup logic
echo "Configuring application on ${aws_instance.app.public_ip}"
ssh -o StrictHostKeyChecking=no ec2-user@${aws_instance.app.public_ip} 'sudo systemctl enable myapp'
echo "Setup completed successfully at $(date)"
EOT
}
}
Modern Alternatives and Platform Considerations
Cloud-Native Initialization
Modern cloud platforms offer built-in initialization mechanisms that often eliminate the need for provisioners:
# Using AWS user data instead of provisioners
resource "aws_instance" "web" {
ami = "ami-12345678"
instance_type = "t3.micro"
user_data = base64encode(<<-EOF
#!/bin/bash
yum update -y
yum install -y nginx
systemctl enable nginx
systemctl start nginx
EOF
)
tags = {
Name = "web-server"
}
}
Platform Integration Benefits
When working with advanced Terraform automation platforms like Scalr, resourceless provisioners can be enhanced with additional features:
- Centralized execution: Provisioners run in controlled environments with consistent access to credentials and tools
- Audit trails: All provisioner executions are logged and tracked across your organization
- Policy enforcement: Pre and post-execution hooks can enforce organizational policies
- Collaboration features: Team members can view provisioner outputs and troubleshoot failures together
Scalr's support for both Terraform (up to 1.5.7) and OpenTofu makes it particularly well-suited for organizations navigating the transition between these tools while maintaining consistent provisioner workflows.
Container-Based Alternatives
For application deployment, container orchestration often provides better separation of concerns:
# Instead of using provisioners for app deployment
resource "aws_ecs_service" "app" {
name = "app-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = 3
deployment_configuration {
maximum_percent = 200
minimum_healthy_percent = 100
}
}
resource "aws_ecs_task_definition" "app" {
family = "app"
container_definitions = jsonencode([
{
name = "app"
image = "myapp:${var.app_version}"
# ... container configuration
}
])
}
Summary
Provisioners without resources serve as a crucial bridge between Terraform/OpenTofu's declarative model and the imperative operations required in real-world infrastructure management. While the ecosystem is evolving toward more declarative approaches, these tools remain valuable when used appropriately.
Key Recommendations
- Choose the right tool: Prefer
terraform_data
for new projects, as it's built into Terraform core and represents the future direction - Design precise triggers: Only include attributes that should actually cause re-execution
- Implement robust error handling: Scripts should be idempotent and provide clear error messages
- Consider alternatives first: Always evaluate whether cloud-native initialization or other declarative approaches can solve your problem
- Leverage platform features: Use advanced platforms like Scalr to enhance provisioner capabilities with centralized execution, audit trails, and team collaboration
When to Use Resourceless Provisioners
- Multi-resource orchestration that requires coordination beyond simple dependencies
- External system integration where no native provider exists
- Legacy system bridging during migration periods
- Specialized cleanup operations that must run before resource destruction
When to Avoid Them
- Simple server initialization (use cloud-init or user-data instead)
- Application deployment (use container orchestration or deployment tools)
- Complex configuration management (use dedicated tools like Ansible or Chef)
- Operations that can be handled by native resources (always prefer declarative approaches)
By understanding these patterns and following best practices, platform engineers can effectively use provisioners without resources to fill gaps in their infrastructure automation while maintaining the reliability and maintainability that makes IaC valuable.