Skip to Content
The HIC Learning Exchange begins July 13, 2026. View the agenda
LabItem 3 of 22 · 2 hr

Lab 1: Provision the VM & Configure with Ansible

In this lab you create the guest VM on the KVM host, make it reachable from the internet, and use Ansible to bring the OS to a secure, Docker-ready baseline.

Part 2 of 4Overview · Lab 2: Deploy & Release · Operations & Troubleshooting

What you'll learn
  • Provision a KVM guest VM with a static private IP and a snapshot-protected baseline
  • Forward inbound ports 80/443 from the KVM host to the VM and verify external reachability
  • Choose between a manual nginx reverse proxy and an Ansible-managed one
  • Configure Ansible inventory and run the baseline and Docker playbooks against the VM
  • Take and restore VM snapshots as rollback points
Before you start

On the KVM host:

  • KVM + libvirt installed (virsh, virt-install) with the default NAT bridge (virbr0, 192.168.122.0/24)
  • A static public IP with the ability to port-forward, plus iptables and netfilter-persistent
  • Ubuntu 22.04 LTS Server ISO available on the host
  • SSH + sudo access to the host, and serial-console access for the OS install

On your control machine:

  • Ansible 2.9+ installed, with this repo’s inventory/ and playbooks/ checked out
  • SSH key material for the VM’s admin user (created during Phase 1’s installer step)
Your lab values

Fill these once and every command on this page updates. Values stay in your browser only.

A note on values in this guide. Values you need to personalize for this lab — VM_IP, SSH_KEY_PATH, SSL_DOMAIN, SSL_EMAIL — appear as __NAME__-style tokens and are filled in automatically once you enter them in the Your lab values panel above. Other values (e.g. <VM_NAME>, <INTERFACE_NAME>) aren’t tracked by that panel — substitute those by hand with your environment’s real values. Keep the real ones in your secret store / password manager, not in these pages.

Download the lab files (.zip)

Phase 1 — Provision the VM on the KVM Host

All commands in this phase run on the KVM host unless noted.

The guest VM sits on libvirt’s private NAT subnet and is not reachable from the internet until you add port-forwarding (Step 1.4). The end state of this phase is a running, snapshot-protected VM with a stable private IP, reachable from the internet on ports 80/443.

Step 1.1 — Define VM specifications

Pick resource values for the workload. Starting points:

ParameterExample
VM name<VM_NAME>
vCPUs2–8
RAM4–64 GB
Disk50–500 GB (qcow2)
OSUbuntu 22.04 LTS Server
Networklibvirt default NAT
Static IP__VM_IP__ (a free address in 192.168.122.0/24)
Admin userany non-root username

Run osinfo-query os | grep ubuntu to find the exact --os-variant string for your ISO.

Step 1.2 — Create the VM

sudo virt-install \ --name <VM_NAME> \ --ram <RAM_IN_MB> \ --vcpus <NUM_VCPUS> \ --disk path=/var/lib/libvirt/images/<VM_NAME>.qcow2,size=<DISK_GB>,format=qcow2 \ --os-variant ubuntu22.04 \ --network network=default \ --graphics none \ --console pty,target_type=serial \ --location /path/to/ubuntu-22.04-live-server-amd64.iso,kernel=casper/vmlinuz,initrd=casper/initrd \ --extra-args 'console=ttyS0,115200n8 serial'

Attach to the serial console to run the installer:

sudo virsh console <VM_NAME>

Installer choices that matter:

  • Use the full disk unless you need custom partitioning.
  • Create a dedicated non-root admin user with a strong password (do not use root).
  • Enable the OpenSSH server when prompted.
  • Detach from the console with Ctrl+] once installation completes.

Step 1.3 — Configure a static IP

DHCP addresses can change on reboot and break your port-forwarding rules, so pin a static IP.

Find the interface name (usually enp1s0 on KVM):

ip link show

Edit /etc/netplan/00-installer-config.yaml inside the VM and replace its contents:

etc/netplan/00-installer-config.yaml
network: ethernets: <INTERFACE_NAME>: dhcp4: no addresses: - __VM_IP__/24 gateway4: <GATEWAY_IP> nameservers: addresses: [8.8.8.8, 8.8.4.4] version: 2

Apply and verify:

sudo netplan apply ip addr show <INTERFACE_NAME> # static IP present? ping -c 3 8.8.8.8 # internet works?

Step 1.4 — Port-forward from the host to the VM

(These run on the KVM host, not the VM.) Redirect inbound public traffic to the VM’s private IP.

