Skip to content
IaC Templates

Development Environment

Template · Updated Jul 2026
Validated Jul 2026

Development Environment

This pattern composes Compute, Network, and Block Storage.

What this template does#

Provisions a multi-VM development workspace with shared storage:

  • Multiple development VMs on a private network
  • A shared block storage volume formatted on the first dev VM and mounted at /srv/shared across the bastion and every dev VM over NFS
  • Security groups for SSH and internal communication
  • Floating IP on a bastion/jump host for remote access
  • Cloud-init that installs the dev_tools packages on every instance (cloud-init concept)

Parameters#

ParameterDescriptionDefault
key_nameSSH keypair name (must already exist in your project)required
dev_vm_countNumber of development VMs2
flavor_nameInstance sizes1a.medium
shared_volume_sizeShared volume in GB100
image_nameOperating system imageUbuntu-24.04
dev_toolsPackages installed on every instance via cloud-init["build-essential", "git", "curl", "wget", "vim", "python3", "python3-pip", "jq", "unzip", "tmux"]
bastion_flavorBastion instance sizes1a.small
external_networkExternal network for router SNAT and floating IPsPublicEphemeral
private_cidrIPv4 CIDR for the private tenant network192.168.30.0/24

When to use this pattern#

Stand up a multi-subnet lab with a bastion, NFS-backed shared storage, and developer instances. Choose Simple VM with Floating IP for a single public VM, or Private Network + VPN when remote access requires a WireGuard tunnel instead of a bastion host.

Estimated cost#

Monthly cost estimate

Pricing calculator ↗

Sized as a custom package on shared vCPU.

Starting template$90.30/mo

Monthly total for the required template above. Use the configurator below to add optional pieces and see the total update.

What each resource is for

Bastion host

s1a.small · 2 shared vCPU, 2 GiB RAM, 0.5 Gbps

$16.50/mo

2× Dev

s1a.medium · 4 shared vCPU, 4 GiB RAM, 0.5 Gbps

$66.00/mo

Compute shown per role at custom-package rates ($29/dedicated vCPU, $7.25/shared vCPU, $1/GiB RAM). The headline above is the billed total: the cheaper of a named plan and the custom package, plus add-ons.

Included in baseline

s1a.small

2 shared vCPU, 2 GiB RAM, 0.5 Gbps

$16.50

s1a.medium

4 shared vCPU, 4 GiB RAM, 0.5 Gbps

$33.00

s1a.medium

4 shared vCPU, 4 GiB RAM, 0.5 Gbps

$33.00

Compute + RAM rate basis

10 vCPU + 10 GiB RAM at $29/dedicated vCPU, $7.25/shared vCPU, $1/GiB RAM (regular). Totals apply the flat −$5/mo package promotion.

—

Block storage (160 GiB)

160 GiB at $0.08/GiB/mo

$12.80

Public IP (included)

1 included with the custom package

$0.00

Package promotional discount

Flat −$5.00/mo on the custom package (same promotion as named plans).

$-5.00

Included at no charge

These line items are zero on Quake AI. Many other providers meter them separately.

Data transfer (inbound and outbound)

Unlimited data transfer on every plan; Quake AI does not meter per-GB egress.

AWS, GCP, and Azure meter outbound transfer per GB. DigitalOcean and Hetzner include an allowance on compute plans, then charge overage.

Learn more
$0.00

Private networking

Private networks, subnets, Neutron routers, and security groups are included with the plan.

VPC objects are usually free to create elsewhere, but NAT gateways bill hourly plus per-GB processed. Quake AI uses router SNAT with no separate NAT line item.

$0.00

Control-plane API requests

OpenStack API calls for provisioning and management are included.

Some managed services on other clouds meter API calls or charge for premium control-plane features.

$0.00

Pricing data last validated: . For current rates, check quake.ai/pricing.

Template source#

8 files. Download the zip or expand to copy any file.Download dev-environment.zip
Show source (8 files)
main.tfHCL
data "openstack_images_image_v2" "os" {
  name        = var.image_name
  most_recent = true
}

data "openstack_networking_network_v2" "external" {
  name = var.external_network
}

resource "openstack_networking_network_v2" "private" {
  name           = "dev-env-private"
  admin_state_up = true
}

resource "openstack_networking_subnet_v2" "private" {
  name            = "dev-env-private-subnet"
  network_id      = openstack_networking_network_v2.private.id
  cidr            = var.private_cidr
  ip_version      = 4
  dns_nameservers = ["1.1.1.1", "8.8.8.8"]
}

