TL;DR
- In this post, we build a Polycrate workspace that manages on-premise servers and Azure VMs in a shared inventory—including Resource Groups, VNet/Subnet, and virtual machines (Linux & Windows).
- Azure resources (Resource Group, VNet, VMs) are provisioned with Ansible modules from
azure.azcollection, encapsulated in Polycrate blocks—no local Python or Ansible installation required. - A dynamic inventory playbook writes newly provisioned Azure VMs into the central
inventory.ymlso you can continue withpolycrate ssh(Linux) or WinRM (Windows) immediately afterward. - Additional blocks cover backups/artifacts in Azure Blob Storage and cost control via stop/start patterns for non-production VMs.
- ayedo supports you with proven Polycrate workspaces and Azure building blocks—from the first demo to enterprise-wide hybrid automation.
Azure as a Hybrid Target for Polycrate
Many teams sit somewhere between traditional data centers and the cloud: hundreds of Linux servers on-premise, Active Directory on Windows Server, plus some workloads in Azure. This is where Polycrate shines:
- One workspace manages on-premise servers and Azure VMs together.
- Ansible always runs in the container—no local Python conflicts, no version drift across the team.
- Blocks structure your automation; each Azure concern (network, VMs, cost, backups) lives in its own block.
The Azure integration uses the Ansible integration in Polycrate. The Azure modules (azure.azcollection) run inside the Polycrate container and talk to the Azure APIs directly (hosts: localhost, connection: local is correct—you are not connecting to a VM via SSH/WinRM, only the Azure control plane).
A central goal of this post: the dynamic inventory. Newly created Azure VMs land automatically in inventory.yml in the workspace root—alongside your on-premise hosts—so you can reuse the same Ansible roles you already use on-premise.
Workspace layout: on-premise + Azure in one setup
First we define a minimal workspace with on-premise hosts and Azure blocks.
# workspace.poly
name: acme-corp-automation
organization: acme
blocks:
- name: azure-infra
from: registry.acme-corp.com/acme/infra/azure-infra:0.1.0
config:
subscription_id: "00000000-0000-0000-0000-000000000000"
location: "westeurope"
resource_group: "rg-acme-hybrid"
vnet_name: "vnet-acme-hybrid"
address_prefix: "10.50.0.0/16"
- name: azure-vms
from: registry.acme-corp.com/acme/infra/azure-vms:0.1.0
config:
subscription_id: "00000000-0000-0000-0000-000000000000"
location: "westeurope"
resource_group: "rg-acme-hybrid"
vnet_name: "vnet-acme-hybrid"
subnet_name: "default"
admin_username: "acmeadmin"
- name: azure-cost-control
from: registry.acme-corp.com/acme/infra/azure-cost-control:0.1.0
config:
subscription_id: "00000000-0000-0000-0000-000000000000"
resource_group: "rg-acme-hybrid"
tag_filter: "env=dev"
- name: azure-backup
from: registry.acme-corp.com/acme/infra/azure-backup:0.1.0
config:
subscription_id: "00000000-0000-0000-0000-000000000000"
resource_group: "rg-acme-backup"
storage_account: "acmehybridbackup"
Blocks are not loose blocks/<name> directories in the workspace; they are pulled via from: from an OCI registry (registry.acme-corp.com/... is a fictional example; in practice use polycrate blocks pull … or your own registry). from: contains the full reference including a version tag. There is no Jinja2 in workspace.poly or block.poly—paths to files under artifacts/secrets/ are literal strings in the block config; the local admin password for Azure Windows VMs belongs in secrets.poly like in the hybrid post (merged into block.config). See Configuration.
secrets.poly (excerpt, merged with workspace.poly):
blocks:
- name: azure-vms
config:
win_admin_password: "…"
Our shared inventory lives in the workspace root as inventory.yml. As in the multi-server post and the hybrid post, every host appears once under all.hosts; under children we assign onprem_linux, onprem_windows, azure_linux, and azure_windows—so Ansible and polycrate ssh share the same canonical host list. Initially we only list on-premise servers; the Azure groups stay empty until the inventory action runs:
# inventory.yml (workspace root)
all:
vars:
ansible_ssh_common_args: "-o StrictHostKeyChecking=no"
ansible_python_interpreter: /usr/bin/python3
hosts:
server01.acme-corp.com:
ansible_user: ubuntu
dc01.acme-corp.com: {}
children:
onprem_linux:
hosts:
server01.acme-corp.com:
onprem_windows:
hosts:
dc01.acme-corp.com:
vars:
ansible_connection: winrm
ansible_winrm_transport: ntlm
ansible_winrm_server_cert_validation: ignore
azure_linux:
hosts: {}
azure_windows:
hosts: {}
vars:
ansible_connection: winrm
ansible_winrm_transport: ntlm
ansible_winrm_server_cert_validation: ignore
Polycrate sets ANSIBLE_INVENTORY to this file automatically. More detail: Best Practices.
Azure basics: Resource Group and network as a block
Block definition for Azure infrastructure
The first block handles the Resource Group and Virtual Network.
# blocks/registry.acme-corp.com/acme/infra/azure-infra/block.poly
# name = from: without tag (full registry path)
name: registry.acme-corp.com/acme/infra/azure-infra
version: 0.1.0
kind: generic
config:
azure_credentials_path: "artifacts/secrets/azure-credentials.json"
subscription_id: ""
location: "westeurope"
resource_group: "rg-acme-hybrid"
vnet_name: "vnet-acme-hybrid"
address_prefix: "10.50.0.0/16"
subnet_name: "default"
subnet_prefix: "10.50.1.0/24"
actions:
- name: provision
driver: ansible
playbook: provision.yml
Azure credentials (e.g. a service principal JSON file) are stored encrypted under artifacts/secrets/azure-credentials.json. Encryption uses age in Polycrate—see Workspace encryption.
Ansible playbook for Resource Group and VNet
# …/azure-infra/provision.yml
- name: Provision Azure Resource Group and network
hosts: localhost
connection: local
gather_facts: false
vars:
azure_credentials_path: "{{ block.config.azure_credentials_path }}"
subscription_id: "{{ block.config.subscription_id }}"
location: "{{ block.config.location }}"
resource_group: "{{ block.config.resource_group }}"
vnet_name: "{{ block.config.vnet_name }}"
address_prefix: "{{ block.config.address_prefix }}"
subnet_name: "{{ block.config.subnet_name }}"
subnet_prefix: "{{ block.config.subnet_prefix }}"
pre_tasks:
- name: Load Azure credentials
set_fact:
azure_credentials: "{{ lookup('file', azure_credentials_path) | from_json }}"
tasks:
- name: Create Resource Group
azure.azcollection.azure_rm_resourcegroup:
subscription_id: "{{ subscription_id }}"
tenant: "{{ azure_credentials.tenant }}"
client_id: "{{ azure_credentials.client_id }}"
secret: "{{ azure_credentials.client_secret }}"
name: "{{ resource_group }}"
location: "{{ location }}"
- name: Create Virtual Network
azure.azcollection.azure_rm_virtualnetwork:
subscription_id: "{{ subscription_id }}"
tenant: "{{ azure_credentials.tenant }}"
client_id: "{{ azure_credentials.client_id }}"
secret: "{{ azure_credentials.client_secret }}"
resource_group: "{{ resource_group }}"
name: "{{ vnet_name }}"
address_prefixes:
- "{{ address_prefix }}"
- name: Create subnet
azure.azcollection.azure_rm_subnet:
subscription_id: "{{ subscription_id }}"
tenant: "{{ azure_credentials.tenant }}"
client_id: "{{ azure_credentials.client_id }}"
secret: "{{ azure_credentials.client_secret }}"
resource_group: "{{ resource_group }}"
name: "{{ subnet_name }}"
address_prefix: "{{ subnet_prefix }}"
virtual_network_name: "{{ vnet_name }}"
Run:
polycrate run azure-infra provision
With plain Ansible you would install azure.azcollection locally, align Python versions, and tune ansible.cfg. With Polycrate this playbook runs reproducibly in the container—including the right Azure modules.
Provisioning virtual machines: Linux and Windows
Block for Azure VMs
# blocks/registry.acme-corp.com/acme/infra/azure-vms/block.poly
name: registry.acme-corp.com/acme/infra/azure-vms
version: 0.1.0
kind: generic
config:
azure_credentials_path: "artifacts/secrets/azure-credentials.json"
subscription_id: ""
location: "westeurope"
resource_group: "rg-acme-hybrid"
vnet_name: "vnet-acme-hybrid"
subnet_name: "default"
admin_username: "acmeadmin"
ssh_public_key_path: "artifacts/secrets/azure-ssh.pub"
actions:
- name: provision
driver: ansible
playbook: provision.yml
- name: inventory
driver: ansible
playbook: inventory.yml
The Windows admin password for VM creation comes from secrets.poly (see above) and is available in the playbook after merge as block.config.win_admin_password.
Playbook: create VMs
# …/azure-vms/provision.yml
- name: Provision Azure Linux and Windows VMs
hosts: localhost
connection: local
gather_facts: false
vars:
azure_credentials_path: "{{ block.config.azure_credentials_path }}"
subscription_id: "{{ block.config.subscription_id }}"
location: "{{ block.config.location }}"
resource_group: "{{ block.config.resource_group }}"
vnet_name: "{{ block.config.vnet_name }}"
subnet_name: "{{ block.config.subnet_name }}"
admin_username: "{{ block.config.admin_username }}"
ssh_public_key: "{{ lookup('file', block.config.ssh_public_key_path) }}"
win_admin_password: "{{ block.config.win_admin_password }}"
linux_vms:
- name: "vm-ubuntu-01"
size: "Standard_B2s"
windows_vms:
- name: "vm-win-01"
size: "Standard_B2s"
pre_tasks:
- name: Load Azure credentials
set_fact:
azure_credentials: "{{ lookup('file', azure_credentials_path) | from_json }}"
tasks:
- name: Create Linux VMs
azure.azcollection.azure_rm_virtualmachine:
subscription_id: "{{ subscription_id }}"
tenant: "{{ azure_credentials.tenant }}"
client_id: "{{ azure_credentials.client_id }}"
secret: "{{ azure_credentials.client_secret }}"
resource_group: "{{ resource_group }}"
name: "{{ item.name }}"
vm_size: "{{ item.size }}"
admin_username: "{{ admin_username }}"
ssh_password_enabled: false
ssh_public_keys:
- path: "/home/{{ admin_username }}/.ssh/authorized_keys"
key_data: "{{ ssh_public_key }}"
image:
offer: "0001-com-ubuntu-server-jammy"
publisher: "Canonical"
sku: "22_04-lts"
version: "latest"
os_type: "Linux"
state: present
started: true
network_interfaces:
- name: "{{ item.name }}-nic"
virtual_network: "{{ vnet_name }}"
subnet: "{{ subnet_name }}"
public_ip_allocation_method: "Dynamic"
loop: "{{ linux_vms }}"
- name: Create Windows VMs
azure.azcollection.azure_rm_virtualmachine:
subscription_id: "{{ subscription_id }}"
tenant: "{{ azure_credentials.tenant }}"
client_id: "{{ azure_credentials.client_id }}"
secret: "{{ azure_credentials.client_secret }}"
resource_group: "{{ resource_group }}"
name: "{{ item.name }}"
vm_size: "{{ item.size }}"
admin_username: "azureadmin"
admin_password: "{{ win_admin_password }}"
image:
offer: "WindowsServer"
publisher: "MicrosoftWindowsServer"
sku: "2022-Datacenter"
version: "latest"
os_type: "Windows"
state: present
started: true
network_interfaces:
- name: "{{ item.name }}-nic"
virtual_network: "{{ vnet_name }}"
subnet: "{{ subnet_name }}"
public_ip_allocation_method: "Dynamic"
loop: "{{ windows_vms }}"
Run:
polycrate run azure-vms provision
All modules come from azure.azcollection. Polycrate supplies the toolchain in the container—you do not maintain pip install or ansible-galaxy collection install on your laptop.
Dynamic inventory from Azure
Now the interesting part: newly created VMs should land in inventory.yml—in the azure_linux and azure_windows groups.
Inventory update as an action
# …/azure-vms/inventory.yml
- name: Write Azure VMs to inventory
hosts: localhost
connection: local
gather_facts: false
vars:
azure_credentials_path: "{{ block.config.azure_credentials_path }}"
subscription_id: "{{ block.config.subscription_id }}"
resource_group: "{{ block.config.resource_group }}"
pre_tasks:
- name: Load Azure credentials
set_fact:
azure_credentials: "{{ lookup('file', azure_credentials_path) | from_json }}"
tasks:
- name: Gather VM information
azure.azcollection.azure_rm_virtualmachine_info:
subscription_id: "{{ subscription_id }}"
tenant: "{{ azure_credentials.tenant }}"
client_id: "{{ azure_credentials.client_id }}"
secret: "{{ azure_credentials.client_secret }}"
resource_group: "{{ resource_group }}"
register: vm_info
- name: Build host lists for inventory
set_fact:
linux_hosts: "{{ vm_info.virtual_machines | selectattr('os_type', 'equalto', 'Linux') | list }}"
windows_hosts: "{{ vm_info.virtual_machines | selectattr('os_type', 'equalto', 'Windows') | list }}"
- name: Render new inventory
template:
src: inventory.j2
dest: "{{ workspace.path }}/inventory.yml"
Use {{ workspace.path }}/inventory.yml for dest: in Polycrate Ansible, workspace.path is the workspace root—more stable than relative paths like ../../inventory.yml, which depend on how deep the block tree is.
Template in the same block:
# …/azure-vms/inventory.j2
all:
vars:
ansible_ssh_common_args: "-o StrictHostKeyChecking=no"
ansible_python_interpreter: /usr/bin/python3
hosts:
server01.acme-corp.com:
ansible_user: ubuntu
dc01.acme-corp.com: {}
{% for vm in linux_hosts %}
{{ vm.name }}:
ansible_host: {{ vm.public_ip_address }}
ansible_user: {{ block.config.admin_username }}
{% endfor %}
{% for vm in windows_hosts %}
{{ vm.name }}:
ansible_host: {{ vm.public_ip_address }}
ansible_user: azureadmin
{% endfor %}
children:
onprem_linux:
hosts:
server01.acme-corp.com:
onprem_windows:
hosts:
dc01.acme-corp.com:
vars:
ansible_connection: winrm
ansible_winrm_transport: ntlm
ansible_winrm_server_cert_validation: ignore
azure_linux:
hosts:
{% for vm in linux_hosts %}
{{ vm.name }}:
{% endfor %}
azure_windows:
hosts:
{% for vm in windows_hosts %}
{{ vm.name }}:
{% endfor %}
vars:
ansible_connection: winrm
ansible_winrm_transport: ntlm
ansible_winrm_server_cert_validation: ignore
For SSH to Azure Linux VMs, Polycrate sets ANSIBLE_PRIVATE_KEY_FILE (among other env vars) in the action container—do not put ansible_ssh_private_key_file in the inventory. WinRM passwords for Azure Windows hosts do not belong in the YAML file; keep them in secrets.poly / block.config and use them in playbooks (see the hybrid post).
Run:
polycrate run azure-vms inventory
After this action, the inventory covers both on-premise hosts and Azure VMs. Compared with a classic dynamic Ansible inventory, the result is a YAML file in the workspace—versionable in Git and readable by any tool that consumes inventory.yml.
Next: Polycrate SSH and WinRM
Linux VMs:
polycrate ssh vm-ubuntu-01
Windows: use regular Ansible playbooks over WinRM, e.g. with a block from the registry:
# workspace.poly (excerpt)
blocks:
- name: windows-hardening
from: registry.acme-corp.com/acme/infra/windows-hardening:0.1.0
config: {}
polycrate run windows-hardening cis-baseline --limit vm-win-01
More on SSH: SSH with Polycrate.
Backups and artifacts in Azure Blob Storage
Many backup tools treat Azure Blob Storage like an S3-style target even though the API differs. With Ansible we use native Azure modules.
Block for Blob storage
# blocks/registry.acme-corp.com/acme/infra/azure-backup/block.poly
name: registry.acme-corp.com/acme/infra/azure-backup
version: 0.1.0
kind: generic
config:
azure_credentials_path: "artifacts/secrets/azure-credentials.json"
subscription_id: ""
resource_group: "rg-acme-backup"
storage_account: "acmehybridbackup"
container_name: "artifacts"
actions:
- name: prepare
driver: ansible
playbook: prepare.yml
- name: upload-artifact
driver: ansible
playbook: upload.yml
prepare.yml creates the storage account and container:
# …/azure-backup/prepare.yml
- name: Prepare Azure Blob Storage
hosts: localhost
connection: local
gather_facts: false
vars:
azure_credentials_path: "{{ block.config.azure_credentials_path }}"
subscription_id: "{{ block.config.subscription_id }}"
resource_group: "{{ block.config.resource_group }}"
storage_account: "{{ block.config.storage_account }}"
container_name: "{{ block.config.container_name }}"
pre_tasks:
- name: Load Azure credentials
set_fact:
azure_credentials: "{{ lookup('file', azure_credentials_path) | from_json }}"
tasks:
- name: Ensure backup Resource Group
azure.azcollection.azure_rm_resourcegroup:
subscription_id: "{{ subscription_id }}"
tenant: "{{ azure_credentials.tenant }}"
client_id: "{{ azure_credentials.client_id }}"
secret: "{{ azure_credentials.client_secret }}"
name: "{{ resource_group }}"
location: "westeurope"
- name: Create Storage Account
azure.azcollection.azure_rm_storageaccount:
subscription_id: "{{ subscription_id }}"
tenant: "{{ azure_credentials.tenant }}"
client_id: "{{ azure_credentials.client_id }}"
secret: "{{ azure_credentials.client_secret }}"
resource_group: "{{ resource_group }}"
name: "{{ storage_account }}"
kind: "StorageV2"
sku: "Standard_LRS"
- name: Create blob container
azure.azcollection.azure_rm_storageblob:
subscription_id: "{{ subscription_id }}"
tenant: "{{ azure_credentials.tenant }}"
client_id: "{{ azure_credentials.client_id }}"
secret: "{{ azure_credentials.client_secret }}"
resource_group: "{{ resource_group }}"
storage_account_name: "{{ storage_account }}"
container: "{{ container_name }}"
state: present
A simple upload playbook could push local artifacts from artifacts/ into the container. Once storage is ready, external tools with S3-compatible endpoints can use gateways or backup software.
Cost control: stop and start VMs
For smaller teams, cost is often a key reason to use Azure cautiously. A practical pattern: stop non-production VMs overnight and start them in the morning.
Block for cost control
# blocks/registry.acme-corp.com/acme/infra/azure-cost-control/block.poly
name: registry.acme-corp.com/acme/infra/azure-cost-control
version: 0.1.0
kind: generic
config:
azure_credentials_path: "artifacts/secrets/azure-credentials.json"
subscription_id: ""
resource_group: "rg-acme-hybrid"
tag_filter: "env=dev"
actions:
- name: stop
driver: ansible
playbook: stop.yml
- name: start
driver: ansible
playbook: start.yml
stop.yml can shut down all VMs with a given tag:
# …/azure-cost-control/stop.yml
- name: Stop dev VMs
hosts: localhost
connection: local
gather_facts: false
vars:
azure_credentials_path: "{{ block.config.azure_credentials_path }}"
subscription_id: "{{ block.config.subscription_id }}"
resource_group: "{{ block.config.resource_group }}"
tag_filter: "{{ block.config.tag_filter }}"
pre_tasks:
- name: Load Azure credentials
set_fact:
azure_credentials: "{{ lookup('file', azure_credentials_path) | from_json }}"
tasks:
- name: List candidate VMs
azure.azcollection.azure_rm_virtualmachine_info:
subscription_id: "{{ subscription_id }}"
tenant: "{{ azure_credentials.tenant }}"
client_id: "{{ azure_credentials.client_id }}"
secret: "{{ azure_credentials.client_secret }}"
resource_group: "{{ resource_group }}"
register: vm_info
- name: Filter VMs by tag
set_fact:
target_vms: >-
{{ vm_info.virtual_machines
| selectattr('tags', 'defined')
| selectattr('tags.env', 'equalto', 'dev')
| list }}
- name: Stop dev VMs
azure.azcollection.azure_rm_virtualmachine:
subscription_id: "{{ subscription_id }}"
tenant: "{{ azure_credentials.tenant }}"
client_id: "{{ azure_credentials.client_id }}"
secret: "{{ azure_credentials.client_secret }}"
resource_group: "{{ resource_group }}"
name: "{{ item.name }}"
state: present
started: false
loop: "{{ target_vms }}"
start.yml is analogous with started: true.
A cron job or small CI job can run:
polycrate run azure-cost-control stop
at 20:00 and
polycrate run azure-cost-control start
at 07:00. Polycrate keeps the automation consistent—on an admin laptop, a build agent, or a central automation host.
FAQ
How is this different from plain Ansible with Azure?
With plain Ansible you would:
- install Python and
azure.azcollectionlocally, - maintain
ansible.cfgand credentials per workstation, - configure and run a separate dynamic inventory with Azure plugins.
With Polycrate you get:
- a containerized toolchain (Ansible, Python, Azure modules) per workspace,
- structured blocks with clear playbook boundaries,
- a single YAML inventory maintained by actions.
That reduces friction and helps compliance because you can see which block runs which action.
How safe is storing Azure credentials and SSH keys in the workspace?
Sensitive files (e.g. azure-credentials.json, SSH keys, passwords) live under artifacts/secrets/ and are encrypted with age. Polycrate includes this workflow so you do not need a separate secret store. Details: Workspace encryption.
Can I share these blocks with other teams?
Yes. Blocks such as azure-infra, azure-vms, or azure-cost-control can be versioned and published as OCI images to a registry. With PolyHub (see PolyHub documentation) you can share them across teams or publicly.
In practice: one team builds solid Azure blocks; others reference them with from: cargo.ayedo.cloud/...:0.1.0 (or your registry)—with versioning and explicit config parameters.
More questions? See our FAQ.
Next steps
With this setup you have:
- a Polycrate workspace that keeps on-premise and Azure hosts in one
inventory.yml, - Azure foundation (Resource Group, VNet/subnet) in a reusable block,
- Linux and Windows VMs provisioned with
azure.azcollection.azure_rm_virtualmachine, - a dynamic inventory that makes new VMs available for further Ansible roles immediately,
- extra blocks for Azure Blob backups and simple cost control via stop/start actions.
You move away from a sprawl of loose Ansible playbooks toward structured, reusable automation—no local dependency drift, encrypted secrets, and clear guardrails from the block model. That combination is what makes Polycrate a strong hybrid framework: on-premise servers, Azure VMs, container workloads—one operational model.
If you want to evaluate this for your environment, ayedo can help with:
- Azure blocks aligned to Best Practices,
- joint sessions to migrate your Ansible estate into a Polycrate workspace,
- a tailored demo aligned with your on-premise and Azure requirements.
Dig deeper on inventories and hybrid setups: Multi-server management and Hybrid automation. Formats with ayedo: Workshops.
Polycrate brings on-premise and Azure together in one workspace: a single operational model for hybrid teams.