Skip to content
IaC Templates

Provision and configure with OpenTofu plus Ansible

Template · Updated Jul 2026
Validated Jul 2026

Provision and configure with OpenTofu plus Ansible

This pattern composes Compute and Network.

A combined template that runs OpenTofu to provision a Quake AI VM with a floating IP, then runs Ansible against the new host using the openstack.cloud dynamic inventory plugin. A Makefile sequences the two steps so a single make deploy produces a configured, reachable host.

This is the canonical Day 0 plus Day 1 workflow on Quake AI. For the conceptual rationale and a deeper walkthrough of the inventory handoff, read How to use Ansible with OpenTofu on Quake AI.

Monthly cost estimate

Pricing calculator ↗

Sized as a custom package on dedicated vCPU.

Starting template$61.00/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

m2a.large

m2a.large · 2 dedicated vCPU, 8 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

m2a.large

2 dedicated vCPU, 8 GiB RAM, 0.5 Gbps

$66.00

Compute + RAM rate basis

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

—

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

Dev/test vs production

Start on shared CPU for dev/test, then promote to dedicated for production with a flavor resize. The network, storage, and template stay the same.

Dev/test on shared CPU

Burstable s1a flavors; suited to prototyping and low or bursty load.

$11.50/mo

Production on dedicated CPU

The headline estimate above; predictable steady-load performance.

$61.00/mo

Saves $49.50/mo while you build on shared CPU.

Shared flavors carry less RAM (m2a.large (8 GiB RAM) -> s1a.small (2 GiB RAM)). A resize reboots the instance; data on attached volumes persists. Size the dedicated flavor for the RAM your production workload needs.

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

How to use this template#

Prerequisites#

Required collections#

The configure step uses ansible.posix.authorized_key for the operator key and community.general.ufw for the host firewall, in addition to openstack.cloud for the dynamic inventory plugin. Declare all three in a requirements.yml at the project root:

YAML
collections:
  - name: openstack.cloud
  - name: ansible.posix
    version: ">=1.5.4"
  - name: community.general
    version: ">=8.0.0"

Install them once before the first run:

bash
ansible-galaxy collection install -r requirements.yml

The standalone Configure a VM with Ansible template documents the same collection set; one requirements.yml covers both workflows.

File layout#

provision-and-configure/
  Makefile
  infra/
    main.tf
    outputs.tf
    terraform.tfvars
  inventory/
    openstack.yml
  playbooks/
    site.yml
  group_vars/
    all.yml

Run command#

bash
cd provision-and-configure
make deploy

make deploy runs tofu apply and then ansible-playbook against the dynamic inventory. The Ansible run waits for SSH to come up before starting tasks.

To tear everything down: make destroy.

What this template produces#

  • One Quake AI compute instance (Ubuntu-24.04, flavor m2a.large) with a floating IP and a security group that allows SSH, HTTP, and HTTPS
  • The same configured host the Configure a VM with Ansible template produces: non-root user, hardened SSH, ufw, and nginx serving a placeholder page
  • Instance metadata role: web and environment: dev so future Ansible runs can target the host through the dynamic inventory plugin

How to extend#

  • Scale to a fleet by adding a count = N to the openstack_compute_instance_v2 resource and tagging each instance through metadata.
  • Replace the placeholder configuration with your application's roles/. Reference Configure a VM with Ansible for the baseline tasks the playbook below imports inline.
  • Scale to several application VMs and put an edge reverse proxy in front, then point Ansible's keyed_groups at the application hosts.
  • Move secrets such as the operator email or registry credentials into Ansible Vault and reference them with vars_files.

OpenTofu configuration#

infra/main.tf provisions the instance, security group, floating IP, and the keypair binding. Security groups attach to the Neutron port with security_group_ids; see Authoring IaC templates.

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

provider "openstack" {}

variable "key_name" {
  description = "Name of an existing keypair in the Quake AI project"
  type        = string
}

variable "external_network" {
  description = "Public network used as the floating-IP pool. Discover with `openstack network list --external`."
  type        = string
  default     = "PublicStatic"
}

resource "openstack_networking_secgroup_v2" "web" {
  name        = "ansible-demo-web"
  description = "Allow SSH, HTTP, and HTTPS"
}

resource "openstack_networking_secgroup_rule_v2" "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.web.id
}

resource "openstack_networking_secgroup_rule_v2" "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
}

resource "openstack_networking_secgroup_rule_v2" "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
}

data "openstack_images_image_v2" "ubuntu" {
  name        = "Ubuntu-24.04"
  most_recent = true
}

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

resource "openstack_networking_port_v2" "web" {
  name               = "web-01-port"
  network_id         = data.openstack_networking_network_v2.external.id
  security_group_ids = [openstack_networking_secgroup_v2.web.id]
}

resource "openstack_compute_instance_v2" "web" {
  name        = "web-01"
  flavor_name = "m2a.large"
  key_pair    = var.key_name

  metadata = {
    role        = "web"
    environment = "dev"
  }

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

  network {
    port = openstack_networking_port_v2.web.id
  }
}

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

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

infra/outputs.tf exposes the floating IP so other tooling can read it without parsing state directly.

HCL
output "web_floating_ip" {
  description = "Public IP of the configured web host"
  value       = openstack_networking_floatingip_v2.web.address
}

output "web_instance_id" {
  description = "OpenStack instance ID of the web host"
  value       = openstack_compute_instance_v2.web.id
}

infra/terraform.tfvars sets the keypair name. Replace my-keypair with the keypair you have uploaded to Quake AI.

HCL
key_name = "my-keypair"

Ansible dynamic inventory#