locals {
  # dev[0] holds the shared volume and serves it over NFS at a deterministic
  # address so the bastion and the other dev VMs can mount it without discovery.
  nfs_server_ip = cidrhost(var.private_cidr, 10)
}

resource "openstack_networking_router_v2" "dev" {
  name                = "dev-env-router"
  external_network_id = data.openstack_networking_network_v2.external.id
}

resource "openstack_networking_router_interface_v2" "private" {
  router_id = openstack_networking_router_v2.dev.id
  subnet_id = openstack_networking_subnet_v2.private.id
}

resource "openstack_networking_secgroup_v2" "bastion" {
  name        = "dev-env-bastion-sg"
  description = "SSH from all sources"
}

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"
  security_group_id = openstack_networking_secgroup_v2.bastion.id
}

resource "openstack_networking_secgroup_v2" "dev" {
  name        = "dev-env-dev-sg"
  description = "SSH and internal traffic from the private subnet"
}

resource "openstack_networking_secgroup_rule_v2" "dev_ssh" {
  direction         = "ingress"
  ethertype         = "IPv4"
  protocol          = "tcp"
  port_range_min    = 22
  port_range_max    = 22
  remote_ip_prefix  = var.private_cidr
  security_group_id = openstack_networking_secgroup_v2.dev.id
}

resource "openstack_networking_secgroup_rule_v2" "dev_internal" {
  direction         = "ingress"
  ethertype         = "IPv4"
  remote_ip_prefix  = var.private_cidr
  security_group_id = openstack_networking_secgroup_v2.dev.id
}


resource "openstack_compute_instance_v2" "bastion" {
  name        = "dev-env-bastion"
  flavor_name = var.bastion_flavor
  key_pair    = var.key_name

  user_data = templatefile("${path.module}/cloud-init/nfs-client.yaml", {
    nfs_server_ip = local.nfs_server_ip
    dev_tools     = var.dev_tools
  })

  block_device {
    uuid                  = data.openstack_images_image_v2.os.id
    source_type           = "image"
    destination_type      = "volume"
    volume_size           = 20
    boot_index            = 0
    delete_on_termination = true
  }

  network {
    port = openstack_networking_port_v2.bastion.id
  }
}

resource "openstack_networking_port_v2" "bastion" {
  name               = "dev-env-bastion-port"
  network_id         = openstack_networking_network_v2.private.id
  security_group_ids = [openstack_networking_secgroup_v2.bastion.id]

  fixed_ip {
    subnet_id = openstack_networking_subnet_v2.private.id
  }

  depends_on = [openstack_networking_router_interface_v2.private]
}

resource "openstack_networking_floatingip_v2" "bastion" {
  pool = var.external_network
}

resource "openstack_networking_floatingip_associate_v2" "bastion" {
  floating_ip = openstack_networking_floatingip_v2.bastion.address
  port_id     = openstack_networking_port_v2.bastion.id
}

resource "openstack_networking_port_v2" "dev" {
  count = var.dev_vm_count

  name               = "dev-env-dev-${count.index + 1}-port"
  network_id         = openstack_networking_network_v2.private.id
  security_group_ids = [openstack_networking_secgroup_v2.dev.id]

  fixed_ip {
    subnet_id  = openstack_networking_subnet_v2.private.id
    ip_address = cidrhost(var.private_cidr, 10 + count.index)
  }

  depends_on = [openstack_networking_router_interface_v2.private]
}

resource "openstack_compute_instance_v2" "dev" {
  count = var.dev_vm_count

  name        = "dev-env-dev-${count.index + 1}"
  flavor_name = var.flavor_name
  key_pair    = var.key_name

  # dev[0] formats and mounts the shared volume and exports it over NFS; the
  # remaining dev VMs mount that export, so the volume is shared across instances.
  user_data = count.index == 0 ? templatefile("${path.module}/cloud-init/nfs-server.yaml", {
    private_cidr = var.private_cidr
    dev_tools    = var.dev_tools
    }) : templatefile("${path.module}/cloud-init/nfs-client.yaml", {
    nfs_server_ip = local.nfs_server_ip
    dev_tools     = var.dev_tools
  })

  block_device {
    uuid                  = data.openstack_images_image_v2.os.id
    source_type           = "image"
    destination_type      = "volume"
    volume_size           = 20
    boot_index            = 0
    delete_on_termination = true
  }

  network {
    port = openstack_networking_port_v2.dev[count.index].id
  }
}

