Setting up a proxmox host
- Setting up a proxmox host
Building the hypervisor the lab runs on
This page sets up the Proxmox VE host that hosts the DevSecOps lab: an SDN zone with SNAT and DHCP for outbound internet, an isolated internal bridge for attack traffic, and two cloud-init templates (Ubuntu and Kali) that Terraform clones into the three lab VMs. Most of it is automated by create-templates.sh; the manual steps are kept so you understand what the script does.
⚠️ Two networks on purpose.
vnet0(SNAT, 172.20.100.0/24) gives VMs outbound internet for updates; theinternalbridge (10.10.10.0/24, no uplink, no DHCP) carries the attack and monitoring traffic and is deliberately isolated. Don’t bridge them — the isolation is what keeps lab exploits off the real network.
Initial Setup (already completed)
- Set up Proxmox environment (this is already done)
-
Add student@pam user (also already done):
$ useradd -m student $ pveum user add student@pam -comment "Summer 2025" $ pveum acl modify / -user student@pam -role Administrator $ echo "student:$(head /dev/urandom | LC_ALL=C tr -dc 'A-Za-z0-9!@#$%^&*()_+{}|:<>?' | head -c 32)" >| ~/passwd $ cat ~/passwd | chpasswd student $ usermod -aG sudo student
Creating Templates
Run the following script on the Proxmox host. It downloads the images if they are not already present, extracts the Kali archive, and creates both templates. It is safe to re-run — existing images and templates are skipped.
#!/usr/bin/env bash
# create-templates.sh — download images and create Proxmox templates for the DevSecOps lab
# Run this on the Proxmox host before running Terraform.
# Safe to re-run — existing images and templates are skipped.
set -euo pipefail
STORAGE="local-lvm"
IMG_DIR="/var/lib/vz/images"
# ── FILL THIS IN before running ───────────────────────────────────────────────
# SSH *public* key injected into the kali user's authorized_keys so you (and
# Ansible) can log into the Kali template. Paste the full one-line contents of
# your public key, e.g. the output of: cat ~/.ssh/id_ed25519.pub
KALI_SSH_PUBKEY="REPLACE_WITH_YOUR_SSH_PUBLIC_KEY"
# ── Prerequisites ─────────────────────────────────────────────────────────────
if ! command -v 7z &>/dev/null; then
echo "Installing 7zip..."
apt-get install -y 7zip
fi
if ! command -v virt-customize &>/dev/null; then
echo "Installing libguestfs-tools..."
apt-get install -y libguestfs-tools
fi
# ── SDN: Simple zone with SNAT + DHCP (creates vnet0) ─────────────────────────
# Scripts the manual GUI steps in proxmox.md. vnet0 must exist before the
# templates below reference it as their bridge.
ZONE="SNAT"
VNET="vnet0"
SUBNET="172.20.100.0/24"
GATEWAY="172.20.100.1"
DHCP_START="172.20.100.100"
DHCP_END="172.20.100.150"
# dnsmasq is required for SDN automatic DHCP, but its own service must be off —
# PVE runs a separate per-zone dnsmasq instance instead.
if ! command -v dnsmasq &>/dev/null; then
echo "Installing dnsmasq (for SDN DHCP)..."
apt-get install -y dnsmasq
fi
systemctl disable --now dnsmasq 2>/dev/null || true
if pvesh get /cluster/sdn/vnets/"$VNET" &>/dev/null; then
echo "SDN vnet ${VNET} already exists — skipping SDN setup"
else
echo "Creating SDN simple zone ${ZONE}, vnet ${VNET}, and SNAT subnet..."
# Simple zone, automatic DHCP via dnsmasq, built-in pve IPAM
pvesh create /cluster/sdn/zones \
--type simple --zone "$ZONE" --ipam pve --dhcp dnsmasq
# VNet inside the zone — this is the bridge the templates attach to
pvesh create /cluster/sdn/vnets --vnet "$VNET" --zone "$ZONE"
# Subnet with SNAT (outbound masquerade) + DHCP range
pvesh create /cluster/sdn/vnets/"$VNET"/subnets \
--subnet "$SUBNET" --type subnet \
--gateway "$GATEWAY" --snat 1 \
--dhcp-range "start-address=${DHCP_START},end-address=${DHCP_END}"
# Apply pending SDN changes and reload host network config (GUI "Apply")
pvesh set /cluster/sdn
echo " Done: SDN ${ZONE}/${VNET} with SNAT on ${SUBNET}"
fi
# ── Internal bridge (isolated 10.10.10.0/24 lab network) ──────────────────────
# A plain Linux bridge with no physical ports: VMs attached to it talk only to
# each other (attacks, Wazuh agent traffic, Suricata). Static IPs are assigned
# by Ansible — there is deliberately no gateway or DHCP on this segment.
if grep -q "^iface internal " /etc/network/interfaces 2>/dev/null; then
echo "Internal bridge already configured — skipping"
else
echo "Creating isolated internal bridge..."
cat >> /etc/network/interfaces << 'EOF'
auto internal
iface internal inet manual
bridge-ports none
bridge-stp off
bridge-fd 0
EOF
ifreload -a
echo " Done: bridge 'internal' (isolated, no uplink)"
fi
# ── Host firewall: masquerade vnet0 (nftables) ────────────────────────────────
# Masquerades outbound traffic from vnet0. (SDN SNAT already does this; kept as
# belt-and-suspenders.) IP forwarding is required for NAT to route. The SSH
# port-forward into a lab VM is left commented in the config below — enable it
# only after moving the host sshd off :22 (see proxmox.md).
echo "Configuring nftables NAT (masquerade vnet0)..."
cat > /etc/nftables.conf << 'EOF'
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input { type filter hook input priority filter; }
chain forward { type filter hook forward priority filter; }
chain output { type filter hook output priority filter; }
}
table ip nat {
chain prerouting {
type nat hook prerouting priority -100; policy accept;
# Optional: forward host SSH to a lab VM (move host sshd off :22 first).
# Set <HOST_IP> to the vmbr0 address, then uncomment:
# ip daddr <HOST_IP> tcp dport 22 dnat to 172.20.100.100:22
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "vnet0" masquerade
}
}
EOF
# Forwarding must be on for NAT to route between bridges
echo "net.ipv4.ip_forward=1" > /etc/sysctl.d/99-lab-forward.conf
sysctl -p /etc/sysctl.d/99-lab-forward.conf
nft -f /etc/nftables.conf # validate + apply immediately
systemctl enable --now nftables # load /etc/nftables.conf at every boot
echo " Done: nftables masquerade applied; nftables.service enabled"
# ── Ubuntu 26.04 (template VMID 1001) ────────────────────────────────────────
UBUNTU_IMG="${IMG_DIR}/ubuntu-26.04-server-cloudimg-amd64.img"
if [[ ! -f "$UBUNTU_IMG" ]]; then
echo "Downloading Ubuntu 26.04 cloud image..."
curl -L --progress-bar -o "$UBUNTU_IMG" \
https://cloud-images.ubuntu.com/releases/resolute/release/ubuntu-26.04-server-cloudimg-amd64.img
fi
if qm status 1001 &>/dev/null; then
echo "template-ubuntu-26.04 (1001) already exists — skipping"
else
echo "Creating template-ubuntu-26.04..."
qemu-img resize "$UBUNTU_IMG" 32G
qm create 1001 \
--name "template-ubuntu-26.04" \
--ostype l26 --memory 4096 --agent 1 \
--bios seabios --machine q35 --cpu host \
--sockets 1 --cores 4 \
--vga serial0 --serial0 socket \
--net0 virtio,bridge=vnet0
qm importdisk 1001 "$UBUNTU_IMG" "${STORAGE}"
qm set 1001 \
--scsihw virtio-scsi-pci \
--virtio0 "${STORAGE}:vm-1001-disk-0,discard=on" \
--boot order=virtio0 \
--ide2 "${STORAGE}:cloudinit" \
--ipconfig0 ip=dhcp
# cloud-init vendor config: passwordless sudo for the sudo group + qemu-guest-agent
mkdir -p /var/lib/vz/snippets
cat > /var/lib/vz/snippets/ubuntu-vendor.yaml << 'EOF'
#cloud-config
write_files:
- path: /etc/sudoers.d/90-nopasswd-sudo
content: "%sudo ALL=(ALL) NOPASSWD:ALL\n"
permissions: "0440"
runcmd:
- apt update
- apt install -y qemu-guest-agent
- systemctl enable --now qemu-guest-agent
EOF
qm set 1001 --cicustom "vendor=local:snippets/ubuntu-vendor.yaml"
qm cloudinit update 1001
qm template 1001
echo " Done: template-ubuntu-26.04 (1001)"
fi
# ── Kali (template VMID 1002) ─────────────────────────────────────────────────
KALI_QCOW="$(ls "${IMG_DIR}"/kali-linux-*.qcow2 2>/dev/null | head -1 || true)"
if [[ -z "$KALI_QCOW" ]]; then
KALI_7Z="$(ls "${IMG_DIR}"/kali-linux-*.7z 2>/dev/null | head -1 || true)"
if [[ -z "$KALI_7Z" ]]; then
echo "Downloading Kali QEMU image (~4 GB)..."
curl -L --progress-bar -o "${IMG_DIR}/kali-linux-2026.1-qemu-amd64.7z" \
https://cdimage.kali.org/kali-2026.1/kali-linux-2026.1-qemu-amd64.7z
KALI_7Z="${IMG_DIR}/kali-linux-2026.1-qemu-amd64.7z"
fi
echo "Extracting Kali image..."
7z e "$KALI_7Z" -o"${IMG_DIR}"
KALI_QCOW="$(ls "${IMG_DIR}"/kali-linux-*.qcow2 | head -1)"
fi
if qm status 1002 &>/dev/null; then
echo "template-kali (1002) already exists — skipping"
else
if [[ "$KALI_SSH_PUBKEY" == "REPLACE_WITH_YOUR_SSH_PUBLIC_KEY" || -z "$KALI_SSH_PUBKEY" ]]; then
echo "ERROR: set KALI_SSH_PUBKEY at the top of this script before building the Kali template." >&2
exit 1
fi
echo "Customizing Kali image..."
virt-customize -a "$KALI_QCOW" \
--run-command 'echo "%sudo ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/90-nopasswd-sudo && chmod 0440 /etc/sudoers.d/90-nopasswd-sudo' \
--run-command 'systemctl enable ssh' \
--ssh-inject "kali:string:${KALI_SSH_PUBKEY}"
echo "Creating template-kali..."
qm create 1002 \
--name "template-kali" \
--ostype l26 --memory 4096 --agent 1 \
--bios seabios --machine q35 --cpu host \
--sockets 1 --cores 2 \
--vga serial0 --serial0 socket \
--net0 virtio,bridge=vnet0
qm importdisk 1002 "${KALI_QCOW}" "${STORAGE}"
qm set 1002 \
--scsihw virtio-scsi-pci \
--virtio0 "${STORAGE}:vm-1002-disk-0,discard=on" \
--boot order=virtio0 \
--ide2 "${STORAGE}:cloudinit" \
--ipconfig0 ip=dhcp
qm cloudinit update 1002
qm template 1002
echo " Done: template-kali (1002)"
fi
echo ""
echo "Templates ready. Proceed to Terraform."
Save as create-templates.sh and run it on the Proxmox host:
chmod +x create-templates.sh
sudo bash create-templates.sh
Running Terraform from Your Local Machine
The Terraform configuration in secdevops/devsecops-lab/ provisions the three lab VMs by cloning the templates above. Run it from your local workstation — not from the Proxmox host.
Prerequisites on your local machine
- OpenTofu (or Terraform ≥ 1.5) installed
- Network access to the Proxmox host on port 8006
Configure the provider to point at your Proxmox host
Copy providers.tf and edit the API URL and credentials. Do not commit credentials to the repo — use a terraform.tfvars file (already in .gitignore) or environment variables.
providers.tf — set pm_api_url to your Proxmox host IP and read the token from variables:
provider "proxmox" {
pm_debug = true
pm_tls_insecure = true
pm_api_url = "https://<PROXMOX_HOST_IP>:8006/api2/json"
# API token auth — values come from terraform.tfvars (gitignored)
pm_api_token_id = var.pm_api_token_id
pm_api_token_secret = var.pm_api_token_secret
}
The pm_api_token_id and pm_api_token_secret variables are declared (as sensitive) in variables.tf.
Creating an API token (recommended over password):
# On the Proxmox host
sudo pveum user token add student@pam terraform --privsep=0
# Note the token secret shown — it is only displayed once
# if the token already exists, delete it, then re-run the above command
# sudo pveum user token delete student@pam terraform
Create terraform.tfvars
Copy terraform.tfvars.example to secdevops/devsecops-lab/terraform.tfvars (this file is in .gitignore — never commit it) and fill in your values:
# Cloud-init user + SSH key for the Ubuntu VMs
student_username = "your-username"
student_ssh_pubkey = "ssh-ed25519 AAAA... your-key-here"
# Proxmox API token
pm_api_token_id = "student@pam!terraform"
pm_api_token_secret = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
Note: unlike the shell
exportform, the!needs no special quoting here —terraform.tfvarsis HCL, not bash, so the double quotes are fine. The token secret is also written toterraform.tfstatein plaintext, so keep state out of git too (it is gitignored).
Run Terraform
cd secdevops/devsecops-lab
tofu init
tofu plan # review what will be created
tofu apply
After apply, take baseline snapshots on the Proxmox host:
for vmid in 200 201 202; do
qm snapshot $vmid baseline --description "Pre-course baseline $(date +%Y-%m-%d)"
done
Post-Install: Ansible Prerequisites
Ubuntu 26.04 ships sudo-rs (Rust sudo) which has prompt-detection incompatibilities with Ansible’s become plugin. All three VMs require passwordless sudo before the playbook will run cleanly. SSH into each VM and run the appropriate command for that host’s default user:
| VM | Default user | Command |
|---|---|---|
| ubuntu-server | student |
echo "student ALL=(ALL) NOPASSWD: ALL" \| sudo tee /etc/sudoers.d/student && sudo chmod 440 /etc/sudoers.d/student |
| wazuh | student |
echo "student ALL=(ALL) NOPASSWD: ALL" \| sudo tee /etc/sudoers.d/student && sudo chmod 440 /etc/sudoers.d/student |
| kali | kali |
echo "kali ALL=(ALL) NOPASSWD: ALL" \| sudo tee /etc/sudoers.d/kali && sudo chmod 440 /etc/sudoers.d/kali |
After this, the Ansible playbook runs without requiring a become password prompt.
Key takeaways
- The host provides two networks:
vnet0(SNAT + DHCP) for outbound internet and an isolatedinternalbridge with no uplink for attack and monitoring traffic. create-templates.shis idempotent — it scripts the SDN/SNAT setup, the nftables masquerade, IP forwarding, and both cloud-init templates, and is safe to re-run.- Provision the VMs with Terraform/OpenTofu from your workstation (not the host); keep
terraform.tfvarsandterraform.tfstateout of git since both hold the API token in plaintext. - Prefer a scoped Proxmox API token over password auth, and snapshot every VM as
baselineright afterapplyso labs can be reset. - Ubuntu 26.04’s
sudo-rsbreaks Ansible’s become prompt detection — grant passwordless sudo on each VM before running the playbook.
References
- Proxmox VE — Setup Simple Zone with SNAT and DHCP — https://pve.proxmox.com/wiki/Setup_Simple_Zone_With_SNAT_and_DHCP
- Proxmox VE — Software Defined Network (SDN) — https://pve.proxmox.com/wiki/Software-Defined_Network
- OpenTofu — Installation — https://opentofu.org/docs/intro/install/
- Telmate Proxmox Terraform provider — https://registry.terraform.io/providers/Telmate/proxmox/latest/docs
- Ubuntu cloud images — https://cloud-images.ubuntu.com/
Related course pages: Infrastructure as Code · Firewalls · SSH setup
🛠️ Maintenance note: Image URLs and versions age fast — the Ubuntu 26.04 (
resolute) cloud image and Kali 2026.1 7z paths here will need bumping each release, as will thefree-pmx-no-subscription.debversion used to remove the subscription nag. Proxmox VE 8.x usesnftables; older hosts may still default toiptables. HashiCorp’s Terraform BSL relicensing (2023) is why this lab standardizes on OpenTofu (tofu) — thetelmate/proxmoxprovider remains compatible. Verify thepveshSDN flags against the installed PVE version, since the SDN API is still evolving.