Skip to content

Tutorials

Configuration with Ansible

Use Ansible to launch a Linux and a Windows instance with the openstack.cloud collection, find them with a dynamic inventory, and configure a web server on each: over SSH on Linux, and over WinRM on Windows.

Well-Architected

Pillar: Operational excellence. Configuration lives in playbooks you can rerun, review and keep in version control, so every instance is set up the same way.

Also touches: Security and sovereignty (encrypted WinRM, a vaulted password, access limited to your IP) and Reliability (instances you can rebuild and reconfigure from code).

What you'll build

Resource Name Details
Key pair ansible-key Your SSH public key, for Linux
Security group ansible-access SSH (22), WinRM over HTTPS (5986) and HTTP (80) from your IP only
Linux instance ansible-web-01 Ubuntu, v1.c1r2, configured with nginx
Windows instance ansible-win-01 Windows Server, v1.c2r8, configured with IIS

Four playbooks and files do the work:

File What it does
provision.yml Creates the key pair, security group and both instances
openstack.yml A dynamic inventory: asks Breqwatr Cloud which instances exist
configure.yml Installs and configures the web servers
destroy.yml Removes everything provision.yml created

Before you start

  • Completed: Launch your first instances. This tutorial uses the Windows console password reset from 4.1.
  • Quota for 2 instances, 3 vCPUs, 10 GB of memory, 100 GB of storage and 2 floating IPs.
  • Time: about 60 minutes.
  • The code: every playbook and file in this tutorial is also in ansible-configuration/ in learn-projects, the course's code repository, at release v1.2.0.

Ansible runs on macOS and Linux. On Windows, it runs inside WSL, Microsoft's Linux environment for Windows, and you follow the Linux steps there.

Step 1: Install Ansible

Ansible goes into the virtual environment from Install the OpenStack CLI, next to the CLI. The ansible package includes the openstack.cloud, ansible.windows and community.windows collections; pywinrm lets it talk to Windows over WinRM.

Ansible doesn't yet run on Python 3.15, which is why Install the OpenStack CLI uses Python 3.14. If ansible --version fails with an error about dataclasses, the environment was made with a newer Python: recreate it with Python 3.14.

  1. Install WSL with Ubuntu, as in Microsoft's Install WSL, and open the Ubuntu terminal.
  2. Inside Ubuntu, follow the Linux steps in Install the OpenStack CLI, and put your clouds.yaml in ~/.config/openstack/ inside Ubuntu.
  3. With the environment active, install Ansible:

    pip install ansible pywinrm
    ansible --version
    

With the environment active:

pip install ansible pywinrm
ansible --version

With the environment active:

pip install ansible pywinrm
ansible --version

Create a folder called ansible-configuration for the files below, and an SSH key for this tutorial in it:

ssh-keygen -t ed25519 -f ansible-key

Step 2: Set your values

vars.yml holds every value that's yours. Use openstack image list and openstack network list --external for the names.

cloud: <cloud-name>
prefix: ansible
my_ip: "<your-ip>/32"
network: default
external_network: <external-network>
linux_image: <linux-image>
windows_image: <windows-image>
public_key_file: "{{ playbook_dir }}/ansible-key.pub"

Step 3: Write the provisioning playbook

Windows needs a WinRM listener on HTTPS, which Breqwatr's Windows images don't set up on their own. First-boot user data creates one with a self-signed certificate and opens port 5986 in the Windows firewall. Save it as winrm-https.ps1:

#ps1_sysnative
$cert = New-SelfSignedCertificate -DnsName $env:COMPUTERNAME -CertStoreLocation Cert:\LocalMachine\My
New-Item -Path WSMan:\localhost\Listener -Transport HTTPS -Address * -CertificateThumbPrint $cert.Thumbprint -Force
New-NetFirewallRule -DisplayName "WinRM over HTTPS" -Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow

