# Provision and configure with OpenTofu plus Ansible

Source: https://docs.quake.ai/resources/iac-templates/ansible-provision-and-configure
Markdown: https://docs.quake.ai/resources/iac-templates/ansible-provision-and-configure.md

---

# 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](/docs/automation/how-to/ansible-opentofu-workflow).

<PricingCompanion templateSlug="ansible-provision-and-configure" />

## How to use this template

### Prerequisites

- OpenTofu 1.6+ installed: see [Get started with OpenTofu on Quake AI](/docs/automation/how-to/getting-started-iac).
- Ansible 6+ installed: see [Get started with Ansible on Quake AI](/docs/automation/how-to/getting-started-ansible).
- The `openstack.cloud`, `ansible.posix`, and `community.general` collections installed (see Required collections below).
- [Application credentials](/docs/tools/generate-app-credentials) configured in `~/.config/openstack/clouds.yaml` as a named cloud `rumble`.
- An SSH keypair already uploaded to your Quake AI project. Set its name in `infra/terraform.tfvars`.

### 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](/resources/iac-templates/ansible-configure-vm) template documents the same collection set; one `requirements.yml` covers both workflows.

### File layout

```text
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](/resources/iac-templates/ansible-configure-vm) 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](/resources/iac-templates/ansible-configure-vm) for the baseline tasks the playbook below imports inline.
- Scale to several application VMs and put an [edge reverse proxy](/resources/iac-templates/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](/docs/automation/concepts/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](/resources/iac-templates/ansible-configure-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](/resources/iac-templates/ansible-configure-vm) when the VM already exists, or [Simple VM with Floating IP](/resources/iac-templates/simple-vm) for provision-only.

## Customize this pattern

- [Customize a template's image and flavor](/docs/automation/how-to/customize-template-image-flavor)
- [Add a block volume to a template](/docs/automation/how-to/add-volume-to-template)
- [Parameterize a template with a tfvars file](/docs/automation/how-to/parameterize-template-tfvars)

<TemplateResourceMap template="ansible-provision-and-configure" format="ansible-opentofu" />

## See also

- [How to use Ansible with OpenTofu on Quake AI](/docs/automation/how-to/ansible-opentofu-workflow): conceptual walkthrough of the dynamic-inventory handoff
- [Configure a VM with Ansible](/resources/iac-templates/ansible-configure-vm): the standalone Day 1 template this combined workflow imports
- [Ansible dynamic inventory on Quake AI](/docs/automation/how-to/ansible-dynamic-inventory): keyed-group patterns and inventory caching
- [Ansible openstack.cloud module reference](/reference/automation/ansible-modules): module catalog
- [Simple VM with Floating IP](/resources/iac-templates/simple-vm): the OpenTofu-only equivalent of the provision step
