Part 2 of 10

    Automating OpenStack Infrastructure

    Generate Terraform configurations, manage security groups, and deploy complete application stacks through natural language on your RamNode Cloud VPS.

    Terraform/OpenTofu
    Infrastructure as Code
    OpenStack API

    In Part 1, we installed Claude Code and generated a server hardening script. Now we're stepping up to infrastructure-as-code: using Claude Code to generate Terraform configurations, manage OpenStack resources, and deploy complete application stacks through natural language.

    If you've ever stared at Terraform documentation trying to figure out the right resource blocks for OpenStack, this guide is for you. Claude Code can translate "I need a web server with a floating IP behind a security group that allows HTTP and SSH" into working infrastructure code in seconds.

    1

    Prerequisites

    Before starting, you'll need:

    • Claude Code installed (see Part 1)
    • A RamNode Cloud account with API access
    • OpenStack credentials (downloadable from Horizon dashboard)
    • Terraform or OpenTofu installed on your local machine

    Installing Terraform/OpenTofu

    Option 1: Terraform
    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
    Option 2: OpenTofu (open-source fork)
    curl -fsSL https://get.opentofu.org/install-opentofu.sh | sudo bash -s -- --install-method deb
    2

    Setting Up OpenStack Credentials

    Download your OpenStack RC file from the RamNode Cloud dashboard (Project → API Access → Download OpenStack RC File). Source it to set environment variables:

    Source credentials
    source ~/ramnode-openrc.sh
    # Enter your password when prompted

    Verify connectivity:

    Test connection
    openstack server list

    Create Project Directory

    Project setup
    mkdir -p ~/infrastructure/ramnode-cloud
    cd ~/infrastructure/ramnode-cloud
    
    # Start Claude Code in this directory
    claude
    3

    Generating Your First Terraform Configuration

    Let's start simple. Ask Claude Code to create a basic OpenStack provider configuration:

    "Create a Terraform configuration for OpenStack that:

    1. 1. Uses environment variables for authentication (OS_* vars)
    2. 2. Targets the RamNode Cloud region
    3. 3. Includes a variables.tf with common settings
    4. 4. Has proper .gitignore for Terraform state

    Use OpenStack provider version 1.54 or later."

    Generated: providers.tf

    providers.tf
    terraform {
      required_version = ">= 1.0"
    
      required_providers {
        openstack = {
          source  = "terraform-provider-openstack/openstack"
          version = "~> 1.54"
        }
      }
    }
    
    provider "openstack" {
      # Authentication via environment variables:
      # OS_AUTH_URL, OS_PROJECT_NAME, OS_USERNAME, OS_PASSWORD, etc.
      # Source your openrc.sh before running terraform
    }

    Generated: variables.tf

    variables.tf
    variable "region" {
      description = "OpenStack region"
      type        = string
      default     = "RegionOne"
    }
    
    variable "availability_zone" {
      description = "Availability zone for instances"
      type        = string
      default     = "nova"
    }
    
    variable "public_network" {
      description = "Name of the external/public network"
      type        = string
      default     = "public"
    }
    
    variable "dns_nameservers" {
      description = "DNS servers for private subnets"
      type        = list(string)
      default     = ["1.1.1.1", "8.8.8.8"]
    }
    
    variable "default_ssh_key" {
      description = "Name of SSH keypair in OpenStack"
      type        = string
    }

    Generated: .gitignore

    .gitignore
    # Terraform state (contains secrets)
    *.tfstate
    *.tfstate.*
    .terraform/
    .terraform.lock.hcl
    
    # Credentials
    *-openrc.sh
    *.pem
    4

    Creating Network Infrastructure

    This is where Claude Code saves serious time—OpenStack networking has many interconnected resources:

    "Add network infrastructure with:

    • • A private network called "app-network" with CIDR 10.0.1.0/24
    • • A router connecting the private network to the public network
    • • DHCP enabled with the DNS servers from variables
    • • Output the network and subnet IDs"

    Generated: network.tf

    network.tf
    # Private Network
    resource "openstack_networking_network_v2" "app_network" {
      name           = "app-network"
      admin_state_up = true
    }
    
    resource "openstack_networking_subnet_v2" "app_subnet" {
      name            = "app-subnet"
      network_id      = openstack_networking_network_v2.app_network.id
      cidr            = "10.0.1.0/24"
      ip_version      = 4
      dns_nameservers = var.dns_nameservers
    
      allocation_pool {
        start = "10.0.1.10"
        end   = "10.0.1.250"
      }
    }
    
    # Router (connects private network to public internet)
    data "openstack_networking_network_v2" "public" {
      name = var.public_network
    }
    
    resource "openstack_networking_router_v2" "app_router" {
      name                = "app-router"
      admin_state_up      = true
      external_network_id = data.openstack_networking_network_v2.public.id
    }
    
    resource "openstack_networking_router_interface_v2" "app_router_interface" {
      router_id = openstack_networking_router_v2.app_router.id
      subnet_id = openstack_networking_subnet_v2.app_subnet.id
    }
    
    # Outputs
    output "network_id" {
      description = "ID of the application network"
      value       = openstack_networking_network_v2.app_network.id
    }
    
    output "subnet_id" {
      description = "ID of the application subnet"
      value       = openstack_networking_subnet_v2.app_subnet.id
    }
    5

    Security Groups: The Conversational Advantage

    Security groups are tedious to write by hand. Each rule needs direction, protocol, port range, and remote specification. Let Claude Code handle it:

    "Create security groups for a typical web application:

    1. 1. "bastion-sg" - SSH from anywhere (I'll restrict this later with my IP)
    2. 2. "web-sg" - HTTP/HTTPS from anywhere, SSH only from bastion-sg
    3. 3. "database-sg" - PostgreSQL (5432) only from web-sg, SSH only from bastion-sg

    Include proper descriptions and use security group references (not CIDR) for internal traffic."

    Generated: security_groups.tf

    security_groups.tf
    # Bastion Security Group
    resource "openstack_networking_secgroup_v2" "bastion" {
      name        = "bastion-sg"
      description = "Security group for bastion/jump host"
    }
    
    resource "openstack_networking_secgroup_rule_v2" "bastion_ssh" {
      direction         = "ingress"
      ethertype         = "IPv4"
      protocol          = "tcp"
      port_range_min    = 22
      port_range_max    = 22
      remote_ip_prefix  = "0.0.0.0/0"  # Restrict to your IP in production
      security_group_id = openstack_networking_secgroup_v2.bastion.id
      description       = "SSH access - restrict to admin IPs"
    }
    
    # Web Server Security Group
    resource "openstack_networking_secgroup_v2" "web" {
      name        = "web-sg"
      description = "Security group for web servers"
    }
    
    resource "openstack_networking_secgroup_rule_v2" "web_http" {
      direction         = "ingress"
      ethertype         = "IPv4"
      protocol          = "tcp"
      port_range_min    = 80
      port_range_max    = 80
      remote_ip_prefix  = "0.0.0.0/0"
      security_group_id = openstack_networking_secgroup_v2.web.id
      description       = "HTTP from anywhere"
    }
    
    resource "openstack_networking_secgroup_rule_v2" "web_https" {
      direction         = "ingress"
      ethertype         = "IPv4"
      protocol          = "tcp"
      port_range_min    = 443
      port_range_max    = 443
      remote_ip_prefix  = "0.0.0.0/0"
      security_group_id = openstack_networking_secgroup_v2.web.id
      description       = "HTTPS from anywhere"
    }
    
    resource "openstack_networking_secgroup_rule_v2" "web_ssh_from_bastion" {
      direction         = "ingress"
      ethertype         = "IPv4"
      protocol          = "tcp"
      port_range_min    = 22
      port_range_max    = 22
      remote_group_id   = openstack_networking_secgroup_v2.bastion.id
      security_group_id = openstack_networking_secgroup_v2.web.id
      description       = "SSH from bastion only"
    }
    
    # Database Security Group
    resource "openstack_networking_secgroup_v2" "database" {
      name        = "database-sg"
      description = "Security group for database servers"
    }
    
    resource "openstack_networking_secgroup_rule_v2" "db_postgres_from_web" {
      direction         = "ingress"
      ethertype         = "IPv4"
      protocol          = "tcp"
      port_range_min    = 5432
      port_range_max    = 5432
      remote_group_id   = openstack_networking_secgroup_v2.web.id
      security_group_id = openstack_networking_secgroup_v2.database.id
      description       = "PostgreSQL from web servers"
    }
    
    resource "openstack_networking_secgroup_rule_v2" "db_ssh_from_bastion" {
      direction         = "ingress"
      ethertype         = "IPv4"
      protocol          = "tcp"
      port_range_min    = 22
      port_range_max    = 22
      remote_group_id   = openstack_networking_secgroup_v2.bastion.id
      security_group_id = openstack_networking_secgroup_v2.database.id
      description       = "SSH from bastion only"
    }
    6

    Deploying Compute Instances

    Now the fun part—spinning up instances. Let's deploy a complete three-tier setup:

    "Create compute instances for:

    1. 1. Bastion host: Smallest flavor (1 vCPU, 1GB RAM), Ubuntu 24.04, Floating IP, bastion-sg
    2. 2. Web server: 2 vCPU, 4GB RAM, Ubuntu 24.04, web-sg, no floating IP
    3. 3. Database server: 2 vCPU, 4GB RAM, Ubuntu 24.04, database-sg, 50GB block storage

    Use cloud-init to set hostname and install basic packages."

    Generated: instances.tf (excerpt)

    instances.tf
    # Data Sources
    data "openstack_images_image_v2" "ubuntu" {
      name        = "Ubuntu 24.04"
      most_recent = true
    }
    
    data "openstack_compute_flavor_v2" "small" {
      vcpus = 1
      ram   = 1024
    }
    
    data "openstack_compute_flavor_v2" "medium" {
      vcpus = 2
      ram   = 4096
    }
    
    # Bastion Host
    resource "openstack_compute_instance_v2" "bastion" {
      name              = "bastion"
      image_id          = data.openstack_images_image_v2.ubuntu.id
      flavor_id         = data.openstack_compute_flavor_v2.small.id
      key_pair          = var.default_ssh_key
      availability_zone = var.availability_zone
    
      security_groups = [
        openstack_networking_secgroup_v2.bastion.name
      ]
    
      network {
        uuid = openstack_networking_network_v2.app_network.id
      }
    
      user_data = <<-EOF
        #cloud-config
        hostname: bastion
        package_update: true
        packages:
          - htop
          - tmux
      EOF
    }
    
    resource "openstack_networking_floatingip_v2" "bastion_fip" {
      pool = var.public_network
    }
    
    resource "openstack_compute_floatingip_associate_v2" "bastion_fip" {
      floating_ip = openstack_networking_floatingip_v2.bastion_fip.address
      instance_id = openstack_compute_instance_v2.bastion.id
    }
    
    # Web Server
    resource "openstack_compute_instance_v2" "web" {
      name              = "web-server"
      image_id          = data.openstack_images_image_v2.ubuntu.id
      flavor_id         = data.openstack_compute_flavor_v2.medium.id
      key_pair          = var.default_ssh_key
      availability_zone = var.availability_zone
    
      security_groups = [
        openstack_networking_secgroup_v2.web.name
      ]
    
      network {
        uuid = openstack_networking_network_v2.app_network.id
      }
    
      user_data = <<-EOF
        #cloud-config
        hostname: web-server
        package_update: true
        packages:
          - nginx
          - htop
        runcmd:
          - systemctl enable nginx
          - systemctl start nginx
      EOF
    
      depends_on = [openstack_networking_router_interface_v2.app_router_interface]
    }
    
    # Database Server with Block Storage
    resource "openstack_compute_instance_v2" "database" {
      name              = "database-server"
      image_id          = data.openstack_images_image_v2.ubuntu.id
      flavor_id         = data.openstack_compute_flavor_v2.medium.id
      key_pair          = var.default_ssh_key
      availability_zone = var.availability_zone
    
      security_groups = [
        openstack_networking_secgroup_v2.database.name
      ]
    
      network {
        uuid = openstack_networking_network_v2.app_network.id
      }
    
      user_data = <<-EOF
        #cloud-config
        hostname: database-server
        package_update: true
        packages:
          - postgresql
          - postgresql-contrib
        runcmd:
          - systemctl enable postgresql
          - systemctl start postgresql
      EOF
    
      depends_on = [openstack_networking_router_interface_v2.app_router_interface]
    }
    
    resource "openstack_blockstorage_volume_v3" "db_data" {
      name              = "db-data-volume"
      size              = 50
      availability_zone = var.availability_zone
      description       = "PostgreSQL data volume"
    }
    
    resource "openstack_compute_volume_attach_v2" "db_data_attach" {
      instance_id = openstack_compute_instance_v2.database.id
      volume_id   = openstack_blockstorage_volume_v3.db_data.id
    }
    
    # Outputs
    output "bastion_floating_ip" {
      description = "Public IP of the bastion host"
      value       = openstack_networking_floatingip_v2.bastion_fip.address
    }
    
    output "ssh_to_web" {
      description = "Command to SSH to web server via bastion"
      value       = "ssh -J ubuntu@${openstack_networking_floatingip_v2.bastion_fip.address} ubuntu@${openstack_compute_instance_v2.web.network[0].fixed_ip_v4}"
    }
    
    output "ssh_to_database" {
      description = "Command to SSH to database server via bastion"
      value       = "ssh -J ubuntu@${openstack_networking_floatingip_v2.bastion_fip.address} ubuntu@${openstack_compute_instance_v2.database.network[0].fixed_ip_v4}"
    }
    7

    Creating Reusable Modules

    Once you have patterns that work, ask Claude Code to modularize them:

    "Refactor the web server into a reusable Terraform module that accepts:

    • • instance_name, flavor (small, medium, large)
    • • security_group_ids (list), network_id, subnet_id
    • • ssh_key_name, user_data (optional)
    • • create_floating_ip (boolean, default false)

    Put it in modules/compute-instance/"

    Using the Module

    Using compute-instance module
    module "app_server" {
      source = "./modules/compute-instance"
    
      instance_name        = "my-app"
      flavor               = "medium"
      security_group_names = [openstack_networking_secgroup_v2.web.name]
      network_id           = openstack_networking_network_v2.app_network.id
      ssh_key_name         = var.default_ssh_key
      create_floating_ip   = true
    
      user_data = <<-EOF
        #cloud-config
        hostname: my-app
        packages: [docker.io, docker-compose]
      EOF
    }
    8

    Debugging Terraform Errors

    Claude Code excels at debugging. When Terraform fails:

    "Terraform is giving me this error:

    Error: Error creating OpenStack server: Bad request with:
    [POST https://cloud.ramnode.com:8774/v2.1/servers],
    error message: {"badRequest": {"message": "Can not find requested image"}}

    Here's my resource: [paste the resource block]
    What's wrong and how do I fix it?"

    Claude Code analyzes the error, identifies the issue (image name mismatch, permissions, or availability), and provides the fix.

    9

    Tips for Effective Terraform Generation

    • Be specific about versions. OpenStack providers and Terraform itself evolve. Mention version constraints.
    • Describe relationships explicitly. "PostgreSQL only from web servers" is clearer than "locked down database."
    • Ask for outputs. Connection strings, SSH commands, and IDs are easy to forget but invaluable.
    • Request idempotency. Ask for configurations that can be applied multiple times safely.
    • Iterate on modules. Start with inline resources, then ask Claude Code to refactor into modules once patterns emerge.

    Quick Reference: Useful Prompts

    TaskPrompt Pattern
    New security group"Create a security group for [service] that allows [ports] from [sources]"
    Add instance"Add a [size] instance running [OS] with [packages] in [security group]"
    Network setup"Create a private network with CIDR [range] connected to public via router"
    Module extraction"Refactor [resource] into a reusable module accepting [variables]"
    Debug error"Terraform gives error [error]. Here's my config [config]. What's wrong?"
    Add volume"Attach a [size]GB volume to [instance] for [purpose]"

    What's Next

    You've learned to generate complete OpenStack infrastructure through conversation. In Part 3, we'll explore Docker Compose Generation & Container Orchestration—describing multi-service stacks in plain English and generating production-ready compose files.

    Continue to Part 3