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 4 — Overview · Lab 2: Deploy & Release · Operations & Troubleshooting
- 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
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
iptablesandnetfilter-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/andplaybooks/checked out - SSH key material for the VM’s admin user (created during Phase 1’s installer step)
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.
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:
| Parameter | Example |
|---|---|
| VM name | <VM_NAME> |
| vCPUs | 2–8 |
| RAM | 4–64 GB |
| Disk | 50–500 GB (qcow2) |
| OS | Ubuntu 22.04 LTS Server |
| Network | libvirt default NAT |
| Static IP | __VM_IP__ (a free address in 192.168.122.0/24) |
| Admin user | any non-root username |
Run
osinfo-query os | grep ubuntuto find the exact--os-variantstring 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 showEdit /etc/netplan/00-installer-config.yaml inside the VM and replace its contents:
network:
ethernets:
<INTERFACE_NAME>:
dhcp4: no
addresses:
- __VM_IP__/24
gateway4: <GATEWAY_IP>
nameservers:
addresses: [8.8.8.8, 8.8.4.4]
version: 2Apply 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 saveAlways -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-numbersStep 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" \
--atomicRevert 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 runsudo bash provision-vm.sh.
Phase 1 exit checklist
| # | Check | Command |
|---|---|---|
| 1 | VM running | sudo virsh list --all |
| 2 | Static IP present & survives reboot | ip addr show <IFACE> then sudo virsh reboot <VM_NAME> |
| 3 | DNAT rules present | sudo iptables -t nat -L PREROUTING --line-numbers |
| 4 | FORWARD ACCEPT rules at top | sudo iptables -L FORWARD --line-numbers |
| 5 | External reachability (from an outside machine) | nc -zv <PUBLIC_IP> 80 / 443 |
| 6 | Baseline snapshot exists | sudo 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.
$ 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-listis empty — Step 1.6 wasn’t run, or--atomicfailed silently. Re-run thesnapshot-create-ascommand and check for errors.nc -zvtimes out — you tested from inside the VM (hairpin routing isn’t supported) or theFORWARDrule was appended (-A) instead of inserted at position 1. Check withsudo 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:
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-forwardConfirm connectivity:
ansible all -m pingserver1 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}Not seeing this?
UNREACHABLE!— the IP or SSH key path ininventory/hosts.ymlis 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_userdoesn’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.ymlPlaybooks 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: SupersetPhase 2 exit checklist
-
ansible all -m pingsucceeds -
01ran:ansible all -b -m command -a "ufw status"shows active, SSH allowed -
05ran:ansible all -m command -a "docker --version"anddocker-compose --versionsucceed - (if used) nginx active and 80/443 open
$ 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 afdd53bNot seeing this?
ufw statusshowsinactive— playbook 01 didn’t run to completion; re-runansible-playbook playbooks/01-server-setup.yml --ask-become-passand check for task failures.docker: command not found— playbook 05 failed, or you’re on Ubuntu 20.04 “focal” and needplaybooks/05-docker-focal.ymlinstead of05-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-metadataIf 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.