Usually, deploying a phone system is a very manual procedure - you get a physical system from the likes of Avaya, Mitel, Panasonic, or a virtualised solution from Cisco, Avaya or other companies. You plug it in, wait for it to turn on, go through the whole learning process of trying to configure the phone system to your liking (which takes forever if you aren't intimately familiar with the solutions quirks), then finally you have a phone system... weeks after you needed it.
At Lunrox, we automated this procedure from start to finish - with our cloud based phone systems, we make a small change to a git repo, then 5 minutes later it is ready for our customer.
Here's how we did it.
0. Picking the automation solutions
This one is easy - Terraform is the industry standard for infrastructure management. We use OpenTofu, a fully open-source fork of Terraform (Hashicorp did some funky stuff, shall we say). But, after you have your infrastructure deployed - in this case a cloud VM - how do we configure it?
1. Setting up the Terraform project
1.1 Modules
First, we need to structure our Terraform project. While you could just chuck all of it in one main.tf
file, structuring the project nicely with modules is the way to go, to make it much easier to change things later.
Let's start with a module for a VM, modules/vm/main.tf
:
resource "hcloud_server" "vm" {
name = var.name
image = var.image
server_type = var.server_type
location = var.location
ssh_keys = var.ssh_keys
}
resource "cloudflare_dns_record" "a_record" {
zone_id = var.cloudflare_zone_id
name = "${var.name}.yourdomain.tld"
type = "A"
content = hcloud_server.vm.ipv4_address
ttl = 1
}
output "dns_name" {
description = "DNS record"
value = cloudflare_dns_record.a_record.name
}
This example uses Hetzner to create a VM. It also uses cloudflare to create a DNS record with the IP address of the VM!
You may notice the variables here - let's define those now in modules/vm/variables.tf
:
variable "name" {}
variable "image" { default = "debian-12" }
variable "server_type" { default = "cx22" }
variable "location" { default = "nbg1" }
variable "ssh_keys" {
type = list(string)
default = [ "mysshkey1", "mysshkey2" ]
}
variable "cloudflare_zone_id" {
type = string
}
This sets some defaults, for example the VM type, the OS, the area, and the SSH keys for the VM. This can be changed on a per VM basis, however.
1.2 Creating a VM
With our module defined, we can create a VM. We decided to do this in vms/[VM NAME]/[VM NAME].tf
(essentially another module), but you can also do this in the root main.tf
if you prefer. We will go with the former option.
variable cloudflare_zone_id {
type = string
}
module "server1" {
source = "../../modules/vm"
name = "server1"
cloudflare_zone_id = var.cloudflare_zone_id
}
output "dns_name" {
value = module.server1.dns_name
}
This snippet will use the VM module we created earlier. As you can see, it passes in the name
variable we defined earlier, but uses the defaults in the module for everything else. If you wanted to, you could set the image here, the server_type etc.
1.3 Gluing it together
Now all that is left is to make the main.tf
:
module "vm_server1" {
source = "./vms/server1"
cloudflare_zone_id = var.cloudflare_zone_id
}
output "fpbx_server1" {
value = module.vm_server1.dns_name
}
Throughout this process, note that we pass the cloudflare_zone_id through the modules. This is needed as the modules do not have access to the global variables set in the root of the project.
With this complete, bar setting up the providers, you should be able to run tofu plan
to see what it will create, and tofu apply
to apply. To keep our secrets, well, secret, we use environment variables to pass in the API keys for the providers.
2. Configuring the VM with Ansible
2.1 Generating an inventory automagically
Now we have a VM created for us, and a DNS record that points to it, we can create an Ansible playbook to install our PBX of choice - for us, that'll be FreePBX.
You may have noticed the output
sections in the Terraform files, which I conveniently didn't explain. Here's where that comes into play - instead of creating the inventory manually, we use the outputs from Terraform to make our inventory for us!
Here's a quick script I (ChatGPT) wrote to convert the output of Terraform into an Ansible inventory:
import json
import yaml
# Read from dns.json
with open("dns.json", "r") as f:
json_data = json.load(f)
# Extract DNS values
inventory = {}
for key, data in json_data.items():
if "value" in data:
if key.split("_")[0] not in inventory:
inventory[key.split("_")[0]] = {"hosts": {}}
inventory[key.split("_")[0]]["hosts"][data["value"]] = {"ansible_user": "root"}
print(inventory)
# Write to inventory.yml
with open("inventory.yml", "w") as f:
yaml.dump(inventory, f, sort_keys=False)
print("✅ Ansible inventory written to inventory.yml")
This script reads dns.json
, which is the output from tofu output -json > dns.json
.
In the root main.tf
, the output is defined as fpbx_server1
. This script takes that and uses the first part as the group. This lets us create multiple VMs in the same project, but with different roles! Neat, right?
2.2 Writing the playbook
Now, let's write a simple playbook which installs FreePBX on the host we just created. Because the inventory includes groups based on the VM output, we can use that. I made a playbook like this:
---
- name: Set hostname
hosts: all
become: true
tasks:
- name: Set the hostname to the FQDN
ansible.builtin.hostname:
name: "{{ inventory_hostname }}"
- name: Install FreePBX 17
hosts: fpbx
become: true
tasks:
- name: Ensure git is installed
ansible.builtin.apt:
name: git
state: present
update_cache: true
- name: Clone the FreePBX install script repo
ansible.builtin.git:
repo: https://github.com/FreePBX/sng_freepbx_debian_install.git
dest: /root/sng_freepbx_debian_install
version: master
- name: Get service facts
ansible.builtin.service_facts:
- name: Run the FreePBX install script
ansible.builtin.command:
cmd: ./sng_freepbx_debian_install.sh
chdir: /root/sng_freepbx_debian_install
when: ansible_facts['services']['freepbx.service']['status'] | default('not-found') == 'not-found'
There is a couple interesting things to note here - first, we use the all
hosts group for common tasks, here setting the hostname to the DNS name of the machine. Secondly, we check the freepbx.service
to make sure it isn't present before installing - this lets us run the playbook multiple times on the same host without re-installing FreePBX.
Conclusion
Using this guide as a framework, you can build your own automation platform for deploying anything, really. Here, we made a simple example with installing FreePBX, but you can take this further and create multiple hosts, use different cloud/on-prem providers and such very easily.
In production, we have this fully automated with a GitLab CI/CD pipeline - while I don't show this here (let me know if you want a full guide!), it is very easy to use OpenTofu with GitLab, and even use GitLab to manage your state for you. Quick hint - for ansible, the alpine/ansible
container works pretty well!
Thank you very much for reading, I hope it helps!