courses

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; the internal bridge (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)

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

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 export form, the ! needs no special quoting here — terraform.tfvars is HCL, not bash, so the double quotes are fine. The token secret is also written to terraform.tfstate in 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

References


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 the free-pmx-no-subscription .deb version used to remove the subscription nag. Proxmox VE 8.x uses nftables; older hosts may still default to iptables. HashiCorp’s Terraform BSL relicensing (2023) is why this lab standardizes on OpenTofu (tofu) — the telmate/proxmox provider remains compatible. Verify the pvesh SDN flags against the installed PVE version, since the SDN API is still evolving.