Build a Proxmox VM Provisioner

Build a PowerShell platform to provision and clean up Proxmox VMs.

Introduction

30 Second Summary

A request for a small test machine often becomes a trail of manual clicks. The requester is left guessing when the machine is truly ready.

In this project, you will build a reusable PowerShell command that provisions a virtual machine from an approved Proxmox VE template. The workflow follows each asynchronous task from request acceptance to a reported running state.

What You'll Build

One PowerShell command turns an approved request into a running lab VM with its final status printed in your terminal.

By the end of this project, you'll have:

  • A connection check that confirms the lab reports Proxmox VE 9.x after API token authentication.
  • A governed provisioning command that turns an approved small or medium request into a running VM.
  • A Git-tracked lifecycle workflow that previews destructive actions before stopping or deleting the VM.
  • Secret Mission: A preflight wrapper that blocks duplicate VM names before provisioning begins.

Are there any prerequisites?

Your Windows PC needs Visual Studio Code plus Git.

Your Proxmox VE administrator access must support creating an API token for one small VM request from an existing Linux Cloud-Init template.

Before We Start

Before you touch your lab, define the VM request you are automating. Naming its requester, approved size, and purpose gives the platform a predictable contract.

Prepare the Workstation and API Identity

Your platform request needs a consistent command environment before it can control infrastructure. PowerShell provides that environment while an API token gives the automation a non-interactive identity in Proxmox VE.

You will also initialize a Git repository for the platform code. Visual Studio Code will keep the repository files and terminal in one workspace.

In this step, get ready to:
  • Install PowerShell 7.6.5 on Windows.
  • Create a privilege-separated Proxmox VE API token with the required role.
  • Initialize the proxmox-mini-platform Git repository with a local author identity.
Install PowerShell 7.6.5

The provisioning scripts depend on the current PowerShell LTS runtime. Checking the installed version first gives you the right path without reinstalling a working copy.

  • Use Windows search to open a terminal window.
  • Check whether PowerShell 7 is available by running:
pwsh

What does this command do?

The pwsh executable starts PowerShell 7. Its startup banner identifies the installed version.

✔️ I see the required version

You should see PowerShell 7.6.5 in the startup banner. Your workstation now has the runtime used throughout this project.

  • Keep this PowerShell session open for the repository setup later in this step.

PowerShell Is Ready

That is the workstation runtime sorted. Every script in the platform now runs against the expected LTS release.

ⓧ I see an older version

An older PowerShell release can behave differently from the runtime used to verify this project. Install the current LTS release before continuing.

  • Install PowerShell 7.6.5 by running this command:
winget install --id Microsoft.PowerShell --source winget

What does this command do?

The command asks Windows Package Manager to install the Microsoft PowerShell package from the WinGet source. This provides the pwsh executable used by the project.

  • Approve the Windows installation request if one appears.
  • Close the current terminal after the installation completes.
  • Use Windows search to open a new terminal window.
  • Start the newly installed runtime by running:
pwsh

What should I see?

The new PowerShell session should show version 7.6.5 in its startup banner. This confirms that Windows is resolving pwsh to the required installation.

Still Seeing the Older Version?

Close every terminal window before opening a new one because existing windows can retain an older command path.

If the older release still starts, use Windows search to launch PowerShell 7 directly. Help me check which PowerShell installation Windows is starting.

ⓧ Command not found

Windows cannot find the PowerShell 7 executable yet. Install the current LTS package before continuing.

  • Install PowerShell 7.6.5 by running this command:
winget install --id Microsoft.PowerShell --source winget

What does this command do?

Windows Package Manager downloads the Microsoft PowerShell package from the WinGet source. The installation adds the pwsh executable to Windows.

  • Approve the Windows installation request if one appears.
  • Close the current terminal after the installation completes.
  • Use Windows search to open a new terminal window.
  • Start PowerShell 7 by running:
pwsh

What should I see?

The terminal should open a PowerShell session whose startup banner shows version 7.6.5.

Still Missing the pwsh Command?

Close every terminal window so the next one loads the updated command path.

If a new terminal still cannot find the command, use Windows search to launch PowerShell 7 directly. Help me troubleshoot a missing pwsh command after installing PowerShell.

Create the Proxmox VE role and API token

The automation needs permission to inspect templates, clone VMs, change approved sizing, control power, and allocate storage. A custom role groups those capabilities into one reusable policy.

Why Use the Proxmox VE API Directly?

Proxmox VE exposes a first-party JSON API for control-plane requests. Direct requests keep task acceptance and completion visible while you build the platform loop.

A community-maintained Terraform provider is useful for declarative fleet state. This project focuses on API authentication and asynchronous task behavior.

  • Open the Proxmox VE console in your browser.
  • Sign in with an administrator identity.
  • Select Datacenter in the left navigation tree.
  • Select Permissions.
  • Select Roles.

A role defines the actions an identity can perform. The permission paths added afterward define where those actions apply.

  • Click Create above the roles table.
  • Enter NextWorkVmProvisioner as the role name.
  • Select VM.Audit, VM.Clone, VM.Allocate, VM.Config.CPU, VM.Config.Memory, VM.PowerMgmt, and Datastore.AllocateSpace from the Privileges list.
  • Click Create to save the role.

You should see NextWorkVmProvisioner in the roles table with all seven privileges.

The token secret is a one-time credential, so keep your password manager ready before generating it.

  • Return to Permissions under Datacenter.
  • Open the API token management view.
  • Click Add.
  • Select root@pam as the backing user.
  • Enter nextwork as the token identifier.
  • Keep privilege separation enabled.

The next action reveals the secret only once. Keep that value outside the proxmox-mini-platform folder.

  • Click Add to generate the token.
  • Confirm that the full token ID is root@pam!nextwork.
  • Save the one-time token secret in your password manager.
  • Close the token dialog after the secret is stored.

The fiddly credential step is complete. Your token can now authenticate without placing its secret in a script or repository.

  • Open the existing Linux Cloud-Init template in the Proxmox VE console.
  • Identify the storage containing the template disk.
  • Record its permission path as /storage/local-lvm.
  • Return to the Permissions table under Datacenter.
  • Create a permission entry that assigns NextWorkVmProvisioner to root@pam on /vms.
  • Create a permission entry that assigns NextWorkVmProvisioner to root@pam!nextwork on /vms.
  • Create a permission entry that assigns NextWorkVmProvisioner to root@pam on /storage/local-lvm.
  • Create a permission entry that assigns NextWorkVmProvisioner to root@pam!nextwork on /storage/local-lvm.

What Does Privilege Separation Change?

A separated token receives only the permissions shared by the backing user and the token. Assigning the custom role to both identities makes that intersection explicit.

The VM path covers the template and the VM created later. The storage path permits space allocation on the datastore that holds the template disk.

Initialize the local Git repository

The platform code needs one named folder with version history from the start. A repository-local identity records who creates each commit without changing identity settings for your other repositories.

  • Switch back to the PowerShell 7.6.5 session from earlier.
  • Create the proxmox-mini-platform folder on your Desktop by running these commands:
Set-Location -Path "$HOME\Desktop"
New-Item -Path . -Name proxmox-mini-platform -ItemType Directory
Set-Location -Path .\proxmox-mini-platform
Get-Location

What Do These Commands Do?

  • The first command moves the terminal to your Desktop.
  • The second command creates the proxmox-mini-platform directory.
  • The final commands move into the directory and print its full path.

You should see a path ending in Desktop\proxmox-mini-platform.

Folder Already Exists?

If PowerShell reports that the folder exists, move into the existing proxmox-mini-platform folder and confirm that it belongs to this project.

Help me resolve a PowerShell folder creation error.

  • Initialize version control inside proxmox-mini-platform by running:
git init

What Does This Command Do?

The command initializes Git metadata inside proxmox-mini-platform. Future files and commits remain associated with this repository.

Git should report that it initialized an empty repository in the proxmox-mini-platform folder.

Git Initialization Failed?

Confirm that your terminal path ends in proxmox-mini-platform before retrying the command.