provision.yml creates everything with the openstack.cloud collection. Each instance boots from a volume deleted with it (terminate_volume), gets a floating IP, and carries os metadata that the inventory uses to group it.

- name: Provision instances on Breqwatr Cloud
  hosts: localhost
  gather_facts: false
  vars_files:
    - vars.yml
  tasks:
    - name: Add the SSH public key
      openstack.cloud.keypair:
        cloud: "{{ cloud }}"
        name: "{{ prefix }}-key"
        public_key: "{{ lookup('file', public_key_file) }}"

    - name: Create the security group
      openstack.cloud.security_group:
        cloud: "{{ cloud }}"
        name: "{{ prefix }}-access"

    - name: Allow SSH, WinRM over HTTPS and HTTP from your IP address
      openstack.cloud.security_group_rule:
        cloud: "{{ cloud }}"
        security_group: "{{ prefix }}-access"
        protocol: tcp
        port_range_min: "{{ item }}"
        port_range_max: "{{ item }}"
        remote_ip_prefix: "{{ my_ip }}"
      loop: [22, 5986, 80]

    - name: Launch the Linux instance
      openstack.cloud.server:
        cloud: "{{ cloud }}"
        name: "{{ prefix }}-web-01"
        image: "{{ linux_image }}"
        flavor: v1.c1r2
        boot_from_volume: true
        volume_size: 20
        terminate_volume: true
        key_name: "{{ prefix }}-key"
        network: "{{ network }}"
        security_groups: [default, "{{ prefix }}-access"]
        floating_ip_pools: ["{{ external_network }}"]
        metadata:
          os: linux
        wait: true
        timeout: 900

    - name: Launch the Windows instance
      openstack.cloud.server:
        cloud: "{{ cloud }}"
        name: "{{ prefix }}-win-01"
        image: "{{ windows_image }}"
        flavor: v1.c2r8
        boot_from_volume: true
        volume_size: 80
        terminate_volume: true
        network: "{{ network }}"
        security_groups: [default, "{{ prefix }}-access"]
        floating_ip_pools: ["{{ external_network }}"]
        userdata: "{{ lookup('file', 'winrm-https.ps1') }}"
        metadata:
          os: windows
        wait: true
        timeout: 900

Run it:

ansible-playbook provision.yml

The Windows instance takes several minutes to build, which is why both tasks wait up to 900 seconds. The playbook is safe to run again: it leaves alone what already exists.

Step 4: Find the instances

openstack.yml is an inventory that asks Breqwatr Cloud for the instances in your project and groups them by their os metadata, as os_linux and os_windows. Each host's address is its floating IP.

plugin: openstack.cloud.openstack
only_clouds:
  - <cloud-name>
expand_hostvars: false
fail_on_errors: true
keyed_groups:
  - key: openstack.metadata.os
    prefix: os
ansible-inventory -i openstack.yml --graph

The graph shows ansible-web-01 under @os_linux and ansible-win-01 under @os_windows. It also lists any other instances in the project; the playbooks below only touch the two os_ groups.

Step 5: Tell Ansible how to connect

Connection settings for each group go in group_vars.

group_vars/os_linux/vars.yml connects over SSH with your key:

ansible_user: ubuntu
ansible_ssh_private_key_file: "{{ playbook_dir }}/ansible-key"
ansible_ssh_common_args: "-o StrictHostKeyChecking=accept-new"

group_vars/os_windows/vars.yml connects over WinRM on HTTPS. The certificate is self-signed, so Ansible doesn't validate it; the connection is still encrypted.

ansible_connection: winrm
ansible_port: 5986
ansible_winrm_scheme: https
ansible_winrm_transport: ntlm
ansible_winrm_server_cert_validation: ignore
ansible_user: Administrator
ansible_password: "{{ vault_windows_password }}"

