Repeated VPS setup steps—packages, users, SSH keys and web services—can be versioned with Terraform and Ansible. Terraform manages resources through a supported API; Ansible configures reachable hosts. This guide connects a real OpenStack example to Ubuntu 24.04 roles and a reviewed deployment workflow. The OpenStack example requires your own authorized API access and does not establish a DCHost API or Terraform provider. An existing DCHost VPS or suitable dedicated server can instead use the direct Ansible inventory path. An HTTP bootstrap page is the initial result; application deployment, TLS, monitoring and recovery need their own acceptance checks.
Why Reproducible VPS Builds Matter
Before diving into tools, it is worth clarifying why this level of automation is worth the effort. A manually built VPS typically follows a checklist in someone’s notebook or wiki page. Over time, people skip steps, tweak configurations directly on the server, or forget to apply improvements everywhere. This leads to configuration drift: two servers that are supposed to be identical behave differently, and debugging becomes guesswork.
Reproducible VPS builds change that. You declare in code exactly how a machine should look—CPU/RAM, disk, OS image, installed packages, users, SSH keys, firewall rules, monitoring agents, backup scripts. Then you let tools like Terraform and Ansible apply that declaration in a repeatable way. If you lose a VPS, the pipeline can rebuild its declared infrastructure and configuration, but application data must be restored separately. Adding a second VPS also requires updating the declared instance count or resource definitions. Combined with a solid backup strategy, this can support a tested disaster recovery process; if you want to go deeper on that side, we recommend our guide on writing a realistic disaster recovery plan with tested runbooks.
This approach can also help with security work. Instead of hardening each VPS by hand, you encode your best practices once and roll them out everywhere. Our detailed checklist for VPS security hardening (sshd_config, Fail2ban, no-root SSH) can guide roles that you test and assign to the appropriate hosts.
Terraform and Ansible: Who Does What?
Terraform and Ansible overlap conceptually, but in practice they shine at different layers of the stack. Understanding that division of responsibilities will keep your automation simple and maintainable.
Terraform: Infrastructure as Code
Terraform describes infrastructure resources such as virtual machines, disks and DNS records using the schema of a selected provider. It plans changes from configuration, state and data read through that provider. Creation, update or replacement depends on the actual resource and API; read the plan before applying it.
For VPS automation, you typically let Terraform handle:
- Creating the VPS itself (vCPU/RAM/disk plan, OS image)
- Assigning public IPs and networking options
- Managing DNS records for hostnames
- Outputting connection details (IP, SSH port, usernames) for later steps
If you want a deeper Terraform-focused perspective, we have a dedicated article on automating VPS and DNS with Terraform.
Ansible: Configuration Management and Orchestration
Ansible focuses on what happens inside the server. You describe the desired state of packages, services, configuration files, users and permissions. Ansible connects via SSH. Tasks using idempotent modules can leave already-correct resources unchanged and update those that differ, but arbitrary commands and scripts need explicit checks to avoid repeating changes.
For VPS automation, Ansible is ideal for:
- Creating non-root users and authorized SSH keys
- Locking down SSH, installing Fail2ban, configuring firewalls
- Installing web stacks (Nginx, PHP-FPM, Node.js, Docker, databases)
- Deploying application code and setting up systemd services
- Configuring backups, monitoring agents and log shippers
For another angle on combining Ansible with cloud-init, see our guide on turning a blank VPS into a ready-to-serve machine with cloud-init and Ansible.
The Simple Rule of Thumb
A helpful way to remember the split:
- Terraform: “Give me three VPS machines with these specs and hostnames.”
- Ansible: “On each VPS, create these users, install this stack and configure these services.”
Once that is clear, wiring them together becomes much easier.
Designing Your Automated VPS Stack
Use one repository layout for the example and separate working directories, state and access controls for staging and production. Version-control the source and dependency lock, while keeping credentials, state, saved plans and generated inventories out of Git.
infra-project/
Makefile
.gitignore
infra/
main.tf
variables.tf
outputs.tf
cloud-init.yml
terraform.tfvars
.terraform.lock.hcl
config/
ansible.cfg
requirements.yml
collections/
inventory/
hosts.json
group_vars/app.yml
playbooks/
site.yml
hardening.yml
roles/
common/tasks/main.yml
webserver/
tasks/main.yml
files/validate-site
templates/app.conf.j2
hardening/tasks/main.yml
scripts/generate_inventory.sh
infra/ contains the OpenStack resource definition; config/ contains the Ansible inventory, roles and playbooks. Run Ansible inside config/ so its configuration and relative paths are loaded. Use a trusted repository that is not world-writable.
Prerequisites
The example has two entry points: create resources through an authorized OpenStack API, or configure an already provisioned host directly with Ansible. Neither route assumes that a hosting plan includes hypervisor access.
- Controller: Python 3.13, Terraform 1.17.1, Bash, GNU Make and an SSH client. Use ansible-core 2.20.11 and community.general 14.0.0 as below; review support and porting guidance when updating the pins.
- Target: a new or explicitly dedicated Ubuntu 24.04 host, Python, working non-root SSH and passwordless sudo for this noninteractive example. Confirm the image’s username and key injection; the example uses
ubuntuand port 22. - For Terraform: authorized OpenStack credentials in a restricted
clouds.yamlprofile, plus an existing image, flavor, network and reviewed security group. The runner must reach the network’s fixed IPv4 addresses; the example does not allocate a floating IP. - A trusted Git repository and a public SSH key whose private key stays outside it. Verify each target host key through a trusted console or provider record before connecting.
config/ansible.cfg
[defaults]
inventory = ./inventory/hosts.json
roles_path = ./roles
collections_path = ./collections
host_key_checking = True
retry_files_enabled = False
config/requirements.yml
---
collections:
- name: community.general
version: 14.0.0
# From the repository root; Python 3.13 and Terraform 1.17.1 installed.
python3.13 -m venv .venv
. .venv/bin/activate
python -m pip install 'ansible-core==2.20.11'
(cd config && ansible-galaxy collection install -r requirements.yml -p collections)
If you are new to VPS administration itself, it is worth reading our guide on what to do in the first 24 hours on a new VPS. Compare that checklist with the explicit scope of each role below and add any missing controls with their own tests.
Step 1 – Defining VPS Infrastructure with Terraform
The following code uses terraform-provider-openstack/openstack 3.4.0. It applies only to an OpenStack cloud you are authorized to manage. A DCHost VPS does not imply an OpenStack endpoint; use the manual inventory route when the machine is already provisioned. The provider reads the named cloud profile; keep its credentials outside this repository.
Basic Terraform Configuration
Declare the cloud, region, image, flavor, network, security groups, public-key path, host prefix and instance count. The validation limits this example to a short host prefix and 1–20 instances; it is not a capacity recommendation.
infra/variables.tf
variable "cloud" { type = string }
variable "region" { type = string }
variable "image_id" { type = string }
variable "flavor_id" { type = string }
variable "network_id" { type = string }
variable "security_group_names" { type = list(string) }
variable "ssh_public_key_file" { type = string }
variable "name_prefix" {
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{0,30}$", var.name_prefix))
error_message = "Use a short lowercase host prefix."
}
}
variable "vps_count" {
type = number
default = 1
validation {
condition = var.vps_count >= 1 && var.vps_count <= 20 && floor(var.vps_count) == var.vps_count
error_message = "Use an integer between 1 and 20."
}
}
Save the following as infra/main.tf and infra/cloud-init.yml. The keypair resource receives an existing public key so it does not generate a private key in state. The Ubuntu cloud image must support the OpenStack metadata/key mechanism and its expected default user. Cloud-init prepares Python and sudo packages; successful instance creation does not prove cloud-init has completed. Keep secrets out of user data and verify non-root access before using Ansible.
infra/main.tf
terraform {
required_version = "= 1.17.1"
required_providers {
openstack = {
source = "terraform-provider-openstack/openstack"
version = "= 3.4.0"
}
}
}
provider "openstack" {
cloud = var.cloud
region = var.region
}
resource "openstack_compute_keypair_v2" "runner" {
name = "${var.name_prefix}-runner"
public_key = file(pathexpand(var.ssh_public_key_file))
}
resource "openstack_compute_instance_v2" "app" {
count = var.vps_count
name = "${var.name_prefix}-${count.index + 1}"
image_id = var.image_id
flavor_id = var.flavor_id
key_pair = openstack_compute_keypair_v2.runner.name
security_groups = var.security_group_names
user_data = file("${path.module}/cloud-init.yml")
metadata = { environment = var.name_prefix, role = "app" }
network {
uuid = var.network_id
access_network = true
}
}
infra/cloud-init.yml
#cloud-config
package_update: true
packages:
- python3
- python3-apt
- sudo
# Ubuntu cloud image's default user and OpenStack-injected public key are used.
# Verify the image's username, SSH policy and sudo access before using it.
Expose fixed IPv4 addresses and a structured Ansible inventory. These addresses may be private: routing from the runner and security-group rules remain prerequisites. A second instance requires a deliberate count/configuration change, followed by plan review.
infra/outputs.tf
output "app_ips" {
description = "Fixed IPv4 addresses; the runner must have a route to this network."
value = [for s in openstack_compute_instance_v2.app : s.access_ip_v4]
}
output "ansible_inventory" {
value = {
app = {
hosts = {
for s in openstack_compute_instance_v2.app : s.name => {
ansible_host = s.access_ip_v4
ansible_user = "ubuntu"
ansible_port = 22
ansible_python_interpreter = "/usr/bin/python3"
cloud_init_wait = true
}
}
}
}
}
Terraform Workflow
Run these commands from the repository root. Use a separate checkout/backend and access policy for each environment; changing only a tfvars file in the same state does not isolate staging from production.
Initialize:
umask 077
terraform -chdir=infra init
terraform -chdir=infra validate
Set the values: save the example below as infra/terraform.tfvars. Replace every REPLACE_… field from your authorized environment.
infra/terraform.tfvars.example
cloud = "REPLACE_AUTHORIZED_CLOUD_NAME"
region = "REPLACE_REGION"
image_id = "REPLACE_UBUNTU_24_04_IMAGE_UUID"
flavor_id = "REPLACE_FLAVOR_ID"
network_id = "REPLACE_RUNNER_REACHABLE_NETWORK_UUID"
security_group_names = ["REPLACE_REVIEWED_SECURITY_GROUP_NAME"]
ssh_public_key_file = "~/.ssh/id_ed25519.pub"
name_prefix = "app-staging"
vps_count = 1
- Plan the changes:
terraform -chdir=infra plan -out=reviewed.tfplan
Review, then apply: confirm the account, region, count, replacements and deletions. A saved-plan apply does not ask for another approval. Run the following only after making the deployment decision; if the operation fails or partially succeeds, generate and review a new plan instead of reusing the old one.
terraform -chdir=infra apply reviewed.tfplan
Preserve .terraform.lock.hcl in Git so provider selections and checksums can be reviewed. Keep state and saved plans protected outside Git. Source history records intended changes; retain plan/apply results separately to show what was actually applied.
Step 2 – Provisioning the Server with Ansible
Ansible now applies the defined host configuration. First check a fresh or dedicated Ubuntu 24.04 target for existing service, port and path ownership. This role is not a configuration for a cPanel/Plesk-managed web server. Reserve the /var/www/iac-app root, validator and iac-app.conf paths for this example.
Building the Inventory from Terraform Outputs
For an existing DCHost host, save the inventory below with your actual address and account. 192.0.2.10 is a documentation placeholder. Keep cloud_init_wait false if that host does not use cloud-init. For the OpenStack route, use the script after a successful apply: it checks for nonempty host data and valid IPv4 addresses, asks Ansible to parse it, then replaces the inventory atomically. A failed Terraform output or validation preserves the previous file.
config/inventory/hosts.json
{
"app": {
"hosts": {
"app-staging-1": {
"ansible_host": "192.0.2.10",
"ansible_user": "ubuntu",
"ansible_port": 22,
"ansible_python_interpreter": "/usr/bin/python3",
"cloud_init_wait": false
}
}
}
}
config/inventory/group_vars/app.yml
---
app_server_name: app.example.com
app_timezone: Etc/UTC
# Set to true only after the fresh-host preflight described in the article.
fresh_host_reviewed: false
scripts/generate_inventory.sh
#!/usr/bin/env bash
set -euo pipefail
cd -- "$(dirname -- "$0")/.."
umask 077
mkdir -p config/inventory
raw=$(mktemp config/inventory/.terraform.XXXXXX)
candidate=$(mktemp config/inventory/.inventory.XXXXXX.json)
trap 'rm -f -- "$raw" "$candidate"' EXIT
terraform -chdir=infra output -json ansible_inventory > "$raw"
python3 - "$raw" "$candidate" <<'PYTHON'
import ipaddress, json, re, sys
from pathlib import Path
value = json.loads(Path(sys.argv[1]).read_text())
hosts = value['app']['hosts']
assert isinstance(hosts, dict) and hosts, 'Empty or invalid app inventory'
for name, host in hosts.items():
assert re.fullmatch(r'[a-z][a-z0-9-]{0,40}', name), 'Invalid host name'
ipaddress.IPv4Address(host['ansible_host'])
assert host['ansible_user'] == 'ubuntu'
assert host['ansible_port'] == 22
assert host['ansible_python_interpreter'] == '/usr/bin/python3'
assert host['cloud_init_wait'] is True
Path(sys.argv[2]).write_text(json.dumps(value, indent=2) + '\n')
PYTHON
(cd config && ansible-inventory -i "${candidate#config/}" --list > /dev/null)
mv -- "$candidate" config/inventory/hosts.json
chmod 0755 scripts/generate_inventory.sh
./scripts/generate_inventory.sh
Set app_server_name to an ASCII hostname you control that is not already assigned to another Nginx site. Set fresh_host_reviewed to true only after the documented access, package, port and path preflight. Inspect the resulting inventory before execution. For direct Ansible use, skip the Terraform inventory generator and run make configure later.
Writing a Base Hardening Role
Keep base setup and access changes as separate rollouts. The base role installs utilities and uses the collection-qualified timezone module with its Ubuntu 24.04 dependencies. It does not perform a full package upgrade. Schedule upgrades/reboots and check cron behavior after timezone changes. The access role below implements part of the VPS security hardening checklist; test it on one host before extending the rollout.
config/roles/common/tasks/main.yml
---
- name: Install base tools and timezone dependencies
ansible.builtin.apt:
name: [curl, git, htop, tzdata, util-linux-extra]
state: present
update_cache: true
cache_valid_time: 3600
- name: Set the system timezone
community.general.timezone:
name: "{{ app_timezone }}"
# Package upgrades, SSH and firewall changes use a separate reviewed rollout.
Before the separate hardening playbook, create the intended non-root account and distribute its public key through your reviewed bootstrap/account role. Validate sudoers changes with visudo -cf. This example reuses the verified ubuntu account: test a fresh key-only SSH session and sudo, plus emergency console access. The two verification flags are records of those real checks, not substitutes for them. Use the management source address as seen by the server after NAT/bastion routing; the CIDR must contain that address. To use this role unchanged, ansible_host must be the numeric local IP receiving the connection on the server, and ansible_port must be its actual local SSH port. If destination NAT translates the address or port, or the inventory uses a hostname, first adapt laddr/lport in sshd -T -C and the UFW port to separate variables containing the server-side values; do not run this role treating an external connection address as a local address. Review existing broad firewall permits, IPv6 policy and other required service ports.
config/inventory/group_vars/hardening-inputs.example.yml
# Merge reviewed values into inventory/group_vars/app.yml; this file is not loaded.
admin_access_verified: false
console_access_verified: false
management_source_ip: 192.0.2.50
management_source_hostname: runner.example.com
management_cidr: 192.0.2.50/32
config/playbooks/hardening.yml
---
- name: Apply the separately reviewed access policy
hosts: app
become: true
serial: 1
any_errors_fatal: true
roles:
- hardening
config/roles/hardening/tasks/main.yml
---
- name: Require real access checks, console recovery and explicit network inputs
ansible.builtin.assert:
that:
- ansible_facts.distribution == 'Ubuntu'
- ansible_facts.distribution_version == '24.04'
- ansible_user != 'root'
- admin_access_verified | default(false) | bool
- console_access_verified | default(false) | bool
- management_source_ip is defined
- management_source_hostname is defined
- management_cidr is defined
- management_source_hostname is match('^[a-zA-Z0-9.-]+$')
- ansible_user is match('^[a-z_][a-z0-9_-]*$')
- name: Check that the actual management source is allowed
ansible.builtin.command:
argv:
- python3
- -c
- 'import ipaddress,sys; assert ipaddress.ip_address(sys.argv[1]) in ipaddress.ip_network(sys.argv[2], strict=False)'
- "{{ management_source_ip }}"
- "{{ management_cidr }}"
delegate_to: localhost
become: false
changed_when: false
- name: Verify sudo before changing access
ansible.builtin.command: /usr/bin/true
changed_when: false
- name: Install hardening tools without activating services
ansible.builtin.apt:
name: [ufw, fail2ban, unattended-upgrades]
state: present
update_cache: true
cache_valid_time: 3600
policy_rc_d: 101
- name: Inspect the owned SSH drop-in
ansible.builtin.stat:
path: /etc/ssh/sshd_config.d/00-iac-access.conf
follow: false
register: old_ssh
- name: Reject non-file collisions
ansible.builtin.assert:
that:
- not old_ssh.stat.exists or old_ssh.stat.isreg | default(false)
- name: Preserve previous owned SSH bytes
ansible.builtin.slurp:
src: /etc/ssh/sshd_config.d/00-iac-access.conf
register: old_ssh_bytes
when: old_ssh.stat.exists
- name: Require ownership marker
ansible.builtin.assert:
that:
- (old_ssh_bytes.content | b64decode).startswith('# Managed by iac-access example\n')
when: old_ssh.stat.exists
- name: Install and check the effective SSH policy
block:
- name: Write the owned SSH drop-in
ansible.builtin.copy:
content: |
# Managed by iac-access example
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
dest: /etc/ssh/sshd_config.d/00-iac-access.conf
owner: root
group: root
mode: '0600'
backup: true
register: ssh_write
- name: Validate the complete SSH configuration
ansible.builtin.command: /usr/sbin/sshd -t
changed_when: false
- name: Read effective policy for the administrator and root
ansible.builtin.command:
argv:
- /usr/sbin/sshd
- -T
- -C
- "user={{ item }},addr={{ management_source_ip }},host={{ management_source_hostname }},laddr={{ ansible_host }},lport={{ ansible_port | default(22) }}"
loop: ["{{ ansible_user }}", root]
register: ssh_effective
changed_when: false
- name: Reject ignored or overridden authentication settings
ansible.builtin.assert:
that:
- "'permitrootlogin no' in item.stdout_lines"
- "'passwordauthentication no' in item.stdout_lines"
- "'kbdinteractiveauthentication no' in item.stdout_lines"
- "'pubkeyauthentication yes' in item.stdout_lines"
loop: "{{ ssh_effective.results }}"
rescue:
- name: Restore previous owned SSH content
ansible.builtin.copy:
content: "{{ old_ssh_bytes.content | b64decode }}"
dest: /etc/ssh/sshd_config.d/00-iac-access.conf
owner: "{{ old_ssh.stat.uid }}"
group: "{{ old_ssh.stat.gid }}"
mode: "{{ old_ssh.stat.mode }}"
when: old_ssh.stat.exists
- name: Remove a newly created owned SSH drop-in
ansible.builtin.file:
path: /etc/ssh/sshd_config.d/00-iac-access.conf
state: absent
when: not old_ssh.stat.exists
- name: Stop before reload
ansible.builtin.fail:
msg: SSH validation failed. Inspect includes, earlier settings and Match blocks.
- name: Reload validated SSH even after an earlier activation failure
ansible.builtin.service:
name: ssh
state: reloaded
- name: Reconnect after SSH policy change
ansible.builtin.meta: reset_connection
- name: Require fresh SSH access
ansible.builtin.wait_for_connection:
timeout: 60
- name: Allow the actual management CIDR and SSH port before enabling UFW
community.general.ufw:
rule: allow
from_ip: "{{ management_cidr }}"
port: "{{ ansible_port | default(22) }}"
proto: tcp
- name: Allow public HTTP and HTTPS by numeric port
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop: ['80', '443']
- name: Enable inbound default-deny policy
community.general.ufw:
state: enabled
direction: incoming
policy: deny
- name: Reconnect after firewall policy change
ansible.builtin.meta: reset_connection
- name: Verify management access again
ansible.builtin.wait_for_connection:
timeout: 60
The SSH drop-in is checked in the complete configuration and in the administrator/root connection contexts before reloading on every successful run, even when the drop-in is unchanged. This intentional repeat applies configuration left pending by an earlier failed reload; a reload failure stops the workflow. Include ordering or Match overrides fail that gate. These checks cover the supplied contexts; review other users and network paths separately. A connection loss after SSH or firewall changes can prevent rescue tasks from running, so keep the old session and tested console recovery available. Fail2ban and unattended-upgrades are only installed here: configure and test their jail/backend, exclusions, update/reboot policy and logs before treating them as active protections.
# Substitute the verified host, account, key and actual SSH port.
ssh -o StrictHostKeyChecking=yes -o BatchMode=yes -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no -i ~/.ssh/id_ed25519 -p 22 ubuntu@192.0.2.10 'sudo -n true'
# After merging reviewed inputs into inventory/group_vars/app.yml:
(cd config && ansible-playbook playbooks/hardening.yml --syntax-check)
(cd config && ansible-playbook playbooks/hardening.yml)
Installing a Web Stack Role
The webserver role installs Ubuntu’s Nginx and PHP 8.3 FPM packages, including the actual php8.3-fpm service and socket. It serves a static bootstrap page and forwards existing PHP files; it does not deploy a WordPress or WooCommerce application. Tune PHP workers and application resources from measurements. The DCHost blog index is a general entry point for related hosting articles.
config/roles/webserver/templates/app.conf.j2
# Managed by iac-app example
server {
listen 80;
server_name {{ app_server_name }};
root /var/www/iac-app;
index index.html index.php;
location / {
try_files $uri $uri/ =404;
}
location ~ /\. {
deny all;
}
location ~ \.php$ {
try_files $uri =404;
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
}
config/roles/webserver/files/validate-site
#!/usr/bin/env bash
set -euo pipefail
candidate=${1:?candidate required}
work=$(mktemp -d)
trap 'rm -rf -- "$work"' EXIT
cp -- "$candidate" "$work/site.conf"
cat > "$work/nginx.conf" <<EOF
events {}
http {
include /etc/nginx/mime.types;
include $work/site.conf;
}
EOF
/usr/sbin/nginx -t -c "$work/nginx.conf"
config/roles/webserver/tasks/main.yml
---
- name: Install the Ubuntu packages without automatic service starts
ansible.builtin.apt:
name: [nginx, php8.3-fpm]
state: present
policy_rc_d: 101
- name: Check the existing complete Nginx configuration
ansible.builtin.command: /usr/sbin/nginx -t
changed_when: false
- name: Inspect the owned site file
ansible.builtin.stat:
path: /etc/nginx/sites-available/iac-app.conf
follow: false
register: old_site
- name: Inspect the owned enable link
ansible.builtin.stat:
path: /etc/nginx/sites-enabled/iac-app.conf
follow: false
register: old_link
- name: Reject path collisions
ansible.builtin.assert:
that:
- not old_site.stat.exists or old_site.stat.isreg | default(false)
- not old_link.stat.exists or (old_link.stat.islnk | default(false) and old_link.stat.lnk_source == '/etc/nginx/sites-available/iac-app.conf')
- name: Read the previous site if present
ansible.builtin.slurp:
src: /etc/nginx/sites-available/iac-app.conf
register: old_bytes
when: old_site.stat.exists
- name: Require the ownership marker before updating an existing file
ansible.builtin.assert:
that:
- (old_bytes.content | b64decode).startswith('# Managed by iac-app example\n')
when: old_site.stat.exists
- name: Install the candidate syntax validator
ansible.builtin.copy:
src: validate-site
dest: /usr/local/sbin/validate-iac-site
owner: root
group: root
mode: '0755'
- name: Create the example document root
ansible.builtin.file:
path: /var/www/iac-app
state: directory
owner: root
group: root
mode: '0755'
- name: Write the static bootstrap page
ansible.builtin.copy:
content: "IaC HTTP bootstrap ready\n"
dest: /var/www/iac-app/index.html
owner: root
group: root
mode: '0644'
- name: Install and validate the owned site
block:
- name: Validate the candidate before replacing the site
ansible.builtin.template:
src: app.conf.j2
dest: /etc/nginx/sites-available/iac-app.conf
owner: root
group: root
mode: '0644'
backup: true
validate: /usr/local/sbin/validate-iac-site %s
register: site_write
- name: Enable only this owned site
ansible.builtin.file:
src: /etc/nginx/sites-available/iac-app.conf
dest: /etc/nginx/sites-enabled/iac-app.conf
state: link
register: link_write
- name: Validate all installed sites together
ansible.builtin.command: /usr/sbin/nginx -t
register: nginx_complete_check
changed_when: false
failed_when: nginx_complete_check.rc != 0 or '[warn]' in nginx_complete_check.stderr
rescue:
- name: Restore the previous owned site content
ansible.builtin.copy:
content: "{{ old_bytes.content | b64decode }}"
dest: /etc/nginx/sites-available/iac-app.conf
owner: "{{ old_site.stat.uid }}"
group: "{{ old_site.stat.gid }}"
mode: "{{ old_site.stat.mode }}"
when: old_site.stat.exists
- name: Remove only the newly created owned link
ansible.builtin.file:
path: /etc/nginx/sites-enabled/iac-app.conf
state: absent
when: not old_link.stat.exists
- name: Remove only the newly created owned file
ansible.builtin.file:
path: /etc/nginx/sites-available/iac-app.conf
state: absent
when: not old_site.stat.exists
- name: Stop after the failed deployment
ansible.builtin.fail:
msg: Validation failed; owned site restored where possible. Inspect the failure before retrying.
- name: Validate the installed PHP-FPM configuration
ansible.builtin.command: /usr/sbin/php-fpm8.3 -t
changed_when: false
- name: Ensure PHP-FPM is running
ansible.builtin.service:
name: php8.3-fpm
state: started
enabled: true
- name: Read service state before start or enable
ansible.builtin.service_facts:
- name: Remember whether Nginx was already running
ansible.builtin.set_fact:
nginx_was_running: "{{ ansible_facts.services.get('nginx.service', {}).get('state', 'stopped') == 'running' }}"
- name: Ensure validated Nginx is running
ansible.builtin.service:
name: nginx
state: started
enabled: true
- name: Reload validated Nginx even after an earlier activation failure
ansible.builtin.service:
name: nginx
state: reloaded
when: nginx_was_running
The role retains the default and other sites. APT policy_rc_d: 101 suppresses package-triggered service starts; it does not establish that no service was previously running. Candidate syntax validation is followed by a full installed-tree check; warnings such as ignored conflicting server names also fail the deployment. On validation failure, only this role’s owned site changes are restored where possible; package installation and concurrent external changes are outside that rollback. After the Nginx and PHP-FPM checks and required service operations succeed, the role reloads an already-running Nginx on every run, even when the files are unchanged. This activates configuration on a successful retry after a PHP check, service operation or reload failed after the files were saved. Nginx that was initially stopped is started after validation and needs no additional reload on that run. Validation and service failures stop the workflow.
curl --fail --show-error --resolve app.example.com:80:192.0.2.10 http://app.example.com/
Use your real host/IP and inspect the returned body, service logs and PHP behavior. A parser success does not establish reachable HTTP or a healthy application.
Bringing Roles Together in a Playbook
The main playbook waits for SSH/Python, waits for cloud-init where applicable, checks the Ubuntu version and preflight flag, then applies common and webserver roles one host at a time. A failure stops this rollout; that serialization is not a zero-downtime guarantee.
config/playbooks/site.yml
---
- name: Configure the reviewed Ubuntu web hosts
hosts: app
become: true
gather_facts: false
serial: 1
any_errors_fatal: true
pre_tasks:
- name: Wait for SSH and a working Python module transport
ansible.builtin.wait_for_connection:
timeout: 300
- name: Wait for cloud-init before using APT
ansible.builtin.command: cloud-init status --wait
changed_when: false
when: cloud_init_wait | default(false) | bool
- name: Gather facts
ansible.builtin.setup:
- name: Require the documented target and completed preflight
ansible.builtin.assert:
that:
- ansible_facts.distribution == 'Ubuntu'
- ansible_facts.distribution_version == '24.04'
- ansible_user != 'root'
- fresh_host_reviewed | bool
- app_server_name is match('^[a-z0-9]+([.-][a-z0-9]+)*$')
fail_msg: Review SSH/sudo, network and existing services before enabling this example.
roles:
- common
- webserver
Run it like this:
# From the repository root, with .venv active.
(cd config && ansible-inventory --graph)
(cd config && ansible-playbook playbooks/site.yml --syntax-check)
(cd config && ansible-playbook playbooks/site.yml)
Inspect the resulting services and actual response before continuing. --syntax-check proves neither SSH/sudo access nor package, service or application behavior. --check is also limited by module support and missing runtime results. Run the playbook again on a disposable test host and examine unexpected changes; idempotence depends on the task and inputs. This example intentionally reloads validated, already-running Nginx and validated SSH on every successful run, reporting those operations as changed; it does not promise a second run with no changed tasks.
Step 3 – Orchestrating Terraform and Ansible Together
A Makefile can connect the reviewed Terraform apply, checked inventory and Ansible playbook. For new or recreated targets, split the first run: apply the reviewed Terraform plan and generate inventory, then verify the target list, host keys, SSH/sudo access and fresh-host preflight. Only then set fresh_host_reviewed to true and run make configure. The make all recipe below does not pause between apply and Ansible for these checks; use it only for changes that preserve the already-verified target identity and access prerequisites.
Using a Makefile as a Simple Orchestrator
Makefile
SHELL := /bin/bash
.SHELLFLAGS := -eu -o pipefail -c
.NOTPARALLEL:
.PHONY: plan all configure destroy
plan:
umask 077; rm -f infra/reviewed.tfplan; terraform -chdir=infra init; terraform -chdir=infra validate; terraform -chdir=infra plan -out=reviewed.tfplan
all:
test -f infra/reviewed.tfplan
read -r -p 'Apply the reviewed plan in this checkout? Type APPLY: ' answer; test "$$answer" = APPLY
terraform -chdir=infra apply reviewed.tfplan
rm -- infra/reviewed.tfplan
./scripts/generate_inventory.sh
cd config && ansible-playbook playbooks/site.yml
configure:
cd config && ansible-playbook playbooks/site.yml
destroy:
umask 077; rm -f infra/destroy.tfplan; terraform -chdir=infra plan -destroy -out=destroy.tfplan
read -r -p 'Destroy resources in this reviewed plan? Type DESTROY: ' answer; test "$$answer" = DESTROY
terraform -chdir=infra apply destroy.tfplan
rm -- infra/destroy.tfplan
A single ordered recipe and .NOTPARALLEL avoid the race in independent all: apply provision prerequisites. Shell failure stops subsequent steps. This only serializes one Make invocation; use CI concurrency control and backend locking for other runners. A successful apply consumes its saved plan. If apply partially fails, inspect state and generate a new reviewed plan; do not keep retrying the old one.
Generate and read a plan first, then apply it from the same checkout. make all requests the exact response APPLY before using the saved plan:
make plan
# Review the plan.
make all
Use separate checkouts/backends, credentials and inventories for staging and production. CLI workspaces can separate state but are not an access-control boundary; a tfvars filename alone does not select a separate state. For disposable tests, make destroy displays a destruction plan and requires DESTROY. Check the exact resources first: objects outside that state, including manual resources and backups, are not automatically removed.
Integrating with CI/CD
Later, you can move this into CI/CD. For example:
- Plan in the correct environment with locked tool/provider dependencies and a dedicated CI identity.
- Review the saved plan together with the source revision, account and state scope; apply that approved artifact with environment protection.
- Prevent overlapping applies, handle partial failure explicitly and generate inventory only after successful provisioning. Restrict saved-plan/log access and retention. Branch or tag naming alone is not environment authorization.
Keep source history plus deployment records for who approved and applied each revision. Git commits show intended changes; they do not prove that a deployment or recovery operation completed.
Adding DNS, SSL, Monitoring and Backups
Treat DNS, TLS, monitoring and backups as separate roles with their own prerequisites, secret handling and acceptance tests. A running Nginx process and an HTTP bootstrap page do not establish production readiness.
Managing DNS with Terraform
If your authorized OpenStack cloud exposes Designate and you control the zone, the optional recordset resource below uses the same pinned provider. Set dns_zone_id and a trailing-dot dns_zone_name, such as example.com.. The example records the instance’s fixed IPv4 address; publish it in public DNS only if that address is intentionally publicly reachable. Use a private zone for private addresses. This does not establish a DCHost DNS API.
# Optional: add to infra/ only if you control this Designate zone.
variable "dns_zone_id" { type = string }
variable "dns_zone_name" { type = string } # Example: example.com.
resource "openstack_dns_recordset_v2" "app" {
count = var.vps_count
zone_id = var.dns_zone_id
name = "${var.name_prefix}-${count.index + 1}.${var.dns_zone_name}"
type = "A"
ttl = 300
records = [openstack_compute_instance_v2.app[count.index].access_ip_v4]
}
Add these declarations to infra/ only for a zone you control, with an existing recordset imported or a new name confirmed unoccupied. Coordinate the intended application hostname with Nginx and TLS names. An AAAA record also requires configured, tested IPv6; do not manufacture it from an IPv4 example.
We go deeper into this style of automation in our article on Terraform-based VPS and DNS automation.
Automating SSL certificates
Once DNS is in place, you want HTTPS. A common pattern is:
- Install and configure an ACME client for the chosen HTTP-01 or DNS-01 challenge. HTTP-01 requires public port 80 and a reachable challenge path; the bootstrap template’s dot-directory denial must be adapted to allow that path.
- Obtain the certificate before enabling a template that references its paths. For wildcard names use DNS-01 with narrowly scoped DNS credentials.
- Test renewal and the deploy hook, validate Nginx before reload and monitor certificate expiry. A timer alone does not prove successful renewal.
This can be either HTTP-01 or DNS-01 based, depending on your DNS setup and whether you need wildcards. For a practical deep dive, including multi-domain and wildcard strategies, see our guide on Let’s Encrypt wildcard SSL automation.
Monitoring and Alerts
Add monitoring with explicit metric and log destinations, network restrictions and credentials. Labels help group targets; verify that the expected target and service are actually observed:
- Terraform metadata can identify environment and role; ensure the discovery system actually reads those values.
- Ansible can install a Node Exporter and a supported log collector. Promtail has reached end of life; use a supported alternative such as Grafana Alloy with its own tested configuration.
- Prometheus scrapes metrics; logs need a log backend such as Loki. Grafana queries those data sources. Test an external HTTP check plus controlled alert firing and recovery.
If you want to set up a basic monitoring stack quickly, we have a beginner-friendly walkthrough on VPS monitoring and alerts with Prometheus, Grafana and Uptime Kuma. Turn the required configuration into your own tested roles, and attach them only to the appropriate hosts.
Backups and Off-Site Storage
Automate backup installation, schedules, retention and reporting, and test the restore procedure. Infrastructure code does not contain the live application data, database consistency policy or recovered encryption keys.
- Choose a supported repository/backend: restic supports S3-compatible storage; Borg’s local/SSH repository model is different and should not be treated as a native interchangeable S3 backend.
- Store credentials and encryption keys securely with a tested recovery process. Define application-consistent capture and retention independently of the server rebuild.
- Use reviewed systemd timers or cron jobs, monitor job failures and run a restore test into an isolated target. Measure RTO/RPO with data, DNS and application acceptance included.
We have a dedicated guide on offsite backups with restic/Borg and S3-compatible storage (versioning, encryption and retention) that can inform your own backup roles and restore tests.
Common Pitfalls and How to Avoid Them
The most useful failure checks cover provider access, state isolation, SSH readiness, idempotence and secrets. Keep failures visible; a wrapper that continues after an error can configure stale or unintended hosts.
Forgetting About SSH Connectivity
Terraform might successfully create a VPS that Ansible cannot reach. Common reasons:
- Firewall or security group does not allow SSH from your Ansible runner.
- The default username (e.g.
ubuntu,debian,root) is different from what you assumed. - Your SSH key was not injected correctly (cloud-init misconfiguration).
Verify routing, host identity, key-only SSH and sudo through the real management path. Wait for cloud-init to finish before APT work; its nonzero result requires inspection. A successful Terraform apply alone does not establish any of these conditions.
Non-Idempotent Ansible Tasks
Idempotence means that applying the same intended state does not introduce unnecessary changes; it is a property to test, not an unconditional guarantee of every Ansible task. Common sources of drift include:
- Using shell commands that append to files on every run.
- Downloading archives into the same directory without cleanup.
- Manipulating configuration files with fragile
sedorlineinfilerules.
Prefer modules with explicit state and documented idempotence, such as ansible.builtin.apt, ansible.builtin.user, ansible.builtin.template and community.general.ufw. Run twice on an isolated target and investigate unexpected changes, including cache refreshes, generated files and service operations. A clean syntax check does not replace these runtime checks.
Mixing Manual Changes with Automation
It is tempting to “just tweak one thing” directly on a live server. Over time these manual edits diverge from what your Ansible roles expect, and future runs might undo your fixes or fail in surprising ways.
Record the intended configuration in code and review changes before deployment. After an emergency manual fix, reconcile it into the source and verify the next plan/playbook so automation does not silently undo it. Apply the same process to security policy and firewall changes.
Secrets Management
Never hard-code passwords, API keys or database credentials in plain-text Ansible vars or Terraform files. Use at least:
- Ansible Vault encrypts stored data; decrypted runtime values still need careful logging and permissions. Use
no_logwhere appropriate and keep CI artifacts restricted. - Provider credentials and private SSH keys belong outside this repository. Terraform
sensitive = truemasks some displays but does not encrypt state or saved plans;output -jsoncan expose sensitive outputs. - Use a backend with explicitly configured access controls, encryption, versioning and supported locking; an “S3-compatible” label does not establish every Terraform backend feature. Test state/key recovery separately from application backups.
.gitignore
infra/.terraform/
infra/*.tfstate*
infra/*.tfplan
infra/terraform.tfvars
config/inventory/hosts.json
config/collections/
.venv/
# Store provider credentials and private SSH keys outside this repository.
If your environment uses a dedicated secret manager or SOPS/age, include its access and recovery procedure in the runbook. Ignoring a filename does not remove a secret already committed to Git; revoke exposed credentials and address retained copies.
Where This Fits in Your Hosting Strategy with dchost
Not every project needs fully automated VPS builds from day one. For a single small website, a manually configured VPS or a managed hosting solution is often enough. Automation starts to shine when:
- You manage multiple environments (development, staging, production).
- You host several projects or many clients on dchost VPS or dedicated servers.
- You need to scale horizontally during campaigns or seasonal peaks.
- You must comply with strict security/audit requirements and prove how servers are configured.
Ansible roles can be reused across compatible VPS, dedicated and self-managed virtualized hosts once SSH, privilege and OS assumptions are verified. Terraform provisioning depends on a real supported API and your authority to use it; physical hardware or a DCHost plan alone does not supply that integration. Data migration and service cutover remain separate work.
If you are unsure which base platform—VPS versus dedicated—is the best starting point for your Terraform + Ansible stack, our comparison on choosing between dedicated servers and VPS for your business can help clarify the trade-offs.
Bringing It All Together
Terraform and Ansible make the intended infrastructure and host configuration reviewable. In this example, an authorized OpenStack deployment produces a checked inventory, while existing suitable DCHost hosts can start directly with Ansible. Base/web configuration and SSH/firewall policy have separate acceptance gates. Scaling requires a deliberate resource change; disaster recovery also requires restored data, credentials and application checks.
Start with one isolated host and keep the source, provider lock and deployment evidence together. Add DNS, TLS, monitoring and backup roles as their access, failure and recovery paths are tested. Revisit tool versions and image/package policy periodically; reproducible input does not remove the need for maintenance.
Choose the hosting platform from the workload, administrative access, region, backup and monitoring requirements. Review these requirements with DCHost when selecting resources, and confirm any needed API integration independently before designing provisioning around it.