Help me troubleshoot git init on Windows.

  • Open the current repository folder in Visual Studio Code by running:
code .

What Does This Command Do?

The code . command opens the current folder as a Visual Studio Code workspace. The dot represents the terminal's current directory.

You should see Visual Studio Code open with proxmox-mini-platform shown as the workspace folder.

VS Code Did Not Open?

Use Windows search to open Visual Studio Code. Select File followed by Open Folder. Choose the proxmox-mini-platform folder on your Desktop.

Help me open my current folder in VS Code.

  • Open the integrated terminal from the View menu in Visual Studio Code.
  • Set the repository-local author name and email by running these commands:
git config user.name "[[GIT_NAME="your name"]]"
git config user.email "[[GIT_EMAIL="you@example.com"]]"

Why Use a Repository-Local Identity?

These commands store the author identity only inside this repository. Your Git identity in other projects remains unchanged.

  • Confirm that Git returns your configured name and email by running:
git config user.name
git config user.email

What Should I See?

Git should print the name and email you entered on separate lines. That output confirms the repository-local identity is available for the first commit.

Identity Values Missing?

Confirm that the Visual Studio Code terminal is inside the proxmox-mini-platform repository. Rerun the two configuration commands with your values.

Help me configure a repository-local Git identity.

Before you run the final checks, which result do you expect from the workstation, repository, and token permission layers?

  • Start a PowerShell 7 process from the Visual Studio Code terminal by running:
pwsh

What Should I See?

The startup banner should identify PowerShell 7.6.5. This confirms that the integrated terminal can reach the required runtime.

  • Confirm that Git recognizes the repository by running:
git status

What Should I See?

Git should report the repository status without saying that the current folder is outside a repository. An empty repository can report that it has no commits yet.

  • Return to the Proxmox VE console.
  • Select the Proxmox VE node in the left navigation tree.
  • Open its Shell view.
  • Check the token's effective permissions by running:
pveum user token permissions root@pam nextwork

What Should I See?

The permission output should include the VM and storage paths you configured. It should list the privileges from NextWorkVmProvisioner as effective permissions for the token.

This output proves that the backing user and separated token share the permissions required by the platform.

Privileges Missing From the Token?

Check that both root@pam and root@pam!nextwork have NextWorkVmProvisioner on the same VM and storage paths.

Help me troubleshoot missing Proxmox VE token permissions.

Your workstation, repository, and automation identity are ready. Next, you will use them to prove that the platform can reach the Proxmox VE control plane.

Prove the Control-Plane Connection

Your workstation now has a privilege-scoped identity for the lab. The next risk is discovering a connection problem during a VM change.

A harmless version request checks the path from PowerShell 7.6.5 to the Proxmox VE control plane. It proves that your API token authentication works before any infrastructure changes.

In this step, get ready to:
  • Create a secure API token header helper.
  • Build a reusable HTTPS request wrapper.
  • Query the node version through the Proxmox VE API.
Create the reusable API module

A PowerShell module groups reusable functions in one file. Your scripts can import the module instead of rebuilding authentication or request logic for every API call.

  • Select the new-file icon beside proxmox-mini-platform in VS Code's file sidebar.
  • Name the file PveApi.psm1.

You should see PveApi.psm1 listed inside proxmox-mini-platform.

  • Add the secure token helper by pasting this code into PveApi.psm1:
Set-StrictMode -Version Latest

function New-PveApiHeader {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$TokenId
    )

    $secureSecret = Read-Host 'Enter the Proxmox VE API token secret' -AsSecureString
    $plainSecret = ConvertFrom-SecureString -SecureString $secureSecret -AsPlainText

    return @{
        Authorization = "PVEAPIToken=$TokenId=$plainSecret"
    }
}

What does this helper do?

  • New-PveApiHeader accepts the token ID that identifies your API token.
  • Read-Host collects the separately recorded secret as a secure string. The secret stays out of the source file.
  • Authorization stores the header format expected by Proxmox VE.
  • Save PveApi.psm1.
  • Confirm that VS Code no longer shows the unsaved-change indicator beside PveApi.psm1.

Seeing syntax warnings?

  • Check that every opening brace has a matching closing brace.
  • Confirm that the token secret itself is absent from PveApi.psm1.
  • Help me check the New-PveApiHeader function for PowerShell syntax errors.

The API wrapper accepts the changing parts of each request as parameters. The function spans the next two connected code blocks.

  • Place your cursor on the second line below the closing brace of New-PveApiHeader.
  • Add the wrapper parameters by pasting this first part:
function Invoke-PveApi {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$PveHost,

        [Parameter(Mandatory)]
        [string]$Path,

        [ValidateSet('Get', 'Post', 'Put', 'Delete')]
        [string]$Method = 'Get',

        [Parameter(Mandatory)]
        [hashtable]$Headers,

        [hashtable]$Body,

        [switch]$SkipCertificateCheck
    )

What do these parameters control?

  • PveHost identifies the Proxmox VE server.
  • Path identifies the requested API endpoint.
  • Method limits requests to the supported HTTP actions.
  • Headers carries the token authentication created by New-PveApiHeader.
  • Body carries form data for requests that change resources.
  • SkipCertificateCheck controls certificate validation for a known lab host.
  • Complete Invoke-PveApi by pasting this second part directly below the parameter block:
    $requestParameters = @{
        Uri         = "https://${PveHost}:8006/api2/json$Path"
        Method      = $Method
        Headers     = $Headers
        ErrorAction = 'Stop'
    }

    if ($PSBoundParameters.ContainsKey('Body')) {
        $requestParameters.Body = $Body
        $requestParameters.ContentType = 'application/x-www-form-urlencoded'
    }

    if ($SkipCertificateCheck) {
        $requestParameters.SkipCertificateCheck = $true
    }

    $response = Invoke-RestMethod @requestParameters
    return $response.data
}

Export-ModuleMember -Function New-PveApiHeader, Invoke-PveApi

How does the request wrapper work?

  • requestParameters builds the HTTPS request for port 8006.
  • PSBoundParameters adds form data only when the caller supplies a request body.
  • Invoke-RestMethod sends the request. It converts the JSON response into a PowerShell object.
  • Export-ModuleMember makes both helper functions available to scripts that import the module.

Before you check the module, which two function names do you expect PowerShell to discover?

  • Save PveApi.psm1.
  • Switch back to the PowerShell terminal panel from earlier.
  • Verify that the module exports both functions by running these commands:
Import-Module .\PveApi.psm1 -Force
Get-Command -Module PveApi

What does this check prove?

Import-Module loads the local module into your current PowerShell session. Get-Command lists the functions that the module exports.

You should see New-PveApiHeader plus Invoke-PveApi in the results. Good progress. Your reusable API layer now loads successfully.

Module not loading?

  • Confirm that the terminal is still inside the proxmox-mini-platform repository.
  • Check that the module filename is exactly PveApi.psm1.
  • Compare the closing braces around Invoke-PveApi with the code above.
  • Help me troubleshoot why PveApi.psm1 does not export both functions.
Query the node version

The node version endpoint provides a read-only control-plane check. A successful response confirms that the host is reachable through the REST API.

Why use the direct API?

The direct API exposes request acceptance plus task behavior without another provider layer. This keeps the project focused on the control loop that a platform must manage.

Terraform is useful for declarative fleet state. This connection test needs the immediate response from the Proxmox VE control plane.

  • Select the new-file icon beside proxmox-mini-platform in VS Code's file sidebar.
  • Name the file Test-PveConnection.ps1.

You should see Test-PveConnection.ps1 beside PveApi.psm1.

  • Add the script parameters plus module import by pasting this first part into Test-PveConnection.ps1:
[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [string]$PveHost,

    [Parameter(Mandatory)]
    [string]$Node,

    [Parameter(Mandatory)]
    [string]$TokenId,

    [switch]$SkipCertificateCheck
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

Import-Module "$PSScriptRoot\PveApi.psm1" -Force

What does this script setup do?

  • PveHost accepts the lab hostname.
  • Node accepts the target node name.
  • TokenId accepts the identifier for the token created earlier.
  • PSScriptRoot finds PveApi.psm1 beside the script.
  • ErrorActionPreference stops the script when the connection request fails.
  • Complete the connection test by pasting this code directly below the module import:
$headers = New-PveApiHeader -TokenId $TokenId
$version = Invoke-PveApi `
    -PveHost $PveHost `
    -Path "/nodes/$Node/version" `
    -Method Get `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck

[pscustomobject]@{
    Release        = $version.release
    PackageVersion = $version.version
    RepoId         = $version.repoid
} | Format-List

What does the version request do?

  • headers receives the token authorization header without storing the secret in the script.
  • version receives the data returned by /nodes/$Node/version.
  • pscustomobject presents the release details as three named fields.
  • Release identifies the Proxmox VE release used for the compatibility check.
  • Save Test-PveConnection.ps1.

Use Certificate Bypass Only in Your Lab

SkipCertificateCheck disables certificate validation. Use it here only because pve.lab.local is your known lab host with a self-signed certificate.

Keep certificate validation enabled for general or production hosts. A trusted certificate protects the identity of the server receiving your token.

The script prompts for the separately recorded token secret. Your typing stays hidden at the prompt.

Before you run the connection test, do you think the lab will return a compatible major release?

  • Test the control-plane connection by running:
pwsh .\Test-PveConnection.ps1 -PveHost pve.lab.local -Node pve -TokenId 'root@pam!nextwork' -SkipCertificateCheck

What does this command prove?

  • PveHost sends the request to your known lab hostname over HTTPS port 8006.
  • Node targets the node named pve.
  • TokenId identifies the Proxmox VE API token.
  • SkipCertificateCheck permits the known self-signed lab certificate for this request.

You should see Release, PackageVersion, plus RepoId in the terminal. The Release value should begin with 9..

That is the connection proved. Your authenticated client can now read from the compatible Proxmox VE control plane.

Connection test failing?

  • Confirm that pve.lab.local resolves to the correct lab host from your Windows PC.
  • Re-enter the one-time token secret from its secure storage location.
  • Confirm that the token ID includes its backing user plus token name.
  • Return to the effective-permissions check from the previous step if the request reports a permission problem.
  • Help me troubleshoot my Proxmox VE version request without exposing my token secret.

✔️ Awesome, I've got everything!

Great. Confirm that both files are saved in proxmox-mini-platform.

ⓧ I'd like to double check the full code

Compare your module with this complete version:

Set-StrictMode -Version Latest

function New-PveApiHeader {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$TokenId
    )

    $secureSecret = Read-Host 'Enter the Proxmox VE API token secret' -AsSecureString
    $plainSecret = ConvertFrom-SecureString -SecureString $secureSecret -AsPlainText

    return @{
        Authorization = "PVEAPIToken=$TokenId=$plainSecret"
    }
}

function Invoke-PveApi {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$PveHost,

        [Parameter(Mandatory)]
        [string]$Path,

        [ValidateSet('Get', 'Post', 'Put', 'Delete')]
        [string]$Method = 'Get',

        [Parameter(Mandatory)]
        [hashtable]$Headers,

        [hashtable]$Body,

        [switch]$SkipCertificateCheck
    )

    $requestParameters = @{
        Uri         = "https://${PveHost}:8006/api2/json$Path"
        Method      = $Method
        Headers     = $Headers
        ErrorAction = 'Stop'
    }

    if ($PSBoundParameters.ContainsKey('Body')) {
        $requestParameters.Body = $Body
        $requestParameters.ContentType = 'application/x-www-form-urlencoded'
    }

    if ($SkipCertificateCheck) {
        $requestParameters.SkipCertificateCheck = $true
    }

    $response = Invoke-RestMethod @requestParameters
    return $response.data
}

Export-ModuleMember -Function New-PveApiHeader, Invoke-PveApi

Module checkpoint

This file defines the two exported functions used by the connection test. It contains no saved token secret.

Compare your connection script with this complete version:

[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [string]$PveHost,

    [Parameter(Mandatory)]
    [string]$Node,

    [Parameter(Mandatory)]
    [string]$TokenId,

    [switch]$SkipCertificateCheck
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

Import-Module "$PSScriptRoot\PveApi.psm1" -Force

$headers = New-PveApiHeader -TokenId $TokenId
$version = Invoke-PveApi `
    -PveHost $PveHost `
    -Path "/nodes/$Node/version" `
    -Method Get `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck

[pscustomobject]@{
    Release        = $version.release
    PackageVersion = $version.version
    RepoId         = $version.repoid
} | Format-List

Connection script checkpoint

This script imports the module before requesting the node version. It displays the three release fields used to verify the connection.

Your control-plane connection is authenticated plus version-checked. Next up, you will submit a clone request and inspect the task identifier it returns.

Submit a Clone Request

Good progress. Your connection test in PowerShell 7.6.5 proved that the lab accepts your privilege-scoped identity.

The Proxmox VE API accepts clone work asynchronously. Its first response is a task identifier.

In this step, get ready to:
  • Prepare the clone request values in your authenticated PowerShell session.
  • Submit a linked clone request for an unused VM ID.
  • Inspect the returned UPID without claiming that the VM is ready.
Prepare the request values

A clone request needs the source template ID. It also needs an unused destination ID for the new VM.

  • Return to the Proxmox VE interface from earlier.
  • Confirm VM ID 9000 is your Linux Cloud-Init template.
  • Confirm VM ID 210 is unused on node pve.
  • Switch back to the PowerShell session from earlier.

The next command asks for your API token secret through a masked prompt. The secret stays in memory instead of being saved in your repository.

  • Prepare the session values by running the lines below:
  • Paste your separately recorded token secret when the secure prompt appears.
  • Press Enter to create the authenticated header.
$PveHost = 'pve.lab.local'
$Node = 'pve'
$TemplateVmid = 9000
$NewVmid = 210
$VmName = 'nw-small-01'
$TokenId = 'root@pam!nextwork'
$FullClone = $false
$SkipCertificateCheck = $true
$headers = New-PveApiHeader -TokenId $TokenId

What do these values control?

  • The host value identifies your known lab server.
  • The template VM ID identifies the existing Cloud-Init source.
  • The new VM ID reserves 210 for the requested VM.
  • The false full-clone setting requests a linked clone.
  • The certificate bypass remains limited to the known self-signed lab host.
  • Confirm PowerShell returns to its prompt after you enter the secret.

Your secret remains masked. The $headers variable now carries the authentication header for this session.

Header not created?

  • Confirm you are still using PowerShell 7.6.5 in the terminal from earlier.
  • Run the session block again if the prompt returned before you entered the token secret.
  • Confirm the module from earlier still exposes New-PveApiHeader.
  • Help me recreate the Proxmox VE API header in my current PowerShell session.
Submit and inspect the clone request

The clone endpoint starts work in the Proxmox VE control plane. The response shows whether the job was accepted.

Before you run this, do you expect a finished VM record or a task identifier? Keep your prediction in mind.

  • Submit the linked-clone request by running this code in the same PowerShell session:
$cloneBody = @{
    newid = $NewVmid
    name  = $VmName
    full  = if ($FullClone) { 1 } else { 0 }
}

$cloneTask = Invoke-PveApi `
    -PveHost $PveHost `
    -Path "/nodes/$Node/qemu/$TemplateVmid/clone" `
    -Method Post `
    -Headers $headers `
    -Body $cloneBody `
    -SkipCertificateCheck:$SkipCertificateCheck

$cloneTask

What does this request do?

  • The $cloneBody carries the destination VM ID.
  • The request body names the new VM nw-small-01.
  • The false value in $FullClone selects linked-clone behavior.
  • The API call stores its returned task identifier in $cloneTask.

You will see a value beginning with UPID:. The value contains no running status for VM 210.

This is the intended shortfall. The control plane accepted the job while completion remains unresolved.

No UPID returned?

  • Confirm VM ID 210 is still unused.
  • Confirm VM ID 9000 still identifies your template.
  • Confirm the template storage supports linked clones.
  • Help me troubleshoot why my Proxmox VE clone request did not return a UPID.

✔️ Awesome, I've got everything!

Your source files remain available in VS Code. The accepted clone task stays in $cloneTask inside the current PowerShell session.

ⓧ I'd like to double check the full code

  • Compare PveApi.psm1 with this reference:
Set-StrictMode -Version Latest

function New-PveApiHeader {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$TokenId
    )

    $secureSecret = Read-Host 'Enter the Proxmox VE API token secret' -AsSecureString
    $plainSecret = ConvertFrom-SecureString -SecureString $secureSecret -AsPlainText

    return @{
        Authorization = "PVEAPIToken=$TokenId=$plainSecret"
    }
}