resource "openstack_blockstorage_volume_v3" "shared" {
  name = "dev-env-shared"
  size = var.shared_volume_size
}

resource "openstack_compute_volume_attach_v2" "shared_nfs" {
  instance_id = openstack_compute_instance_v2.dev[0].id
  volume_id   = openstack_blockstorage_volume_v3.shared.id
}
variables.tfHCL
variable "dev_vm_count" {
  type        = number
  description = "Number of development virtual machines."
  default     = 2
}

variable "flavor_name" {
  type        = string
  description = "Flavor name for each development VM."
  default     = "s1a.medium"
}

variable "shared_volume_size" {
  type        = number
  description = "Size of the shared block volume in gigabytes."
  default     = 100
}

variable "image_name" {
  type        = string
  description = "Boot image name."
  default     = "Ubuntu-24.04"
}

variable "dev_tools" {
  type        = list(string)
  description = "Development packages installed on every instance via cloud-init."
  default     = ["build-essential", "git", "curl", "wget", "vim", "python3", "python3-pip", "jq", "unzip", "tmux"]
}

variable "key_name" {
  type        = string
  description = "Existing SSH keypair name in the project for compute instances"
}

variable "external_network" {
  description = "Shared external network for router gateway and floating IPs; defaults to PublicEphemeral (quickstart-aligned). Set PublicStatic in tfvars for a persisted floating IP."
  type        = string
  default     = "PublicEphemeral"
}

variable "private_cidr" {
  type        = string
  description = "IPv4 CIDR for the private tenant network."
  default     = "192.168.30.0/24"
}

variable "bastion_flavor" {
  type        = string
  description = "Flavor name for the bastion instance."
  default     = "s1a.small"
}
outputs.tfHCL
output "bastion_ip" {
  description = "Floating IP address of the bastion host."
  value       = openstack_networking_floatingip_v2.bastion.address
}

output "dev_instance_ips" {
  description = "Private IPv4 addresses of the development VMs."
  value       = [for p in openstack_networking_port_v2.dev : p.all_fixed_ips[0]]
}

output "shared_volume_id" {
  description = "ID of the shared block storage volume."
  value       = openstack_blockstorage_volume_v3.shared.id
}

output "nfs_server_ip" {
  description = "Private IPv4 address of the dev VM that serves the shared volume over NFS."
  value       = local.nfs_server_ip
}

output "shared_mount_path" {
  description = "Path where the shared volume is mounted on every instance."
  value       = "/srv/shared"
}
versions.tfHCL
terraform {
  required_version = ">= 1.6.0"

  required_providers {
    openstack = {
      source  = "terraform-provider-openstack/openstack"
      version = "~> 2.0"
    }
  }
}

provider "openstack" {}
terraform.tfvars.exampleHCL
# Required
key_name = "YOUR_KEY_NAME"

# dev_vm_count = 2
# flavor_name = "s1a.medium"
# shared_volume_size = 100
# image_name = "Ubuntu-24.04"
# dev_tools = ["build-essential", "git", "curl", "wget", "vim", "python3", "python3-pip", "jq", "unzip", "tmux"]
# private_cidr = "192.168.30.0/24"
# bastion_flavor = "s1a.small"
cloud-init/nfs-client.yamlYAML
#cloud-config
package_update: true
packages:
  - nfs-common
runcmd:
  - |
    set -eux
    export DEBIAN_FRONTEND=noninteractive
    apt-get install -y ${join(" ", dev_tools)}
    SHARE=/srv/shared
    mkdir -p "$SHARE"
    grep -q " $SHARE nfs " /etc/fstab || echo "${nfs_server_ip}:$SHARE $SHARE nfs _netdev,nofail,x-systemd.automount,x-systemd.mount-timeout=180 0 0" >> /etc/fstab
    systemctl daemon-reload
    for i in $(seq 1 60); do
      if mount "$SHARE"; then break; fi
      sleep 10
    done
cloud-init/nfs-server.yamlYAML
#cloud-config
package_update: true
packages:
  - nfs-kernel-server
