☁ Azure · Terraform · Ansible

Provisioning a Guacamole Server on Azure
with Terraform & Ansible

How I automated the full deployment of a remote access server on Azure — from VNet and NSG creation in Terraform to Guacamole configuration via Ansible. Everything in code, nothing clicked in the portal.

✍ Gurjot Singh · April 2026 · 12 min read · Terraform · Azure · Ansible · Guacamole
View on GitHub — sing0978/azure-terraform
Table of Contents

Why I Automated This

At Pythian I maintained Apache Guacamole for our distributed team — a browser-based remote access gateway that lets people RDP and SSH into internal machines without a VPN client. For a long time the setup lived on a manually configured VM. No IaC, no documentation, just tribal knowledge and a prayer that nothing breaks.

When I needed to rebuild the environment, I decided to do it right: Terraform for infrastructure provisioning, Ansible for configuration management. Everything declarative, everything in version control, everything repeatable in minutes.

This post walks through the exact code in my azure-terraform repo — what each file does, why I made certain decisions, and what I'd change next time.

If you can't rebuild it from scratch in under 30 minutes, you don't own it — your documentation does. And if you have no documentation, nobody owns it.
Terraform Azure Ansible Apache Guacamole Ubuntu 22.04 NSG IaC

Architecture Overview

The deployment is intentionally simple — a single Ubuntu VM inside a dedicated VNet, locked down at the network layer with NSG rules, and running Guacamole as the only public-facing service.

Terraform
provisions infra
Azure
RG · VNet · Subnet · NSG · VM · Public IP
Ansible
installs & configures Guacamole
Guacamole
running on port 8080
Terraform creates the VM → Ansible configures it → Guacamole serves remote access via browser

Key decisions baked into the design:

Repo Structure

azure-terraform/ ├── main.tf # provider, resource group, VNet, subnet ├── vm.tf # VM, public IP, network interface ├── nsg.tf # network security group + subnet association ├── .terraform.lock.hcl # provider version lock ├── .gitignore # excludes terraform.tfstate from git └── ansible/ └── *.yml / *.j2 # Guacamole install & config playbooks
⚠ Note on tfstate The terraform.tfstate file is committed to this repo for learning purposes. In production, always store state remotely — Azure Blob Storage with a backend config is the standard approach for Azure deployments.
1
Provider, Resource Group & Networking — main.tf

The foundation. This file pins the AzureRM provider version, creates a dedicated resource group called guacamolerg, and builds the network topology the VM will live in.

main.tf
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.0.2"
    }
  }
  required_version = ">= 1.1.0"
}

provider "azurerm" {
  features {}
}

resource "azurerm_resource_group" "rg" {
  name     = "guacamolerg"
  location = "westus2"
}

resource "azurerm_virtual_network" "vnet" {
  name                = "guacamolevnet"
  address_space       = ["10.0.0.0/16"]
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name
}

resource "azurerm_subnet" "guacsubnet" {
  name                 = "guacsubnet"
  resource_group_name  = azurerm_resource_group.rg.name
  virtual_network_name = azurerm_virtual_network.vnet.name
  address_prefixes     = ["10.0.1.0/24"]
}
terraform

A few things worth noting: the provider version is pinned to ~> 3.0.2 which allows patch updates but prevents breaking changes from minor version bumps. The VNet uses a /16 address space with a single /24 subnet — plenty of room to add more subnets later if needed.

💡 Tip Naming resources with the environment/purpose prefix (guacamole*) makes them easy to find in the Azure portal and easy to bulk-delete when tearing down. Always namespace your resources.
2
VM & Public IP — vm.tf

This is where the actual machine gets created. A few deliberate choices here that are worth explaining.

vm.tf
resource "azurerm_public_ip" "pubip" {
  name                = "acceptanceTestPublicIp1"
  sku                 = "Standard"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  allocation_method   = "Static"

  tags = {
    environment = "Production"
  }
}

resource "azurerm_linux_virtual_machine" "rg" {
  name                = "rg-machine"
  resource_group_name = azurerm_resource_group.rg.name
  location            = azurerm_resource_group.rg.location
  size                = "Standard_F2"
  admin_username      = "guacadmin"

  network_interface_ids = [
    azurerm_network_interface.nic.id,
  ]

  custom_data = base64encode(<<-EOF
    #!/bin/bash
    sudo apt update -y
    sudo apt upgrade -y
    sudo apt install xrdp -y
    sudo systemctl enable xrdp
    sudo systemctl start xrdp
  EOF
  )

  admin_ssh_key {
    username   = "guacadmin"
    public_key = file("~/.ssh/id_rsa.pub")
  }

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "Canonical"
    offer     = "0001-com-ubuntu-server-jammy"
    sku       = "22_04-lts"
    version   = "latest"
  }
}
terraform

Why Standard_F2?

The F-series VMs are compute-optimized — a good fit for Guacamole since RDP and SSH sessions are CPU-intensive rather than memory-heavy. Standard_F2 gives 2 vCPUs and 4GB RAM which comfortably handles 5–10 concurrent sessions.

Why cloud-init for xrdp?

The custom_data block runs a bash script on first boot before Ansible connects. Installing xrdp here means the VM is ready to receive RDP connections the moment Ansible's configuration playbook finishes. It also means if I need to rebuild the VM, xrdp is always there — no manual step, no forgotten dependency.

Why Static IP?

A dynamic IP would change every time the VM restarts. That would break the NSG source IP rules and any DNS records pointing at the machine. Static IP is always the right choice for a server.

3
NSG Lockdown — nsg.tf