function Invoke-PveApi {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$PveHost,

        [Parameter(Mandatory)]
        [string]$Path,

        [ValidateSet('Get', 'Post', 'Put', 'Delete')]
        [string]$Method = 'Get',

        [Parameter(Mandatory)]
        [hashtable]$Headers,

        [hashtable]$Body,

        [switch]$SkipCertificateCheck
    )

    $requestParameters = @{
        Uri         = "https://${PveHost}:8006/api2/json$Path"
        Method      = $Method
        Headers     = $Headers
        ErrorAction = 'Stop'
    }

    if ($PSBoundParameters.ContainsKey('Body')) {
        $requestParameters.Body = $Body
        $requestParameters.ContentType = 'application/x-www-form-urlencoded'
    }

    if ($SkipCertificateCheck) {
        $requestParameters.SkipCertificateCheck = $true
    }

    $response = Invoke-RestMethod @requestParameters
    return $response.data
}

Export-ModuleMember -Function New-PveApiHeader, Invoke-PveApi
  • Compare Test-PveConnection.ps1 with this reference:
[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [string]$PveHost,

    [Parameter(Mandatory)]
    [string]$Node,

    [Parameter(Mandatory)]
    [string]$TokenId,

    [switch]$SkipCertificateCheck
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

Import-Module "$PSScriptRoot\PveApi.psm1" -Force

$headers = New-PveApiHeader -TokenId $TokenId
$version = Invoke-PveApi -PveHost $PveHost -Path "/nodes/$Node/version" -Method Get -Headers $headers -SkipCertificateCheck:$SkipCertificateCheck

[pscustomobject]@{
    Release = $version.release
    PackageVersion = $version.version
    RepoId = $version.repoid
} | Format-List
  • Keep this PowerShell session open so $cloneTask remains available.

Your clone request is accepted. Next, you will turn its UPID into a workflow that waits for real completion.

Build the Provisioning Workflow

Your Proxmox VE API accepted the clone request. The returned UPID only proves that an asynchronous job entered the task queue.

In this step, you will turn that accepted request into a reliable PowerShell workflow. The command will poll every task before it configures the VM or reports success.

In this step, get ready to:
  • Add task polling that waits for a successful exit status.
  • Define approved VM sizes in a service catalog.
  • Reconcile the existing clone task into a configured running VM.
Add reliable task polling

A UPID identifies a background task. The API reports completion through the task status endpoint.

The new Wait-PveTask function converts that identifier into a safe URL segment. It keeps polling until the task stops.

  • Return to PveApi.psm1 in the existing repository.
  • Place the cursor on the blank line before Export-ModuleMember.
  • Start the task-polling function by pasting this first section:
function Wait-PveTask {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$PveHost,

        [Parameter(Mandatory)]
        [string]$Node,

        [Parameter(Mandatory)]
        [string]$Upid,

        [Parameter(Mandatory)]
        [hashtable]$Headers,

        [switch]$SkipCertificateCheck
    )

What does this section define?

  • The function receives the host and node that own the task.
  • The $Upid parameter carries the accepted task identifier from the clone request.
  • The existing authentication header and certificate setting pass through to every status request.
  • Complete Wait-PveTask by pasting this section directly below the closing parenthesis:
    $encodedUpid = [System.Uri]::EscapeDataString($Upid)

    do {
        $task = Invoke-PveApi `
            -PveHost $PveHost `
            -Path "/nodes/$Node/tasks/$encodedUpid/status" `
            -Method Get `
            -Headers $Headers `
            -SkipCertificateCheck:$SkipCertificateCheck

        Write-Host "Task status: $($task.status)"

        if ($task.status -eq 'running') {
            Start-Sleep -Seconds 2
        }
    } while ($task.status -eq 'running')

    if ($task.exitstatus -ne 'OK') {
        throw "Proxmox VE task failed with exit status '$($task.exitstatus)'."
    }

    return $task
}

How does the polling loop work?

  • URL encoding makes the UPID safe to place inside the task status path.
  • The loop requests fresh task state every two seconds while the status remains running.
  • The exit-status check accepts only OK as a successful result.
  • A failed task stops the workflow before another infrastructure action begins.

The module must export the new function before another script can call it.

  • Find the current export line at the bottom of PveApi.psm1:
Export-ModuleMember -Function New-PveApiHeader, Invoke-PveApi

This line exposes the two helpers that existed before task polling was added.

  • Replace the current export line with this expanded export:
Export-ModuleMember -Function New-PveApiHeader, Invoke-PveApi, Wait-PveTask

Why update the export?

Exporting Wait-PveTask makes it available after another script imports the module. The provisioning command can now reconcile every accepted task.

  • Save PveApi.psm1.

The existing connection script also needs to match the cumulative project format.

  • Switch back to Test-PveConnection.ps1.
  • Find the existing one-line version request:
$version = Invoke-PveApi -PveHost $PveHost -Path "/nodes/$Node/version" -Method Get -Headers $headers -SkipCertificateCheck:$SkipCertificateCheck

This is the existing connection call that retrieves the node release.

  • Replace that line with the cumulative multiline form:
$version = Invoke-PveApi `
    -PveHost $PveHost `
    -Path "/nodes/$Node/version" `
    -Method Get `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck

Why split the call across lines?

The backticks continue one PowerShell command across several lines. Each API argument stays visible as the platform scripts grow.

  • Align the output object by replacing its current contents with this cumulative form:
[pscustomobject]@{
    Release        = $version.release
    PackageVersion = $version.version
    RepoId         = $version.repoid
} | Format-List

What does the output object show?

  • The release field supports the major-version compatibility check.
  • The package version identifies the installed Proxmox VE build.
  • The repository ID records the source repository for that build.
  • Save Test-PveConnection.ps1.
Build the approved request command

A service catalog converts a friendly size into a governed CPU and memory policy. Every request uses one of two approved combinations.

  • Create catalog.json inside the existing proxmox-mini-platform folder.
  • Define the approved sizes by pasting this JSON:
{
  "small": {
    "cores": 1,
    "memory": 2048
  },
  "medium": {
    "cores": 2,
    "memory": 4096
  }
}

What does the catalog govern?

  • The small size assigns one CPU core and 2048 MB of memory.
  • The medium size assigns two CPU cores and 4096 MB of memory.
  • The request script reads these values instead of accepting arbitrary capacity choices.
  • Save catalog.json.
  • Confirm catalog.json is listed beside PveApi.psm1 in the repository.

Catalog not appearing?

Check that you created catalog.json inside proxmox-mini-platform. Confirm that the filename does not end with an extra text-file extension.

Ask for help if the file still does not appear. Help me check where catalog.json was created in my proxmox-mini-platform repository.

The provisioning script combines input validation with task reconciliation. Its parameter block exposes the request values that a platform operator is allowed to supply.

The next sections form one script. Keep them in the shown order before running the file.

  • Create Request-LabVm.ps1 inside the existing proxmox-mini-platform folder.
  • Start its public parameter block by pasting this section:
[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [string]$PveHost,

    [Parameter(Mandatory)]
    [string]$Node,

    [Parameter(Mandatory)]
    [ValidateRange(100, 999999999)]
    [int]$TemplateVmid,

    [Parameter(Mandatory)]
    [ValidateRange(100, 999999999)]
    [int]$NewVmid,

    [Parameter(Mandatory)]
    [string]$VmName,

    [Parameter(Mandatory)]
    [ValidateSet('small', 'medium')]
    [string]$Size,

    [Parameter(Mandatory)]
    [string]$TokenId,

What does this parameter section control?

  • The host and node identify the Proxmox VE control-plane target.
  • The template and new VM IDs must stay within the supported VM ID range.
  • The size accepts only small or medium.
  • The token ID identifies the API identity without storing its secret in source code.
  • Complete the parameter block and load the service catalog by pasting this section directly below it:
    [string]$ExistingCloneTask,

    [switch]$FullClone,

    [switch]$SkipCertificateCheck,

    [hashtable]$ApiHeaders
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

Import-Module "$PSScriptRoot\PveApi.psm1" -Force

$catalogPath = Join-Path $PSScriptRoot 'catalog.json'
$catalog = Get-Content -Path $catalogPath -Raw | ConvertFrom-Json
$flavor = $catalog.PSObject.Properties[$Size].Value

$headers = if ($ApiHeaders) {
    $ApiHeaders
} else {
    New-PveApiHeader -TokenId $TokenId
}

How does setup stay reusable?

  • The optional existing task carries the UPID from the naive clone request into this workflow.
  • The catalog lookup selects the CPU and memory policy for the requested size.
  • The script can reuse in-memory API headers when another wrapper calls it.
  • A direct invocation securely prompts for the token secret.

Infrastructure changes should begin only after the script confirms the API major release. This protects the workflow from crossing an unverified compatibility boundary.

  • Add the release check and clone-task decision by pasting this section below the header setup:
$version = Invoke-PveApi `
    -PveHost $PveHost `
    -Path "/nodes/$Node/version" `
    -Method Get `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck

if ($version.release -notmatch '^9\.') {
    throw "This project is verified for Proxmox VE 9.x. The lab reported '$($version.release)'."
}

if ($ExistingCloneTask) {
    $cloneTask = $ExistingCloneTask
    Write-Host "Continuing clone task $cloneTask"
} else {

Why check the release first?

  • The version request runs before any new clone is submitted.
  • The regular expression accepts a release beginning with 9..
  • An existing UPID follows the continuation path without creating a second VM.
  • Complete the clone branch and wait for the clone task by pasting this section:
    $cloneBody = @{
        newid = $NewVmid
        name  = $VmName
        full  = if ($FullClone) { 1 } else { 0 }
    }

    $cloneTask = Invoke-PveApi `
        -PveHost $PveHost `
        -Path "/nodes/$Node/qemu/$TemplateVmid/clone" `
        -Method Post `
        -Headers $headers `
        -Body $cloneBody `
        -SkipCertificateCheck:$SkipCertificateCheck

    Write-Host "Clone task accepted: $cloneTask"
}

Wait-PveTask `
    -PveHost $PveHost `
    -Node $Node `
    -Upid $cloneTask `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck | Out-Null

How does the clone stage reconcile?

  • A new request uses the selected VM ID and name.
  • The default value of full remains zero unless the full-clone switch is supplied.
  • Both the existing-task path and new-request path converge on the same polling call.
  • Configuration begins only after the clone task reports success.
  • Apply the approved CPU and memory policy by pasting this section below the clone wait:
$configBody = @{
    cores  = [int]$flavor.cores
    memory = [int]$flavor.memory
}

$configTask = Invoke-PveApi `
    -PveHost $PveHost `
    -Path "/nodes/$Node/qemu/$NewVmid/config" `
    -Method Post `
    -Headers $headers `
    -Body $configBody `
    -SkipCertificateCheck:$SkipCertificateCheck

Write-Host "Configuration task accepted: $configTask"
Wait-PveTask `
    -PveHost $PveHost `
    -Node $Node `
    -Upid $configTask `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck | Out-Null

How is the catalog enforced?

  • The configuration body reads both capacity values from the selected catalog entry.
  • The API returns another asynchronous task identifier for the configuration change.
  • The second polling call prevents startup from racing ahead of configuration.
  • Add the VM start transition by pasting this section below the configuration wait:
$startTask = Invoke-PveApi `
    -PveHost $PveHost `
    -Path "/nodes/$Node/qemu/$NewVmid/status/start" `
    -Method Post `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck

Write-Host "Start task accepted: $startTask"
Wait-PveTask `
    -PveHost $PveHost `
    -Node $Node `
    -Upid $startTask `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck | Out-Null

Why wait after startup?

The start request also returns a task identifier. Waiting for that task keeps request acceptance separate from completed infrastructure.

  • Finish the workflow by querying runtime state and formatting the result:
$status = Invoke-PveApi `
    -PveHost $PveHost `
    -Path "/nodes/$Node/qemu/$NewVmid/status/current" `
    -Method Get `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck

if ($status.status -ne 'running') {
    throw "VM $NewVmid did not reach running status. Current status: '$($status.status)'."
}

[pscustomobject]@{
    VmId     = $NewVmid
    Name     = $VmName
    Size     = $Size
    Cores    = [int]$flavor.cores
    MemoryMB = [int]$flavor.memory
    Status   = $status.status
} | Format-List

What proves the VM is ready?

  • The final GET request reads the VM's observed runtime state.
  • Any state other than running stops the script with a clear failure.
  • The final object reports the VM identity and its catalog-controlled capacity.
  • Save Request-LabVm.ps1.

Seeing an editor warning?

Check that every pasted section appears once in Request-LabVm.ps1. Preserve each backtick at the end of its continued command line.

Compare your file with the full reference below. Help me find a PowerShell syntax problem in Request-LabVm.ps1.

✔️ Awesome, I've got everything!

Great. Save all four files before you run the workflow.

ⓧ I'd like to double check the full code

Compare PveApi.psm1 with this complete module:

Set-StrictMode -Version Latest

function New-PveApiHeader {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$TokenId
    )

    $secureSecret = Read-Host 'Enter the Proxmox VE API token secret' -AsSecureString
    $plainSecret = ConvertFrom-SecureString -SecureString $secureSecret -AsPlainText

    return @{
        Authorization = "PVEAPIToken=$TokenId=$plainSecret"
    }
}

function Invoke-PveApi {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$PveHost,

        [Parameter(Mandatory)]
        [string]$Path,

        [ValidateSet('Get', 'Post', 'Put', 'Delete')]
        [string]$Method = 'Get',

        [Parameter(Mandatory)]
        [hashtable]$Headers,

        [hashtable]$Body,

        [switch]$SkipCertificateCheck
    )

    $requestParameters = @{
        Uri         = "https://${PveHost}:8006/api2/json$Path"
        Method      = $Method
        Headers     = $Headers
        ErrorAction = 'Stop'
    }

    if ($PSBoundParameters.ContainsKey('Body')) {
        $requestParameters.Body = $Body
        $requestParameters.ContentType = 'application/x-www-form-urlencoded'
    }

    if ($SkipCertificateCheck) {
        $requestParameters.SkipCertificateCheck = $true
    }

    $response = Invoke-RestMethod @requestParameters
    return $response.data
}

function Wait-PveTask {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$PveHost,

        [Parameter(Mandatory)]
        [string]$Node,

        [Parameter(Mandatory)]
        [string]$Upid,

        [Parameter(Mandatory)]
        [hashtable]$Headers,

        [switch]$SkipCertificateCheck
    )

    $encodedUpid = [System.Uri]::EscapeDataString($Upid)

    do {
        $task = Invoke-PveApi `
            -PveHost $PveHost `
            -Path "/nodes/$Node/tasks/$encodedUpid/status" `
            -Method Get `
            -Headers $Headers `
            -SkipCertificateCheck:$SkipCertificateCheck

        Write-Host "Task status: $($task.status)"

        if ($task.status -eq 'running') {
            Start-Sleep -Seconds 2
        }
    } while ($task.status -eq 'running')

    if ($task.exitstatus -ne 'OK') {
        throw "Proxmox VE task failed with exit status '$($task.exitstatus)'."
    }

    return $task
}