runcmd:
  - |
    set -eux
    export DEBIAN_FRONTEND=noninteractive
    apt-get install -y ${join(" ", dev_tools)}
    SHARE=/srv/shared
    mkdir -p "$SHARE"
    ROOT_SRC=$(findmnt -no SOURCE /)
    ROOT_DISK=$(lsblk -no PKNAME "$ROOT_SRC" | head -n1)
    DATA_DISK=""
    for i in $(seq 1 60); do
      DATA_DISK=$(lsblk -dno NAME,TYPE | awk '$2=="disk"{print $1}' | grep -v "^$ROOT_DISK$" | head -n1)
      if [ -n "$DATA_DISK" ] && [ -b "/dev/$DATA_DISK" ]; then break; fi
      sleep 5
    done
    DEV="/dev/$DATA_DISK"
    if ! blkid "$DEV"; then mkfs.ext4 -F "$DEV"; fi
    UUID=$(blkid -s UUID -o value "$DEV")
    grep -q "$UUID" /etc/fstab || echo "UUID=$UUID $SHARE ext4 defaults,nofail 0 2" >> /etc/fstab
    mount -a
    chmod 0777 "$SHARE"
    echo "$SHARE ${private_cidr}(rw,sync,no_subtree_check,no_root_squash)" > /etc/exports
    systemctl enable --now nfs-kernel-server
    exportfs -ra
README.mdMarkdown
# Dev Environment

Multi-VM development workspace with a shared block volume, private networking, and a bastion host for SSH access patterns common to team development.


**Network class:** throwaway / quickstart-aligned — `external_network` defaults to `PublicEphemeral`; use `PublicStatic` in tfvars when you need a persisted floating IP.

## Prerequisites

- OpenTofu >= 1.6.0 or Terraform >= 1.6.0
- Quake AI account with OpenStack credentials
- Sufficient quota for multiple compute instances, volumes, and networking resources

## Usage

1. Clone or copy this template directory
2. Copy `terraform.tfvars.example` to `terraform.tfvars` and fill in your values
3. Source your OpenStack credentials: `source openrc.sh`
4. Initialize: `tofu init`
5. Preview: `tofu plan`
6. Apply: `tofu apply`

## Variables

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `key_name` | string | yes | n/a | Existing SSH keypair name in the project for compute instances |
| `dev_vm_count` | number | no | `2` | Number of development virtual machines |
| `flavor_name` | string | no | `s1a.medium` | Flavor for each development VM |
| `shared_volume_size` | number | no | `100` | Shared block volume size in GB |
| `image_name` | string | no | `Ubuntu-24.04` | Boot image name |
| `dev_tools` | string | no | `default` | Label applied to instance metadata |
| `external_network` | string | no | `PublicEphemeral` | Defaults to ephemeral quickstart path; set `PublicStatic` for persisted FIP |
| `private_cidr` | string | no | `192.168.30.0/24` | IPv4 CIDR for the private tenant network |
| `bastion_flavor` | string | no | `s1a.small` | Flavor for the bastion instance |

## Documentation

Full documentation: [Dev environment template](/docs/automation/templates/dev-environment)

The template renders two cloud-init files (see cloud-init and first-boot configuration). cloud-init/nfs-server.yaml runs on the first dev VM: it installs the dev_tools packages, formats and mounts the shared volume at /srv/shared, and exports that path over NFS to the private subnet. cloud-init/nfs-client.yaml runs on the bastion and the remaining dev VMs: it installs the dev_tools packages and mounts /srv/shared from the first dev VM.

Resources, parameters, and variables
Provisions
Parameterized by
Variables
  • dev_vm_count=2
  • flavor_name="s1a.medium"
  • shared_volume_size=100
  • image_name="Ubuntu-24.04"
  • dev_tools=["build-essential", "git", "curl", "wget", "vim", "python3", "python3-pip", "jq", "unzip", "tmux"]
  • key_namerequired
  • external_network="PublicEphemeral"
  • private_cidr="192.168.30.0/24"
  • bastion_flavor="s1a.small"

Customize this pattern#

See also#

Usage Guidelines

The sample code, software libraries, command line tools, proofs of concept, templates, and other related technology on this page (including any of the foregoing that is provided by Quake AI personnel) is provided to you as Quake AI Content under the Quake AI Customer Agreement, or the relevant written agreement between you and Quake AI (whichever applies). Do not use this Quake AI Content in your production accounts, or on production or other critical data. You are responsible for testing, securing, and optimizing the Quake AI Content (such as sample code) as appropriate for production grade use based on your specific quality control practices and standards. Deploying Quake AI Content may incur Quake AI charges for creating or using Quake AI chargeable resources, such as running Compute instances or storing data in Object Storage. Your use is also subject to the Acceptable Use Policy.

For the full policy, see Usage Guidelines.

Last validated: 07.07.2026

Was this page helpful?