Development Environment
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/sharedacross 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_toolspackages on every instance (cloud-init concept)
Parameters#
| Parameter | Description | Default |
|---|---|---|
key_name | SSH keypair name (must already exist in your project) | required |
dev_vm_count | Number of development VMs | 2 |
flavor_name | Instance size | s1a.medium |
shared_volume_size | Shared volume in GB | 100 |
image_name | Operating system image | Ubuntu-24.04 |
dev_tools | Packages installed on every instance via cloud-init | ["build-essential", "git", "curl", "wget", "vim", "python3", "python3-pip", "jq", "unzip", "tmux"] |
bastion_flavor | Bastion instance size | s1a.small |
external_network | External network for router SNAT and floating IPs | PublicEphemeral |
private_cidr | IPv4 CIDR for the private tenant network | 192.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.
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
2× Dev
s1a.medium · 4 shared vCPU, 4 GiB RAM, 0.5 Gbps
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
s1a.medium
4 shared vCPU, 4 GiB RAM, 0.5 Gbps
s1a.medium
4 shared vCPU, 4 GiB RAM, 0.5 Gbps
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
Public IP (included)
1 included with the custom package
Package promotional discount
Flat −$5.00/mo on the custom package (same promotion as named plans).
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 morePrivate 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.
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.
Pricing data last validated: . For current rates, check quake.ai/pricing.
Template source#
Show source (8 files)Hide source
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
}
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"
}
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"
}
terraform {
required_version = ">= 1.6.0"
required_providers {
openstack = {
source = "terraform-provider-openstack/openstack"
version = "~> 2.0"
}
}
}
provider "openstack" {}
# 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-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-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
# 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
dev_vm_count=2flavor_name="s1a.medium"shared_volume_size=100image_name="Ubuntu-24.04"dev_tools=["build-essential", "git", "curl", "wget", "vim", "python3", "python3-pip", "jq", "unzip", "tmux"]key_namerequiredexternal_network="PublicEphemeral"private_cidr="192.168.30.0/24"bastion_flavor="s1a.small"
Customize this pattern#
- Customize a template's image and flavor
- Add a block volume to a template
- Parameterize a template with a tfvars file
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
See Also
Terraform and OpenTofu on Quake AI
Prerequisite
Kubernetes Cluster Bootstrap
Shares: Key Pairs, Security Groups
Authoring IaC templates for Quake AI
Shares: Volumes, Security Groups
Deploy an API gateway with the api-gateway template
Shares: Volumes, Security Groups
Deploy the development environment template with OpenTofu
Shares: Volumes, Security Groups