Export-ModuleMember -Function New-PveApiHeader, Invoke-PveApi, Wait-PveTask

Compare Request-LabVm.ps1 with this complete provisioning command:

[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [string]$PveHost,

    [Parameter(Mandatory)]
    [string]$Node,

    [Parameter(Mandatory)]
    [ValidateRange(100, 999999999)]
    [int]$TemplateVmid,

    [Parameter(Mandatory)]
    [ValidateRange(100, 999999999)]
    [int]$NewVmid,

    [Parameter(Mandatory)]
    [string]$VmName,

    [Parameter(Mandatory)]
    [ValidateSet('small', 'medium')]
    [string]$Size,

    [Parameter(Mandatory)]
    [string]$TokenId,

    [string]$ExistingCloneTask,

    [switch]$FullClone,

    [switch]$SkipCertificateCheck,

    [hashtable]$ApiHeaders
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

Import-Module "$PSScriptRoot\PveApi.psm1" -Force

$catalogPath = Join-Path $PSScriptRoot 'catalog.json'
$catalog = Get-Content -Path $catalogPath -Raw | ConvertFrom-Json
$flavor = $catalog.PSObject.Properties[$Size].Value

$headers = if ($ApiHeaders) {
    $ApiHeaders
} else {
    New-PveApiHeader -TokenId $TokenId
}

$version = Invoke-PveApi `
    -PveHost $PveHost `
    -Path "/nodes/$Node/version" `
    -Method Get `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck

if ($version.release -notmatch '^9\.') {
    throw "This project is verified for Proxmox VE 9.x. The lab reported '$($version.release)'."
}

if ($ExistingCloneTask) {
    $cloneTask = $ExistingCloneTask
    Write-Host "Continuing clone task $cloneTask"
} else {
    $cloneBody = @{
        newid = $NewVmid
        name  = $VmName
        full  = if ($FullClone) { 1 } else { 0 }
    }

    $cloneTask = Invoke-PveApi `
        -PveHost $PveHost `
        -Path "/nodes/$Node/qemu/$TemplateVmid/clone" `
        -Method Post `
        -Headers $headers `
        -Body $cloneBody `
        -SkipCertificateCheck:$SkipCertificateCheck

    Write-Host "Clone task accepted: $cloneTask"
}

Wait-PveTask `
    -PveHost $PveHost `
    -Node $Node `
    -Upid $cloneTask `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck | Out-Null

$configBody = @{
    cores  = [int]$flavor.cores
    memory = [int]$flavor.memory
}

$configTask = Invoke-PveApi `
    -PveHost $PveHost `
    -Path "/nodes/$Node/qemu/$NewVmid/config" `
    -Method Post `
    -Headers $headers `
    -Body $configBody `
    -SkipCertificateCheck:$SkipCertificateCheck

Write-Host "Configuration task accepted: $configTask"
Wait-PveTask `
    -PveHost $PveHost `
    -Node $Node `
    -Upid $configTask `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck | Out-Null

$startTask = Invoke-PveApi `
    -PveHost $PveHost `
    -Path "/nodes/$Node/qemu/$NewVmid/status/start" `
    -Method Post `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck

Write-Host "Start task accepted: $startTask"
Wait-PveTask `
    -PveHost $PveHost `
    -Node $Node `
    -Upid $startTask `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck | Out-Null

$status = Invoke-PveApi `
    -PveHost $PveHost `
    -Path "/nodes/$Node/qemu/$NewVmid/status/current" `
    -Method Get `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck

if ($status.status -ne 'running') {
    throw "VM $NewVmid did not reach running status. Current status: '$($status.status)'."
}

[pscustomobject]@{
    VmId     = $NewVmid
    Name     = $VmName
    Size     = $Size
    Cores    = [int]$flavor.cores
    MemoryMB = [int]$flavor.memory
    Status   = $status.status
} | Format-List

Compare Test-PveConnection.ps1 with this cumulative connection test:

[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [string]$PveHost,

    [Parameter(Mandatory)]
    [string]$Node,

    [Parameter(Mandatory)]
    [string]$TokenId,

    [switch]$SkipCertificateCheck
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

Import-Module "$PSScriptRoot\PveApi.psm1" -Force

$headers = New-PveApiHeader -TokenId $TokenId
$version = Invoke-PveApi `
    -PveHost $PveHost `
    -Path "/nodes/$Node/version" `
    -Method Get `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck

[pscustomobject]@{
    Release        = $version.release
    PackageVersion = $version.version
    RepoId         = $version.repoid
} | Format-List

Compare catalog.json with this complete catalog:

{
  "small": {
    "cores": 1,
    "memory": 2048
  },
  "medium": {
    "cores": 2,
    "memory": 4096
  }
}
Reconcile the clone and verify the VM

The existing $cloneTask lets the workflow continue the request you already submitted. This avoids creating another VM.

Use certificate bypass carefully

The -SkipCertificateCheck switch skips certificate validation. Use it only for the known self-signed lab host in this project.

  • Keep the existing authenticated PowerShell session active.
  • Prepare to enter the API token secret when the secure prompt appears.
  • Predict whether the existing UPID can progress through configuration and startup into a running VM.
  • Run the complete provisioning workflow with the existing clone task:
pwsh .\Request-LabVm.ps1 -PveHost pve.lab.local -Node pve -TemplateVmid 9000 -NewVmid 210 -VmName nw-small-01 -Size small -TokenId 'root@pam!nextwork' -ExistingCloneTask $cloneTask -SkipCertificateCheck

What should you see?

The terminal reports task status while the clone completes. It then reports accepted configuration and start tasks.

The final summary shows VM ID 210 with the name nw-small-01. It shows size small with 1 core and 2048 MB of memory.

The last field reads Status: running. That observed state proves the platform completed its control loop.

Task did not complete?

Check that template VM 9000 exists on node pve. Confirm that VM ID 210 belongs to the clone request from the previous step.

A task failure can also indicate missing privileges or insufficient storage capacity. Help me diagnose the failed Proxmox VE task in my provisioning workflow.

That is the control loop working. Your accepted clone request is now a governed VM with an observed running state.

Your provisioning command now creates infrastructure through verified state transitions. Next, you will add guarded shutdown and deletion before storing the platform in Git.

Add Cleanup and Version the Platform

Your PowerShell provisioner can now turn an approved Proxmox VE request into a running VM. A create-only platform leaves capacity behind when that VM is no longer needed.

This step adds guarded shutdown and deletion through the task poller you already built. Git records an auditable version of the platform without saving the API token secret.

In this step, get ready to:
  • Protect token and secret files from Git.
  • Build a guarded VM cleanup command.
  • Commit a clean snapshot of the platform.
Keep secrets out of Git

The token secret belongs in memory for the current PowerShell session. Ignore rules prevent files with common secret extensions from entering a commit.

  • Return to the proxmox-mini-platform folder from earlier in VS Code.
  • Create .gitignore beside PveApi.psm1 using the new-file control beside the file list.
  • Add the secret-file patterns by pasting this code:
*.token
*.secret

What do these patterns protect?

  • The *.token pattern excludes any file whose name ends in .token.
  • The *.secret pattern excludes any file whose name ends in .secret.
  • Your scripts still request the token secret securely at runtime.
  • Save .gitignore.

You should see .gitignore beside the existing platform files in VS Code.

Ignore file missing?

  • Check that the filename begins with a dot and contains no extra file extension.
  • Confirm that .gitignore sits inside proxmox-mini-platform.
  • Ask for help if the file is hidden or renamed by Windows: Help me verify my .gitignore filename in VS Code on Windows.

✔️ Awesome, I've got everything!

Great. Your repository now excludes files that use the token and secret extensions.

ⓧ I'd like to double check the full code

*.token
*.secret

What should match?

Your .gitignore file should contain these two patterns on separate lines.

Build the guarded lifecycle command

Infrastructure deletion needs a safety gate before any API request runs. PowerShell provides that gate through SupportsShouldProcess and the -WhatIf switch.

  • Create Remove-LabVm.ps1 beside Request-LabVm.ps1 using the new-file control beside the file list.
  • Define the cleanup command parameters by pasting this first block:
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
    [Parameter(Mandatory)]
    [string]$PveHost,

    [Parameter(Mandatory)]
    [string]$Node,

    [Parameter(Mandatory)]
    [ValidateRange(100, 999999999)]
    [int]$VmId,

    [Parameter(Mandatory)]
    [string]$TokenId,

    [switch]$ShutdownOnly,

    [switch]$SkipCertificateCheck
)

What does this code define?

  • The mandatory parameters identify the Proxmox VE host, node, VM, and token identity.
  • The ShutdownOnly switch preserves the VM after stopping it.
  • The SupportsShouldProcess setting adds confirmation support and enables -WhatIf.
  • Save Remove-LabVm.ps1.

You should see Remove-LabVm.ps1 in the file list with the six public request parameters.

Parameter block showing an error?

  • Check that the closing parenthesis appears after $SkipCertificateCheck.
  • Confirm that each mandatory parameter has its own [Parameter(Mandatory)] declaration.
  • Ask for help with the parameter syntax: Help me check the parameter block in Remove-LabVm.ps1.

The safety gate must run before authentication or VM lookup. This ordering lets -WhatIf preview the operation without requesting the token secret.

  • Add the action description and safety gate below the parameter block by pasting:
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$action = if ($ShutdownOnly) {
    'Shut down the VM'
} else {
    'Shut down and delete the VM and its owned volumes'
}

if (-not $PSCmdlet.ShouldProcess("VM $VmId on node $Node", $action)) {
    return
}

How does the safety gate work?

  • The $action value describes the shutdown-only path or the shutdown-and-delete path.
  • The ShouldProcess() check asks PowerShell whether the requested operation should continue.
  • The early return stops the script before authentication when the operation is declined or previewed.
  • Save Remove-LabVm.ps1.

Before you run this, consider whether PowerShell will touch VM 210 when -WhatIf is present.

  • Test the safety gate by running this command in the terminal from earlier:
pwsh .\Remove-LabVm.ps1 -PveHost pve.lab.local -Node pve -VmId 210 -TokenId 'root@pam!nextwork' -WhatIf

What should you see?

PowerShell prints a preview of the shutdown-and-delete action for VM 210. The command returns before requesting the API token secret.

Preview not appearing?

  • Confirm that SupportsShouldProcess appears inside the opening CmdletBinding declaration.
  • Check that the ShouldProcess() block appears before the module import.
  • Ask for help with the safety gate: Help me debug -WhatIf in Remove-LabVm.ps1.

A graceful cleanup first reads the current VM state. It submits a shutdown task only when the VM is running.

  • Add the status lookup and graceful shutdown logic below the safety gate by pasting:
Import-Module "$PSScriptRoot\PveApi.psm1" -Force
$headers = New-PveApiHeader -TokenId $TokenId

$status = Invoke-PveApi `
    -PveHost $PveHost `
    -Path "/nodes/$Node/qemu/$VmId/status/current" `
    -Method Get `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck

if ($status.status -eq 'running') {
    $shutdownTask = Invoke-PveApi `
        -PveHost $PveHost `
        -Path "/nodes/$Node/qemu/$VmId/status/shutdown" `
        -Method Post `
        -Headers $headers `
        -Body @{ timeout = 60; forceStop = 1 } `
        -SkipCertificateCheck:$SkipCertificateCheck

    Wait-PveTask `
        -PveHost $PveHost `
        -Node $Node `
        -Upid $shutdownTask `
        -Headers $headers `
        -SkipCertificateCheck:$SkipCertificateCheck | Out-Null
}

How does graceful shutdown work?

  • The status request checks whether the selected VM is currently running.
  • The shutdown request allows 60 seconds before the API can force the VM to stop.
  • The existing Wait-PveTask function follows the shutdown UPID until Proxmox VE reports completion.
  • Save Remove-LabVm.ps1.
  • Recheck the unchanged safety gate by running:
pwsh .\Remove-LabVm.ps1 -PveHost pve.lab.local -Node pve -VmId 210 -TokenId 'root@pam!nextwork' -WhatIf

What did this prove?

You should still see the preview before any token prompt appears. The new shutdown logic remains behind the original safety gate.

Seeing a token prompt during the preview?

  • Move the module import below the complete ShouldProcess() block.
  • Confirm that the guard contains return before its closing brace.
  • Ask for help checking the script order: Help me keep authentication behind the WhatIf guard.

The final branch preserves the VM when -ShutdownOnly is present. Otherwise, it submits a purge request and waits for the deletion task.

  • Complete the lifecycle command by adding this code at the end of Remove-LabVm.ps1.
if ($ShutdownOnly) {
    Write-Host "VM $VmId is stopped and remains allocated."
    return
}

$destroyTask = Invoke-PveApi `
    -PveHost $PveHost `
    -Path "/nodes/$Node/qemu/$VmId" `
    -Method Delete `
    -Headers $headers `
    -Body @{ purge = 1 } `
    -SkipCertificateCheck:$SkipCertificateCheck

Wait-PveTask `
    -PveHost $PveHost `
    -Node $Node `
    -Upid $destroyTask `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck | Out-Null

Write-Host "VM $VmId was deleted."

How does deletion stay controlled?

  • The ShutdownOnly branch exits after a successful stop.
  • The deletion request uses purge = 1 to remove the VM and its owned volumes.
  • The final success message appears only after Wait-PveTask accepts an OK exit status.
  • Save the completed Remove-LabVm.ps1 file.

Deletion is the sharp edge of this project. This rehearsal stops at PowerShell's safety gate, so VM 210 stays untouched.

  • Rehearse the completed cleanup path by running:
pwsh .\Remove-LabVm.ps1 -PveHost pve.lab.local -Node pve -VmId 210 -TokenId 'root@pam!nextwork' -WhatIf

What should happen?

PowerShell prints the guarded shutdown-and-delete preview. VM 210 remains running because the script returns before making an API request.

Cleanup rehearsal failing?

  • Confirm that the terminal is still working inside proxmox-mini-platform.
  • Check that the script filename is exactly Remove-LabVm.ps1.
  • Ask for help with the guarded run: Help me troubleshoot the Remove-LabVm.ps1 WhatIf rehearsal.

✔️ Awesome, I've got everything!

Excellent. Your lifecycle command can preview shutdown and deletion without changing the running VM.

ⓧ I'd like to double check the full code

[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
    [Parameter(Mandatory)]
    [string]$PveHost,

    [Parameter(Mandatory)]
    [string]$Node,

    [Parameter(Mandatory)]
    [ValidateRange(100, 999999999)]
    [int]$VmId,

    [Parameter(Mandatory)]
    [string]$TokenId,

    [switch]$ShutdownOnly,

    [switch]$SkipCertificateCheck
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$action = if ($ShutdownOnly) {
    'Shut down the VM'
} else {
    'Shut down and delete the VM and its owned volumes'
}

if (-not $PSCmdlet.ShouldProcess("VM $VmId on node $Node", $action)) {
    return
}

Import-Module "$PSScriptRoot\PveApi.psm1" -Force
$headers = New-PveApiHeader -TokenId $TokenId

$status = Invoke-PveApi `
    -PveHost $PveHost `
    -Path "/nodes/$Node/qemu/$VmId/status/current" `
    -Method Get `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck

if ($status.status -eq 'running') {
    $shutdownTask = Invoke-PveApi `
        -PveHost $PveHost `
        -Path "/nodes/$Node/qemu/$VmId/status/shutdown" `
        -Method Post `
        -Headers $headers `
        -Body @{ timeout = 60; forceStop = 1 } `
        -SkipCertificateCheck:$SkipCertificateCheck

    Wait-PveTask `
        -PveHost $PveHost `
        -Node $Node `
        -Upid $shutdownTask `
        -Headers $headers `
        -SkipCertificateCheck:$SkipCertificateCheck | Out-Null
}

if ($ShutdownOnly) {
    Write-Host "VM $VmId is stopped and remains allocated."
    return
}

$destroyTask = Invoke-PveApi `
    -PveHost $PveHost `
    -Path "/nodes/$Node/qemu/$VmId" `
    -Method Delete `
    -Headers $headers `
    -Body @{ purge = 1 } `
    -SkipCertificateCheck:$SkipCertificateCheck

Wait-PveTask `
    -PveHost $PveHost `
    -Node $Node `
    -Upid $destroyTask `
    -Headers $headers `
    -SkipCertificateCheck:$SkipCertificateCheck | Out-Null

Write-Host "VM $VmId was deleted."

What should match?

Compare your file from top to bottom. The safety gate must appear before authentication, followed by status lookup, shutdown polling, the shutdown-only exit, and guarded deletion.

Commit and verify the platform

A commit turns the working scripts into a reproducible platform snapshot. The ignore rules keep token and secret files outside that snapshot.

  • Stage every platform file by running:
git add .

What does this command do?

Git stages the API module, connection test, catalog, provisioning command, cleanup command, and ignore file for one commit.

  • Inspect the staged snapshot by running:
git status

What should be staged?

You should see the platform files ready to commit. You should not see any file ending in .token or .secret.

  • Create the platform snapshot by running:
git commit -m "Build Proxmox VM provisioner"

What did Git record?

Git records the current platform files under the commit message Build Proxmox VM provisioner. The terminal prints a commit summary when the snapshot succeeds.

Commit not completing?

  • Confirm that the repository-local Git identity from the setup step is still configured.
  • Check that the staging summary lists Remove-LabVm.ps1 and .gitignore.
  • Ask for help with the commit: Help me troubleshoot my local Git commit.

Before the final check, consider whether the cleanup preview can alter VM 210 or make the repository dirty.

  • Verify the guarded cleanup path by running:
pwsh .\Remove-LabVm.ps1 -PveHost pve.lab.local -Node pve -VmId 210 -TokenId 'root@pam!nextwork' -WhatIf

What should the final rehearsal show?

PowerShell prints the shutdown-and-delete preview without requesting the token secret. VM 210 remains running as nw-small-01.

  • Confirm that the committed repository is clean by running:
git status

What should Git report?

Git reports a clean working tree with no uncommitted platform changes. This confirms that the cleanup command and ignore rules are stored in the repository.

Working tree not clean?

  • Read the Git status output to identify the file that changed after the commit.
  • Save any unfinished platform file before creating another commit.
  • Keep any API secret out of the repository by storing it only through the secure runtime prompt.
  • Ask for help interpreting the remaining changes: Help me clean my Git working tree without committing secrets.

That closes the lifecycle loop. Your guarded cleanup command is now committed.

VM 210 remains running. Git reports a clean tree.

Secret mission

Block Duplicate VM Names

A duplicate VM name can make a self-service catalog confusing before provisioning even begins. Add a preflight wrapper that checks existing VM names and rejects a duplicate before Proxmox VE returns a clone task.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep VM 210 running, stop it for later, or delete the project resources. This project adds no paid service cost, but the VM consumes local CPU, memory, and storage while it remains running.

Resources you used:

  • Linked-clone VM 210 named nw-small-01 in your Proxmox VE lab.
  • Privilege-scoped API token identified as root@pam!nextwork.
  • Custom role containing VM.Audit, VM.Clone, VM.Allocate, VM.Config.CPU, VM.Config.Memory, VM.PowerMgmt, and Datastore.AllocateSpace.
  • Local Git repository in the proxmox-mini-platform folder.

Keep everything running

No action is needed. Choose this option if you want to keep demonstrating the provisioning platform or continue developing it.

  • Leave VM 210 running with one core and 2048 MB of memory.
  • Retain the root@pam!nextwork token for authenticated platform requests.
  • Keep the custom role and its path assignments in place.
  • Keep the committed proxmox-mini-platform repository for future changes.

Protect the token secret

Keep the one-time token secret in secure credential storage. Keep it out of the repository and every file matched by .gitignore.

Pause - I'll come back to this later

Stop VM 210 to free its active compute resources. The linked clone remains allocated so you can start it again later.

The cleanup script opens a secure token prompt. It also requests confirmation before changing the VM.

  • Return to the PowerShell session in the proxmox-mini-platform repository.
  • Keep your Proxmox VE token secret ready for the secure prompt.
  • Verify that the confirmation names VM 210 on node pve before approving it.
  • Shut down the VM while keeping it allocated by running this command:
pwsh .\Remove-LabVm.ps1 -PveHost pve.lab.local -Node pve -VmId 210 -TokenId 'root@pam!nextwork' -ShutdownOnly -SkipCertificateCheck

What does this command do?

The -ShutdownOnly switch runs the graceful shutdown path without sending the deletion request. The script waits for the shutdown task before reporting that the VM remains allocated.

You will see task status updates followed by VM 210 is stopped and remains allocated. Your repository and access configuration stay available for the next session.

Did the shutdown fail?

Confirm that your terminal is inside the proxmox-mini-platform folder. The script loads PveApi.psm1 from that location.

Check that pve.lab.local resolves to your lab. Confirm that the token still has power-management permission.

Help me troubleshoot the guarded VM shutdown.

  • Retain the API token and custom role for your next session.
  • Keep the proxmox-mini-platform repository on your computer.

Delete - I don't want to use this again

Remove the provisioned VM before retiring its API identity. Deletion is permanent, so the guarded dry run gives you one final target check.

  • Return to the PowerShell session in the proxmox-mini-platform repository.
  • Preview the shutdown-and-delete action by running this command:
pwsh .\Remove-LabVm.ps1 -PveHost pve.lab.local -Node pve -VmId 210 -TokenId 'root@pam!nextwork' -WhatIf

What does the dry run prove?

The -WhatIf switch prints the proposed action without changing VM 210. This confirms the target before the destructive request runs.

You will see the shutdown-and-delete action described in the terminal. VM 210 remains running after this preview.

  • Keep your Proxmox VE token secret ready for the secure prompt.
  • Verify that the confirmation names VM 210 on node pve before approving it.
  • Delete VM 210 and its owned volumes by running this command:
pwsh .\Remove-LabVm.ps1 -PveHost pve.lab.local -Node pve -VmId 210 -TokenId 'root@pam!nextwork' -SkipCertificateCheck

What happens during deletion?

The script shuts down the running VM and waits for that task to finish. It then submits the deletion request with volume purging enabled.

The same polling function waits for an OK exit status before the script reports success. This prevents an accepted request from masquerading as completed cleanup.

You will see task status updates followed by VM 210 was deleted. VM 210 should no longer appear on node pve.

Was the VM not deleted?

Confirm that the token still has permission to control power and remove VMs. Check that VM 210 still exists on node pve before retrying.

Help me troubleshoot the guarded VM deletion.

The VM is gone. Retire the project identity next so its access cannot be reused.

  • Select Datacenter in the Proxmox VE navigation tree.
  • Select Permissions.
  • Select API Tokens.
  • Select the token identified as root@pam!nextwork.
  • Click Remove to revoke the token.
  • Return to Permissions.
  • Select each project role assignment on the required VM and storage paths.
  • Click Remove for each selected assignment.

The token and its path assignments are removed. The custom role can now be retired without leaving project access behind.

  • Select Roles under Permissions.
  • Identify the custom role by its project privilege list.
  • Select the custom role.
  • Click Remove to delete it.
  • Locate the proxmox-mini-platform folder in Windows File Explorer.
  • Select the proxmox-mini-platform folder.
  • Press Delete to move the repository to the Recycle Bin.
  • Empty the Recycle Bin to remove the local repository permanently.

Nice Work!

Nice Work!

You did it! You built a mini provisioning platform that turns an approved request into a running VM in Proxmox VE with guarded lifecycle cleanup.

You've learned how to:

  • Built a reusable PowerShell client for authenticated Proxmox VE lifecycle requests. Your API token secret stays out of source files.
  • Implemented task polling for clone, configuration, start, shutdown, and deletion jobs. Your platform distinguishes an accepted UPID from completed infrastructure.
  • Enforced an approved service catalog for small and medium VMs. You added guarded cleanup before committing the complete platform to Git.
  • Completed an optional Secret Mission that checks existing VM names before provisioning. Duplicate requests now fail before a clone task consumes capacity.

Ready to quiz yourself?