Windows needs its Administrator password set before Ansible can sign in. Set it on the console as in 4.1: open the console (openstack --os-cloud <cloud-name> console url show ansible-win-01), choose Send CtrlAltDel, and set a new password when asked. Then store it, encrypted, with Ansible Vault:

ansible-vault create group_vars/os_windows/vault.yml

Choose a vault password when asked. In the editor that opens, add one line with the Administrator password, then save:

vault_windows_password: "<administrator-password>"

Check that Ansible can reach both instances:

ansible -i openstack.yml os_linux -m ping
ansible -i openstack.yml os_windows -m ansible.windows.win_ping --ask-vault-pass

Each instance answers pong.

Step 6: Configure the web servers

configure.yml installs nginx on Linux and IIS on Windows, publishes a page on each, and opens HTTP in the Windows firewall:

- name: Configure Linux web servers
  hosts: os_linux
  become: true
  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        update_cache: true

    - name: Publish a page
      ansible.builtin.copy:
        content: "Configured by Ansible on {{ inventory_hostname }}\n"
        dest: /var/www/html/index.html

- name: Configure Windows web servers
  hosts: os_windows
  tasks:
    - name: Install IIS
      ansible.windows.win_feature:
        name: Web-Server
        include_management_tools: true

    - name: Publish a page
      ansible.windows.win_copy:
        content: "Configured by Ansible on {{ inventory_hostname }}\r\n"
        dest: C:\inetpub\wwwroot\index.html

    - name: Allow HTTP through the Windows firewall
      community.windows.win_firewall_rule:
        name: HTTP in
        localport: 80
        protocol: tcp
        direction: in
        action: allow
ansible-playbook -i openstack.yml configure.yml --ask-vault-pass

Run it a second time. Every task reports ok instead of changed: Ansible changes only what differs from the playbook.

Check it worked

Open each instance's floating IP in a browser, or:

curl http://<web-01-address>/
curl http://<win-01-address>/

Each returns "Configured by Ansible on" followed by the instance's name.

Clean up

destroy.yml removes the instances with their root volumes and floating IPs, then the security group and the key pair:

- name: Remove everything provision.yml created
  hosts: localhost
  gather_facts: false
  vars_files:
    - vars.yml
  tasks:
    - name: Delete the instances, with their root volumes and floating IPs
      openstack.cloud.server:
        cloud: "{{ cloud }}"
        name: "{{ item }}"
        state: absent
        delete_ips: true
        wait: true
      loop: ["{{ prefix }}-web-01", "{{ prefix }}-win-01"]

    - name: Delete the security group
      openstack.cloud.security_group:
        cloud: "{{ cloud }}"
        name: "{{ prefix }}-access"
        state: absent

    - name: Delete the key pair
      openstack.cloud.keypair:
        cloud: "{{ cloud }}"
        name: "{{ prefix }}-key"
        state: absent
ansible-playbook destroy.yml

If something goes wrong

Message What to check
ansible --version fails with an error about dataclasses Your Python is newer than Ansible supports. Recreate the environment with Python 3.14
"A worker was found in a dead state" on macOS Run export no_proxy='*' in the terminal, then rerun the command. It stops Python looking up macOS proxy settings in Ansible's worker processes
"Timeout waiting for the server to come up" The Windows build took longer than timeout. Wait until openstack server list shows it ACTIVE, then rerun provision.yml
WinRM: "the specified credentials were rejected" The vault holds a different password from the one set on the console, or the password hasn't been set yet
SSH or WinRM can't connect Check your IP address in vars.yml is still current, and that the Windows first boot has finished

What you learned

  • The openstack.cloud collection builds resources through the same APIs as the CLI and OpenTofu; see GUI, API and infrastructure as code.
  • A dynamic inventory finds instances by what they are, not by hard-coded addresses.
  • One playbook configures Linux over SSH and Windows over WinRM, and running it again changes nothing that's already right.
  • Secrets such as the Windows password go in Ansible Vault, not in plain text; see Security and sovereignty.

Next