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

  1. Choose the right tool: Prefer terraform_data for new projects, as it's built into Terraform core and represents the future direction
  2. Design precise triggers: Only include attributes that should actually cause re-execution
  3. Implement robust error handling: Scripts should be idempotent and provide clear error messages
  4. Consider alternatives first: Always evaluate whether cloud-native initialization or other declarative approaches can solve your problem
  5. 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.