The Network Security Group is what keeps this server from being hammered by the internet. Three inbound rules — RDP (3389), SSH (22), and HTTP (8080) — all scoped to a single source IP.

nsg.tf
resource "azurerm_network_security_group" "guacnsg" {
  name                = "guacnsg"
  location            = azurerm_resource_group.rg.location
  resource_group_name = azurerm_resource_group.rg.name

  security_rule {
    name                       = "allow_rdp"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "3389"
    source_address_prefix      = "174.112.119.102/32"
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "allow_ssh"
    priority                   = 101
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "22"
    source_address_prefix      = "174.112.119.102/32"
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "allow_http"
    priority                   = 102
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "8080"
    source_address_prefix      = "174.112.119.102/32"
    destination_address_prefix = "*"
  }

  tags = {
    environment = "guacamole"
  }
}

resource "azurerm_subnet_network_security_group_association" "nsgasso" {
  subnet_id                 = azurerm_subnet.guacsubnet.id
  network_security_group_id = azurerm_network_security_group.guacnsg.id
}
terraform

The /32 prefix on the source IP means only exactly that one IP can reach these ports. Everything else is implicitly denied by Azure's default rules. This is the network security equivalent of allowlisting — far safer than opening ports to 0.0.0.0/0.

🚨 Before you reuse this code Replace 174.112.119.102/32 with your own IP. You can find your current public IP at curl ifconfig.me. If your IP is dynamic (home internet), you'll need to update this rule periodically or use a VPN with a fixed egress IP.

Why associate the NSG with the subnet, not the NIC?

You can attach NSGs to either subnets or individual NICs. Subnet-level association means all VMs added to the subnet automatically inherit the rules — a better default for a dedicated environment like this where every machine should have the same access controls.

4
Ansible: Configure Guacamole

Once Terraform finishes, the VM exists and has xrdp running. Now Ansible takes over to install and configure Guacamole itself — Docker, the guacd container, the web app, database setup, and any custom config.

The ansible/ directory contains Jinja2 templates (.j2 files) for generating Guacamole config files dynamically, and YAML playbooks that run the installation sequence.

A typical playbook flow looks like this:

# ansible/deploy-guacamole.yml (structure)
- name: Deploy Guacamole
  hosts: guacamole_servers
  become: yes
  tasks:
    - name: Install Docker
      apt:
        name: docker.io
        state: present

    - name: Pull guacd image
      docker_image:
        name: guacamole/guacd
        tag: latest

    - name: Pull guacamole image
      docker_image:
        name: guacamole/guacamole
        tag: latest

    - name: Deploy guacd container
      docker_container:
        name: guacd
        image: guacamole/guacd
        restart_policy: always

    - name: Deploy guacamole web container
      docker_container:
        name: guacamole
        image: guacamole/guacamole
        ports:
          - "8080:8080"
        env:
          GUACD_HOSTNAME: "guacd"
          MYSQL_HOSTNAME: "{{ db_host }}"
          MYSQL_DATABASE: "guacamole_db"
          MYSQL_USER: "{{ db_user }}"
          MYSQL_PASSWORD: "{{ db_password }}"
        restart_policy: always
yaml

The Jinja2 templates (.j2 files) generate config files with environment-specific values injected at deploy time — database credentials, hostnames, and connection strings. This keeps secrets out of the playbooks themselves and makes the same code reusable across environments.

💡 Running Ansible after Terraform Get the VM's public IP from Terraform output, add it to your Ansible inventory, then run the playbook. You can wire this together with a local-exec provisioner in Terraform, but I prefer keeping them separate for clearer failure isolation.
# Get the public IP from Terraform
terraform output public_ip_address

# Add to Ansible inventory
echo "[guacamole_servers]
<public_ip> ansible_user=guacadmin ansible_ssh_private_key_file=~/.ssh/id_rsa" > inventory.ini

# Run the playbook
ansible-playbook -i inventory.ini ansible/deploy-guacamole.yml
bash
5
Running It End to End

The full deployment from zero to working Guacamole instance:

# 1. Initialize Terraform (downloads the AzureRM provider)
terraform init

# 2. Preview what will be created
terraform plan

# 3. Apply — creates resource group, VNet, subnet, NSG, VM, public IP
terraform apply

# 4. Get the VM's public IP
terraform output

# 5. Wait ~2 minutes for cloud-init to finish (xrdp install)
# Then run Ansible
ansible-playbook -i inventory.ini ansible/deploy-guacamole.yml

# 6. Access Guacamole
# Open https://<public-ip>:8080/guacamole
# Default: guacadmin / guacadmin — change immediately!
bash

Total time from terraform apply to a working Guacamole login page: roughly 8–12 minutes. Most of that is Azure provisioning the VM. The Ansible playbook itself runs in under 3 minutes.

Lessons Learned

What I'd do differently next time

What worked well

The separation of concerns between Terraform and Ansible worked exactly as intended. Terraform handles the "what infrastructure exists" question — it's great at idempotent resource creation. Ansible handles the "what's running on the server" question — it's great at configuration and package management. They're complementary tools, not competing ones.

The NSG approach also proved its value quickly. During testing I accidentally left a port open to 0.0.0.0/0 on a different project and saw SSH brute-force attempts within 20 minutes. The IP-scoped rules on this deployment meant zero noise in the auth logs.

Gurjot Singh
IT Systems Administrator
Specializing in identity management, Zero Trust security, and infrastructure automation. Currently at Pythian managing enterprise IAM, endpoint security, and IaC deployment pipelines. The full code for this project is on GitHub.
← Back to Blog