# DNAT: rewrite destination of inbound :80 / :443 to the VM sudo iptables -t nat -I PREROUTING 1 -i <PUBLIC_IFACE> -p tcp --dport 80 -j DNAT --to-destination __VM_IP__:80 sudo iptables -t nat -I PREROUTING 1 -i <PUBLIC_IFACE> -p tcp --dport 443 -j DNAT --to-destination __VM_IP__:443 # FORWARD: allow the routed traffic (insert at position 1, BEFORE libvirt's REJECT rules) sudo iptables -I FORWARD 1 -p tcp -d __VM_IP__ --dport 80 -j ACCEPT sudo iptables -I FORWARD 1 -p tcp -d __VM_IP__ --dport 443 -j ACCEPT # Persist across reboots (iptables rules are volatile by default) sudo apt install netfilter-persistent -y sudo netfilter-persistent save

Always -I FORWARD 1 (insert), never -A (append). Appending puts your rule after libvirt’s default REJECT rule, which silently drops the forwarded traffic.

Verify:

sudo iptables -t nat -L PREROUTING --line-numbers sudo iptables -L FORWARD --line-numbers

Step 1.5 — (Optional) Host-level reverse proxy

The VM provisioning doc includes a manual nginx reverse-proxy setup on the VM (ports 80/443 → 127.0.0.1:<APP_PORT>, initially with a self-signed “snakeoil” cert). You have two options and should pick one — don’t do both:

  • Option A (recommended for this platform): skip the manual nginx here and let Ansible playbook 02 + 04 (Phase 2) install and configure nginx + a real Let’s Encrypt certificate. This keeps the VM’s web tier reproducible.
  • Option B: if you want the VM reachable before running Ansible, do the manual nginx setup now, then let the Ansible nginx playbook take over later.

Either way, the reverse proxy’s job is the same: terminate 80/443 on the VM and forward to the application container ports (Prefect 14200, Superset 18088, Dagster 13000 — see Phase 4).

Step 1.6 — Take a baseline snapshot

Capture a clean, known-good state you can roll back to.

sudo virsh snapshot-create-as \ --domain <VM_NAME> \ --name "baseline-post-provision" \ --description "Clean provision: static IP set, ports 80/443 forwarded" \ --atomic

Revert later if needed:

sudo virsh shutdown <VM_NAME> sudo virsh snapshot-revert --domain <VM_NAME> --snapshotname baseline-post-provision sudo virsh start <VM_NAME>

There is a fully automated version of all of Phase 1 (provision-vm.sh) — edit its config block and run sudo bash provision-vm.sh.

Phase 1 exit checklist

#CheckCommand
1VM runningsudo virsh list --all
2Static IP present & survives rebootip addr show <IFACE> then sudo virsh reboot <VM_NAME>
3DNAT rules presentsudo iptables -t nat -L PREROUTING --line-numbers
4FORWARD ACCEPT rules at topsudo iptables -L FORWARD --line-numbers
5External reachability (from an outside machine)nc -zv <PUBLIC_IP> 80 / 443
6Baseline snapshot existssudo virsh snapshot-list --domain <VM_NAME>

Gotcha: testing nc -zv <PUBLIC_IP> from inside the VM hangs — libvirt NAT doesn’t support hairpin routing (a VM reaching its own public IP). Always test from an external machine.

CheckpointVM provisioned and reachable
You should see:
$ sudo virsh snapshot-list --domain <VM_NAME> Name Creation Time ------------------------------------------------------- baseline-post-provision 2026-07-01 10:12:03 -0400 $ nc -zv <PUBLIC_IP> 80 Connection to <PUBLIC_IP> 80 port [tcp/http] succeeded!
Not seeing this?
  • virsh snapshot-list is empty — Step 1.6 wasn’t run, or --atomic failed silently. Re-run the snapshot-create-as command and check for errors.
  • nc -zv times out — you tested from inside the VM (hairpin routing isn’t supported) or the FORWARD rule was appended (-A) instead of inserted at position 1. Check with sudo iptables -L FORWARD --line-numbers.

Phase 2 — Configure the VM with Ansible

Runs from your control machine, targeting the VM.

Now that the VM exists and is reachable, use the Ansible playbooks to bring the OS to a known, secure baseline and install the runtime the warehouse containers need (Docker). This section is the operational sequence.

Step 2.1 — Point the inventory at the VM

Edit inventory/hosts.yml:

inventory/hosts.yml
all: vars: ansible_user: <VM_ADMIN_USER> # the non-root user created in Phase 1 ansible_ssh_private_key_file: __SSH_KEY_PATH__ ansible_host_key_checking: false hosts: server1: ansible_host: __VM_IP__ # or the public IP if reaching via port-forward

Confirm connectivity:

ansible all -m ping
CheckpointAnsible can reach the VM
You should see:
server1 | SUCCESS => { "ansible_facts": { "discovered_interpreter_python": "/usr/bin/python3" }, "changed": false, "ping": "pong" }
Not seeing this?
  • UNREACHABLE! — the IP or SSH key path in inventory/hosts.yml is wrong, or the VM’s firewall isn’t up yet (only Step 1 provisioned it; port 22 is open by default on Ubuntu Server).
  • Permission denied (publickey)ansible_user doesn’t match the non-root user created during the installer in Step 1.2, or the key file permissions are too open (chmod 600).

Step 2.2 — Run the playbooks in order

The playbooks are numbered in run order. For the warehouse VM, the essential ones are 01 (baseline) and 05 (Docker) — the app ships its own nginx/Postgres inside containers or uses the external Postgres, so 02/03/04/06 are optional depending on how you expose the stack.

# 1. Base OS: updates, essentials, UFW firewall, fail2ban (REQUIRED) ansible-playbook playbooks/01-server-setup.yml --ask-become-pass # 2. nginx — only if you want Ansible (not manual Phase 1.5) to manage the reverse proxy ansible-playbook playbooks/02-nginx.yml --ask-become-pass # 4. SSL/Let's Encrypt — only if a domain points at the public IP (needs playbook 02 first) ansible-playbook playbooks/04-ssl-certbot.yml \ -e ssl_domain="__SSL_DOMAIN__" -e ssl_email="__SSL_EMAIL__" --ask-become-pass # 5. Docker + Docker Compose (REQUIRED — the warehouse runs as containers) ansible-playbook playbooks/05-docker.yml --ask-become-pass # On Ubuntu 20.04 "focal" use: playbooks/05-docker-focal.yml

Playbooks 03 (PostgreSQL) and 06 (create-database) install a local Postgres on the VM. The MNCH warehouse instead uses an external Postgres server (see Phase 4). Only run 03/06 if you intend to host the databases on the VM itself — otherwise skip them and point the app at your external DB via secrets in Phase 3.

Step 2.3 — Firewall note

Playbook 01 enables UFW with a deny-by-default incoming policy and opens only SSH (22). Playbook 02 opens 80/443. If you expose the container ports directly (14200/18088/13000) instead of proxying through nginx, open them explicitly:

ansible all -b -m ufw -a "rule=allow port=18088 proto=tcp" # example: Superset

Phase 2 exit checklist

  • ansible all -m ping succeeds
  • 01 ran: ansible all -b -m command -a "ufw status" shows active, SSH allowed
  • 05 ran: ansible all -m command -a "docker --version" and docker-compose --version succeed
  • (if used) nginx active and 80/443 open
CheckpointOS baseline ready for the application
You should see:
$ ansible all -b -m command -a "ufw status" server1 | CHANGED | rc=0 >> Status: active To Action From -- ------ ---- 22/tcp ALLOW Anywhere $ ansible all -m command -a "docker --version" server1 | CHANGED | rc=0 >> Docker version 24.0.7, build afdd53b
Not seeing this?
  • ufw status shows inactive — playbook 01 didn’t run to completion; re-run ansible-playbook playbooks/01-server-setup.yml --ask-become-pass and check for task failures.
  • docker: command not found — playbook 05 failed, or you’re on Ubuntu 20.04 “focal” and need playbooks/05-docker-focal.yml instead of 05-docker.yml.

Take a new snapshot now (virsh snapshot-create-as … --name "post-ansible-config") so you have a rollback point before layering the application on top.

Clean up

If you’re done with this lab environment, tear down what Phases 1–2 created:

# remove the port-forwarding rules added in Step 1.4 sudo iptables -t nat -D PREROUTING -i <PUBLIC_IFACE> -p tcp --dport 80 -j DNAT --to-destination __VM_IP__:80 sudo iptables -t nat -D PREROUTING -i <PUBLIC_IFACE> -p tcp --dport 443 -j DNAT --to-destination __VM_IP__:443 sudo iptables -D FORWARD -p tcp -d __VM_IP__ --dport 80 -j ACCEPT sudo iptables -D FORWARD -p tcp -d __VM_IP__ --dport 443 -j ACCEPT sudo netfilter-persistent save # persist the removal # stop and delete the VM along with its snapshots sudo virsh destroy <VM_NAME> # power off sudo virsh undefine <VM_NAME> --remove-all-storage --snapshots-metadata

If you’re continuing to Lab 2, skip this — keep the VM and its snapshots as rollback points.

What’s next

The VM is provisioned and configured. Continue to Lab 2: CI/CD, Warehouse Stack & Release to wire up the deployment pipeline and bring the warehouse application online.