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.
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.
Key decisions baked into the design:
- Static public IP — the VM gets a predictable IP so DNS and NSG rules don't break on reboot
- NSG scoped to my IP only — RDP, SSH, and HTTP ports are open but only from a single source IP. No brute-force exposure.
- cloud-init for xrdp — xrdp installs automatically on first boot via cloud-init, before Ansible even connects
- SSH key auth only — no password login on the VM
Repo Structure
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.
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.
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.
guacamole*) makes them easy to find in the Azure portal and easy to bulk-delete when tearing down. Always namespace your resources.
This is where the actual machine gets created. A few deliberate choices here that are worth explaining.
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.
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.
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.
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.
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: alwaysyaml
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.
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.ymlbash
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
- Remote state backend — storing
terraform.tfstatein the repo works for learning but breaks down with teams. I'd move to Azure Blob Storage with state locking next time. - Variables file — hardcoded values like region, VM size, and admin username should live in a
variables.tffile. Makes the code reusable without editing main files. - Separate NSG rule for Guacamole — port 8080 should eventually be replaced with HTTPS on 443 via a reverse proxy (NGINX or Azure Application Gateway). HTTP on 8080 is fine for internal access but not for anything internet-facing.
- Ansible Vault for secrets — database passwords in the playbook should be encrypted with
ansible-vaultrather than passed as plain variables. - Terraform outputs — I should have added an
outputs.tffile to surface the public IP automatically rather than looking it up manually.
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.