inventory/openstack.yml configures the openstack.cloud.openstack plugin. The plugin queries Quake AI and groups instances by their role and environment metadata, which the OpenTofu configuration sets at create time.

YAML
plugin: openstack.cloud.openstack
clouds:
  - rumble
expand_hostvars: false
inventory_hostname: name
keyed_groups:
  - key: metadata.role
    prefix: role
  - key: metadata.environment
    prefix: env
compose:
  ansible_host: interface_ip
  ansible_user: ubuntu

interface_ip is the address openstacksdk resolves for connecting to the host (the floating IP if attached, otherwise the first fixed IP). The plugin also emits meta-role_web and meta-environment_dev siblings to the role_web and env_dev groups; either set works as a playbook target.

Verify the inventory after tofu apply completes:

bash
ansible-inventory -i inventory/openstack.yml --list
# expect: role_web group containing web-01 with ansible_host set to the floating IP

Group variables#

group_vars/all.yml is the same shape as the standalone configure-vm template; it centralizes the values the playbook references.

YAML
operator_user: deploy
operator_ssh_key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
baseline_packages:
  - curl
  - git
  - vim
  - fail2ban
  - ufw
  - nginx
firewall_allowed_ports:
  - 22
  - 80
  - 443

Site playbook#

playbooks/site.yml runs against the role_web group emitted by the dynamic inventory. The task list is the same as the standalone Configure a VM template; collapse the two playbooks into a shared role if you maintain both.

YAML
---
- name: Wait for SSH on freshly provisioned hosts
  hosts: role_web
  gather_facts: false
  tasks:
    - name: Wait for SSH to accept connections
      ansible.builtin.wait_for:
        host: "{{ ansible_host }}"
        port: 22
        delay: 5
        timeout: 300
      delegate_to: localhost

- name: Configure Quake AI web host
  hosts: role_web
  become: true
  gather_facts: true

  tasks:
    - name: Update apt cache
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600

    - name: Upgrade installed packages
      ansible.builtin.apt:
        upgrade: dist
        autoremove: true

    - name: Install baseline packages
      ansible.builtin.apt:
        name: "{{ baseline_packages }}"
        state: present

    - name: Create non-root sudo user
      ansible.builtin.user:
        name: "{{ operator_user }}"
        groups: sudo
        shell: /bin/bash
        create_home: true
        append: true

    - name: Authorize operator SSH key
      ansible.posix.authorized_key:
        user: "{{ operator_user }}"
        key: "{{ operator_ssh_key }}"
        state: present

    - name: Ensure /run/sshd exists for sshd validate
      ansible.builtin.file:
        path: /run/sshd
        state: directory
        mode: "0755"

    - name: Harden sshd_config
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: "{{ item.regexp }}"
        line: "{{ item.line }}"
        state: present
        validate: sshd -t -f %s
      loop:
        - { regexp: "^#?PermitRootLogin",       line: "PermitRootLogin no" }
        - { regexp: "^#?PasswordAuthentication", line: "PasswordAuthentication no" }
        - { regexp: "^#?MaxAuthTries",          line: "MaxAuthTries 3" }
      notify: Restart sshd

    - name: Set ufw default-deny inbound
      community.general.ufw:
        direction: incoming
        policy: deny

    - name: Allow firewall ports
      community.general.ufw:
        rule: allow
        port: "{{ item }}"
        proto: tcp
      loop: "{{ firewall_allowed_ports }}"

    - name: Enable ufw
      community.general.ufw:
        state: enabled

    - name: Render placeholder index page
      ansible.builtin.copy:
        dest: /var/www/html/index.html
        mode: "0644"
        content: |
          <!doctype html>
          <html>
            <head><title>Provisioned and configured</title></head>
            <body>
              <h1>Hello from Quake AI</h1>
              <p>OpenTofu provisioned this host and Ansible configured it.</p>
            </body>
          </html>

    - name: Ensure nginx is running
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

    - name: Verify nginx responds
      ansible.builtin.uri:
        url: http://127.0.0.1/
        status_code: 200
        return_content: false

  handlers:
    - name: Restart sshd
      ansible.builtin.service:
        name: ssh
        state: restarted

Makefile#

The Makefile is the single entry point. make deploy runs OpenTofu and then Ansible; make configure reruns only the playbook against the existing inventory; make destroy tears everything down.

makefile
.PHONY: init plan apply deploy configure destroy

INFRA := infra
INVENTORY := inventory/openstack.yml
PLAYBOOK := playbooks/site.yml

init:
	tofu -chdir=$(INFRA) init

plan: init
	tofu -chdir=$(INFRA) plan

apply: init
	tofu -chdir=$(INFRA) apply -auto-approve

configure:
	ansible-playbook -i $(INVENTORY) $(PLAYBOOK)

deploy: apply configure

destroy:
	tofu -chdir=$(INFRA) destroy -auto-approve

Run it complete#

bash
cd provision-and-configure
make deploy

Expected output:

  • tofu apply reports a single instance, security group, three security group rules, one floating IP, and one floating-IP association created.
  • ansible-inventory --list (run by the playbook step) groups the instance under role_web with ansible_host set to the floating IP.
  • ansible-playbook reports ok for every task on the first run and ok=N changed=0 on subsequent runs.

After make deploy, the host responds at http://<floating-ip>/. To converge changes after editing the playbook or group_vars/all.yml, run make configure.

When to use this pattern#

Run OpenTofu provisioning and an Ansible playbook from one Makefile-driven workflow. Choose Configure a VM with Ansible when the VM already exists, or Simple VM with Floating IP for provision-only.

Customize this pattern#

Resources, parameters, and variables

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

Quick answers

Was this page helpful?