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.
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
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 terraformcurl -fsSL https://get.opentofu.org/install-opentofu.sh | sudo bash -s -- --install-method debSetting 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 ~/ramnode-openrc.sh
# Enter your password when promptedVerify connectivity:
openstack server listCreate Project Directory
mkdir -p ~/infrastructure/ramnode-cloud
cd ~/infrastructure/ramnode-cloud
# Start Claude Code in this directory
claudeGenerating 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. Uses environment variables for authentication (OS_* vars)
- 2. Targets the RamNode Cloud region
- 3. Includes a variables.tf with common settings
- 4. Has proper .gitignore for Terraform state
Use OpenStack provider version 1.54 or later."
Generated: 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
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
# Terraform state (contains secrets)
*.tfstate
*.tfstate.*
.terraform/
.terraform.lock.hcl
# Credentials
*-openrc.sh
*.pemCreating 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
# 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
}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. "bastion-sg" - SSH from anywhere (I'll restrict this later with my IP)
- 2. "web-sg" - HTTP/HTTPS from anywhere, SSH only from bastion-sg
- 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
# 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"
}Deploying Compute Instances
Now the fun part—spinning up instances. Let's deploy a complete three-tier setup:
"Create compute instances for:
- 1. Bastion host: Smallest flavor (1 vCPU, 1GB RAM), Ubuntu 24.04, Floating IP, bastion-sg
- 2. Web server: 2 vCPU, 4GB RAM, Ubuntu 24.04, web-sg, no floating IP
- 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)
# 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}"
}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
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
}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.
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
| Task | Prompt 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