Author: jeremy

  • CSVActiveDirectory

    PowerShell Module

    CSVActiveDirectory

    Jun 29, 2026~9 min readPowerShell . Active Directory . CSV
    PowerShell 5.1+PowerShell 7Active DirectoryCSV backendPester testsCross-platform

    CSVActiveDirectory is a PowerShell module that simulates Active Directory using plain CSV files as the backend. It gives you a realistic set of AD cmdlets — Get-ADUser, New-ADUser, Set-ADAccountPassword, and the rest — without needing a domain controller, so you can learn AD concepts, test scripts, and practice automation in a completely safe, isolated environment.

    Why I built it

    Standing up a domain controller just to practice AD cmdlets or test a script is heavy, and doing it against production is risky. I wanted something portable that behaves like the real ActiveDirectory module — same verbs, same parameters, same property names — but stores everything in a CSV you can throw away and recreate in seconds. That makes it ideal for labs, training, demos, and CI where a real directory is overkill.

    The module mirrors the cmdlets people actually reach for day to day, validates input the way AD would (including password complexity), and keeps the data layer simple enough to inspect by opening a spreadsheet.

    01 Import .psd1 module 02 Cmdlet Get / New / Set 03 Validate password policy 04 CSV store Database.csv 05 Format ps1xml views 06 Backup timestamped ZIP

    The cmdlets

    The module exposes the core AD cmdlets as public functions, grouped roughly the way you would use them:

    • UsersGet-ADUser (query by Identity or Filter with property selection), New-ADUser (create with validation), and Remove-ADUser (delete with confirmation).
    • AccountsEnable-ADAccount, Disable-ADAccount, and Search-ADAccount for finding accounts by state.
    • PasswordsSet-ADAccountPassword with complexity validation backed by a configurable password policy.
    • ConfigurationGet-ADConfig, Set-ADConfig, and Test-ADConfig for reading, updating, and validating module settings.

    CSV backend and backups

    Everything lives in a CSV database (Data/Database.csv), which keeps the whole thing portable and easy to inspect — you can literally open the directory in a spreadsheet. The data layer is designed to stay consistent across every operation, and it ships with a backup system:

    • Portable storage — a single CSV acts as the directory; copy it, version it, or reset it at will.
    • Automatic backups — timestamped files with ZIP compression so you can roll back after a bad change.
    • Backup management — an interactive console menu plus age-based cleanup utilities with safety prompts and a -WhatIf preview.

    Using it

    Install

    # Clone and run the one-click installer
    git clone https://github.com/ma1c0ntent/CSVActiveDirectory.git
    cd CSVActiveDirectory
    .\install.ps1
    
    # Or import the module directly
    Import-Module .\CSVActiveDirectory.psd1 -Force
    Get-Command -Module CSVActiveDirectory

    The repository ships with an empty database, so after cloning you populate it with realistic sample users:

    # Create a set of sample users (the one-click installer does this for you)
    .\Functions\Private\Create-Users.ps1

    Querying users

    # All users
    Get-ADUser -Identity "*"
    
    # A single user
    Get-ADUser -Identity "mbryan"
    
    # Select specific properties
    Get-ADUser -Identity "mbryan" -Properties "Department", "Title", "Enabled"
    
    # Filter
    Get-ADUser -Filter "Department -eq 'IT'"

    Creating and managing accounts

    # Create a user
    New-ADUser -SamAccountName "jdoe" -FirstName "John" -LastName "Doe" `
        -EmailAddress "[email protected]" -Department "IT" -Title "Developer"
    
    # Disable, enable, then remove
    Disable-ADAccount -Identity "jdoe"
    Enable-ADAccount  -Identity "jdoe"
    Remove-ADUser     -Identity "jdoe" -Confirm:$false

    Passwords

    Set-ADAccountPassword runs the new password through the same kind of complexity rules a real policy would enforce, so weak passwords are rejected before they ever reach the database:

    # Set a password (validated against the configured complexity policy)
    Set-ADAccountPassword -Identity "jdoe" -NewPassword "SecurePass123!"

    Configuration

    Module behavior is driven by a JSON settings file (Data/Config/Settings.json) rather than hardcoded values. Get-ADConfig reads it, Set-ADConfig updates it, and Test-ADConfig validates its integrity:

    # Read current configuration
    Get-ADConfig
    
    # Validate configuration integrity
    Test-ADConfig

    Available properties

    Users expose the properties you would expect from AD. The defaults stay lightweight, and -Properties * pulls the full set:

    • DefaultFirstName, LastName, DisplayName, SamAccountName.
    • ExtendedDistinguishedName, EmailAddress, Title, Department, Guid, Created, Modified, Enabled, UserPrincipalName, SID, PrimaryGroupID, PasswordLastSet, LastLogon.

    Output is shaped by custom format files (ADUser.format.ps1xml) for clean table and list views, with color-coded status messages and progress indicators for bulk operations.

    Compatibility

    The module targets both Windows PowerShell and PowerShell 7+, detecting the version automatically and adapting:

    • PowerShell 5.1 — full functionality with ASCII alternatives for emoji output.
    • PowerShell 7+ — full Unicode support and modern syntax such as the ?? null-coalescing operator.
    • Cross-platform — runs on Windows, Linux, and macOS.

    Testing

    The module is covered by a Pester test suite under Tests/ — integration tests for module loading and the full user lifecycle, plus focused function tests for each cmdlet (Get / New / Remove-ADUser, Enable / Disable-ADAccount, Search-ADAccount, configuration management, and password complexity). Everything is verified across both PowerShell 5.1 and 7+.

  • ESXiManScan

    Compliance Automation

    ESXiManScan

    Jun 29, 2026~10 min readAnsible . PowerCLI . STIG
    AnsiblePowerShellVMware PowerCLIESXi 7.0 / 8.0DISA STIGSTIG Viewer CKLB

    ESXiManScan is an Ansible role and PowerCLI tool I wrote to automate DISA STIG compliance scanning of VMware ESXi hosts. It connects to each host, runs version-appropriate rule checks, and produces a populated STIG Viewer checklist (a .cklb file) that is ready for submission or review — turning a tedious manual control-by-control review into a repeatable, hands-off scan.

    Why I built it

    STIG checklists for ESXi run to hundreds of controls, and doing them by hand for every host is slow and error-prone. This one came straight out of the day job — the reviews are repetitive, but most of the evidence is sitting right there in the API, so they are a perfect target for automation. I wanted a tool that reads the same evidence an assessor would, fills in the official STIG Viewer checklist, and leaves only the genuinely manual items for a person to confirm.

    The result reads each rule, queries the host live, and writes back NotAFinding, Not_Applicable, or Open into a real .cklb — so the human time goes to the few controls that actually need judgment.

    01 Connect PowerCLI 02 Detect 7 / 8 / 9 03 Load rules per version 04 Evaluate live API 05 Shell checks SSH (Ansible) 06 Output .cklb + logs

    How it works

    ESXiManScan works in two phases. Most rules are evaluated through PowerCLI, and the handful that require raw filesystem state are handled over SSH by Ansible and merged back into the same checklist.

    Phase 1 — PowerCLI scan

    • Connects to each ESXi host and detects the major version (7.0 / 8.0 / 9.0), then dot-sources the matching rule set.
    • Caches commonly used objects once — advanced settings, esxcli, host services, vSwitches, port groups, VMkernel adapters — to avoid redundant API calls across rules.
    • Loads the blank STIG checklist for that version and populates asset metadata (hostname, IP, MAC, FQDN).
    • Runs each rule scriptblock, compares the result to its expected value, and marks the entry NotAFinding, Not_Applicable, or Open.
    • Saves the completed checklist to Completed_Checklists/ and writes a timestamped log to Logs/.

    Phase 2 — Shell checks

    Two controls inspect raw filesystem state that PowerCLI cannot reach, so the Ansible play SSHes into each host, runs the shell commands, then merges the results into the existing checklist with a PowerShell XML update step (matched by hostname):

    # V-258791  /etc/vmware/settings must be 0 bytes
    stat -c "%s" /etc/vmware/settings
    
    # V-258792  no vmx.log override in /etc/vmware/config (expect no output)
    grep "^vmx\.log" /etc/vmware/config

    Supported versions

    • ESXi 7.0 — STIG V1R4.
    • ESXi 8.0 — STIG V2R3 (latest known build 24677879).
    • ESXi 9.0 — supported via its own rule set.

    Version and build constants live at the top of ESXiManScan.ps1 and get bumped when new STIG releases or ESXi builds drop.

    Prerequisites and layout

    • An Ansible control node with PowerShell (pwsh) installed.
    • The VMware PowerCLI module (VCF.PowerCLI) available to the runner.
    • SSH access to the target hosts as root.
    • Blank STIG Viewer checklists placed in files/Blank_Checklists/.

    The role lays out like this:

    roles/
      esximanscan/
        tasks/
          main.yml
        files/
          ESXiManScan.ps1
          Rules/
            ESXi70_Rules.ps1
            ESXi80_Rules.ps1
            ESXi90_Rules.ps1
          Blank_Checklists/
            ESXi80_V2R3_BlankChecklist.cklb
          Completed_Checklists/   (generated at runtime)
          Logs/                   (generated at runtime)

    Blank checklists follow a fixed naming convention so the script can locate the right one for each version:

    ESXi80_V2R3_BlankChecklist.cklb
    ESXi70_V1R4_BlankChecklist.cklb

    Running it

    Include the role in a play targeting your ESXi hosts. The scan runs once and iterates over all connected hosts:

    - hosts: esxi
      gather_facts: false
      roles:
        - esximanscan

    Store each host root password in an Ansible Vault variable (ansible_password) and run with the vault prompt:

    ansible-playbook -i inventory.ini esxi_stig.yml --ask-vault-pass
    Credential handling

    The root password is passed to PowerShell through the PASSWORD environment variable and converted to a SecureString at runtime — it is never written to disk. The scan task sets no_log: true so credentials never show up in Ansible output.

    Rule authoring

    Rules live in Rules/ESXi<ver>_Rules.ps1 as PSCustomObject entries appended to a $rules array. Each carries the STIG identifiers, a scriptblock that returns a single value, and the value that counts as compliant:

    $rules += [PSCustomObject]@{
        VULN_ID      = 'V-258757'
        STIG_ID      = 'ESXI-80-000196'
        Command      = { $script:advSettings['Security.AccountLockFailures'].Value }
        PassResult   = '3'
        FailComments = 'Account lockout threshold must be 3 or fewer failed attempts.'
    }

    Each scriptblock has access to the objects cached once at scan start, which keeps rule logic short and fast:

    • $script:vmhost — the VMHost object
    • $script:esxcli — EsxCli v2 interface
    • $script:advSettings — advanced settings
    • $script:hostServices — host services
    • $script:vmkAdapters — VMkernel adapters
    • $script:vSwitches and $script:portGroups — networking

    Returning "NotApplicable" from a scriptblock marks the control Not Applicable; using { return $false } with a documented FailComments flags a control as always-open for manual review.

    Output

    Every run produces a completed checklist in Completed_Checklists/ and a per-host log in Logs/ with pass / fail / N/A per rule. Each checklist entry records exactly how the determination was made, in a consistent format:

    ESXiManScan-1.0
    
    Command: <scriptblock or shell command>
    
    Expected Result: <PassResult value>
    
    Actual Result: <observed value>

    Known limitations

    • V-258784 (ESXI-80-000229) is hardcoded Open — DoD certificate installation is out of scope for this tool.
    • V-258791 and V-258792 require SSH; the ESXi shell and SSH service must be enabled during the scan window.
  • Trivy Security Center

    Security Tooling

    Trivy Security Center

    Jun 29, 2026~12 min readDjango . Trivy . CycloneDX
    DjangoUvicorn / ASGISQLiteTrivyCycloneDX SBOMRPM / systemdRBAC

    Trivy Security Center (TrivySC) is a vulnerability management platform I built to centralize how container and SBOM scans are collected, reviewed, and signed off. Trivy is excellent at finding vulnerabilities, but on its own it leaves you with a pile of JSON files and nowhere shared to triage them. TrivySC turns that raw output into a multi-user dashboard with role-based access, audit logging, risk-acceptance waivers, and one-command deployment.

    Why I built it

    Most teams run Trivy in CI and pipe the results somewhere — a log, an artifact, a chat message — and then lose track of them. There was no single place to ask simple questions: which systems are affected by a given CVE, what risk have we already accepted, and who signed off. I wanted a self-hosted, auditable system of record for vulnerability data that a small team could actually operate.

    The design goals were straightforward: ingest what Trivy already produces, enforce who can do what, keep a tamper-evident trail of every action, and make deployment a single command so it can live on a hardened RHEL box without a complicated runbook.

    INGESTION PIPELINE 01 Scan trivy image / sbom 02 Submit POST /api/submit 03 Detect + parse CycloneDX or JSON 04 Store SQLite schema 05 Analyze dashboard . reports CROSS-CUTTING CONTROLS RBAC 4 roles, least privilege sessions killed on change Audit Log 24 event types actor + source IP Waivers central registry + expiry CSV import / export Backups tar.gz: db + env + certs guarded restore Django + Uvicorn (ASGI) . SQLite . packaged as an RPM . systemd service over HTTPS

    What it does

    • Dual-format ingestion — accepts both CycloneDX and native Trivy JSON; the format is auto-detected and normalised into one schema.
    • Live scan execution — start a Trivy scan from the browser and watch output stream in real time over server-sent events.
    • RBAC — four roles from administrator down to user, with session invalidation on any role or password change.
    • Audit logging — 24 event types recorded with actor and source IP.
    • Waiver management — a central risk-acceptance registry with expiry dates and CSV import / export.
    • Exports and reports — CSV and HTML reports per scan, plus a full REST API.
    • Operable — packaged as an RPM, runs as a systemd service over HTTPS, with built-in backup and restore.

    Architecture and stack

    TrivySC is a Django application served by Uvicorn as an ASGI app, which is what makes the live scan streaming clean — the server-sent events endpoint pushes Trivy output to the browser as it is produced. Data lives in SQLite, which keeps the deployment dependency-free and trivial to back up as a single file.

    The whole thing ships as a versioned RPM built per Python version (3.10 through 3.13). The package post-install scriptlet creates the service account, installs dependencies, generates a self-signed TLS certificate, initialises the schema, creates the first admin with a one-hour temporary password, and enables the systemd unit. The service is reachable over HTTPS the moment the install finishes.

    Installation

    Install on RHEL, Rocky, or AlmaLinux 8, 9, or 10. Pick the RPM that matches your Python version:

    sudo rpm -i trivysc-<version>-0.py312.x86_64.rpm

    The %post scriptlet runs automatically and:

    • Creates the trivysc system account.
    • Installs Python dependencies into /opt/trivysc/lib/.
    • Generates a self-signed TLS certificate.
    • Initialises the database schema.
    • Creates the initial admin account with a one-hour temporary password.
    • Enables and starts the trivysc systemd service.

    From there it is an ordinary systemd unit:

    sudo systemctl status  trivysc
    sudo systemctl restart trivysc
    sudo journalctl -u trivysc -f

    First login

    The temporary admin password is printed to the terminal during install and is never stored anywhere retrievable afterward. Log in at /login, then set a permanent password when prompted. The temporary password expires after one hour.

    Heads up

    The install output scrolls fast because the service starts immediately. Scroll up and save the temporary password before navigating away — if it is lost, recovery means direct database access or a reinstall. That is by design: nothing keeps a recoverable copy of it.

    Configuration

    All configuration is read from /opt/trivysc/.env. Edit the file and restart to apply. The most important value is SECRET_KEY — generate a long random one and keep it private:

    sudo nano /opt/trivysc/.env
    sudo systemctl restart trivysc
    
    # Generate a strong SECRET_KEY
    python -c "import secrets; print(secrets.token_urlsafe(50))"

    Submitting scans

    Scans are submitted to /api/submit, which accepts CycloneDX or native Trivy JSON and auto-detects the format. From CI or a workstation, post the file with an API key:

    curl -sk -X POST https://<host>:<port>/api/submit -H "X-API-Key: <your-api-key>" -F "system_name=registry.example.com/org/app:latest" -F "[email protected]"

    In the lab I push results straight from Ansible after a Trivy run, using the same endpoint:

    - name: Submit CycloneDX results to TrivySC
      ansible.builtin.uri:
        url: "{{ trivysc_url }}/api/submit"
        method: POST
        headers:
          X-API-Key: "{{ trivysc_api_key }}"
        body_format: form-multipart
        body:
          system_name: "{{ inventory_hostname }}"
          file:
            filename: results.json
            content: "{{ lookup('file', trivy_output_path) }}"
            mime_type: application/json

    Running a live scan

    For ad-hoc work, the /start-scan page runs Trivy on the server and streams live output to the browser. Image and SBOM targets are supported: pull and scan a registry image, upload a local image tar, or scan an existing CycloneDX SBOM. CycloneDX results are ingested automatically on completion; JSON results can be downloaded and re-submitted. Trivy just needs to be installed and on the server PATH.

    Waivers and risk acceptance

    Real environments carry vulnerabilities that are accepted, deferred, or confirmed false positives. TrivySC manages these through a dedicated central waiver registry at /waivers (auditor and above). Entries are matched by CVE and package name across every scan, support optional expiry dates, and can be added, edited, deleted, imported, or exported as CSV. Matching waivers appear in System Detail right next to the findings they cover:

    Package_Name,CVE,Waiver_ID,Justification,Expires_At
    openssl,CVE-2023-1234,WVR-0001,Not exploitable in our configuration,
    curl,CVE-2024-5678,WVR-0002,Fix scheduled for next release,2026-12-31
    libxml2,CVE-2025-1111,WVR-0003,Compensating controls in place,2026-09-30
    Expiry

    Waivers can carry an optional expiry date. Expired waivers stop being applied to new scans, and the registry flags entries that are already expired or due within 30 days so nothing silently lingers.

    Roles and RBAC

    • administrator — every action, including role changes, password resets, and the administration panel.
    • maintainer — create users, reset passwords, enable or disable accounts, view the audit log.
    • auditor — view everything, submit and start scans, manage waivers; no user management.
    • user — submit scans, manage their own profile and API key.

    Any role or password change immediately ends the affected sessions and forces a re-login; a graceful shutdown ends all sessions.

    Exports and API

    Every view that lists data can export it — per-scan CSV and HTML reports, full-database CSV, SBOM CSV, and the original CycloneDX JSON, all available from the UI or the REST API. A few examples:

    # Full database export
    curl -sk https://<host>:<port>/api/export/database.csv -o all_scans.csv
    
    # Single scan as an HTML report
    curl -sk https://<host>:<port>/api/scans/5/export.html -o scan5_report.html
    
    # Original CycloneDX JSON (as submitted)
    curl -sk https://<host>:<port>/api/scans/5/export.cdx.json -o scan5.cdx.json

    Security design notes

    • HTTPS by default — a self-signed certificate is generated on install; swap in your own via .env, and a gen_cert CLI command regenerates it.
    • Session hygiene — idle timeout, session invalidation on privilege changes, and full invalidation on shutdown.
    • Safe restore — backup archives are validated on restore; members with absolute paths or directory-traversal sequences are rejected.
    • Secret handling — the temporary admin password is shown once and never persisted in a recoverable form; the Django secret key lives only in .env.
    • Least privilege — runs under a dedicated trivysc service account, not root.

    What I learned

    The most satisfying part of this build was making the boring parts boring: a single RPM that stands the whole service up, a database that is one file to back up, and an audit log that answers who did what. Trivy finds the problems; TrivySC is the place a team agrees on what to do about them.

  • VM Provisioning With Proxmox

    Virtualization

    VM Provisioning With Proxmox

    May 15, 2026~13 min readProxmox . templates . KVM

    Why I template everything

    Spinning up a fresh VM from scratch every time — attaching the ISO, clicking through the installer, setting the timezone, patching, installing the guest agent — is fine once. Doing it for the tenth machine is a waste of an afternoon. The fix is to do that work exactly once: get a single VM into a clean, fully updated, known-good state, and then turn it into a template. After that, every new machine is a clone that comes up in seconds, already patched and configured.

    This is the workflow I actually use in my lab: build one Debian VM the normal way, get it stable and current, generalize it so the clones are not carbon copies, convert it to a template, and clone from it whenever I need a new box.

    01 Create VM qm create + ISO 02 Install OS from ISO 03 Update patch + agent 04 Generalize clean identity 05 Template qm template 06 Clone qm clone

    Prerequisites

    You will need:

    • A working Proxmox VE node (this lab runs 8.x), with enough storage for a base disk plus clones.
    • An OS ISO uploaded to the node. In the web UI that is Datacenter > your storage > ISO Images > Upload; on disk it lands in /var/lib/vz/template/iso/. This guide uses Debian 12, but the same steps apply to almost any Linux.
    • Access to the node — either the web UI, or SSH / the node shell as root for the command-line steps.
    Note

    Pick a VMID convention and stick to it. I reserve 9000 for the template and use the 1xx range for real machines, so it is obvious at a glance which VM is the golden image and which are clones.

    Step 1 – Create the VM

    Create a normal VM that you will install an OS into. In the web UI, click Create VM in the top right and walk through the wizard; the settings that matter are below. If you prefer the shell, the equivalent qm commands follow.

    Wizard tabs worth setting deliberately:

    • General — set the VMID to 9000 and a clear name such as debian12-base.
    • OS — select the ISO you uploaded.
    • System — tick the QEMU Guest Agent box (you will install the agent inside the guest later), and leave machine type and BIOS at their defaults unless you need UEFI.
    • Disks — a 16-32 GB disk on your main storage (for example local-lvm) is plenty for a base image; use the VirtIO SCSI controller and tick Discard if your storage is thin-provisioned.
    • CPU — 2 cores, type host for best performance.
    • Memory — 2048 MB is fine for a base Linux.
    • Network — the VirtIO model on your management bridge (for example vmbr0).
    # Create the VM shell (CPU, memory, NIC, guest agent enabled)
    qm create 9000 --name debian12-base --memory 2048 --cores 2 --cpu host \
        --net0 virtio,bridge=vmbr0 --scsihw virtio-scsi-pci --agent enabled=1
    
    # Add a 32 GB disk on local-lvm
    qm set 9000 --scsi0 local-lvm:32
    
    # Attach the installer ISO and set the boot order
    qm set 9000 --ide2 local:iso/debian-12.7.0-amd64-netinst.iso,media=cdrom
    qm set 9000 --boot 'order=scsi0;ide2'

    Substitute the bridge, storage and ISO filename that match your node.

    Step 2 – Install the guest OS

    Start the VM and open the Console from the Proxmox UI, then run through the OS installer the way you normally would:

    • Choose your language, keyboard and timezone.
    • Set a hostname — something generic like debian-base; clones will get their real names later.
    • Partition the disk (the guided whole-disk option is fine for a template).
    • Create your admin user, and if the installer offers it, select the SSH server and standard system utilities.

    When the installer finishes, let the VM reboot and log in at the console. Detach the ISO once it is booted so the template is not tied to an installer image — in the UI, Hardware > CD/DVD Drive > Edit > Do not use any media, or from the shell:

    qm set 9000 --ide2 none

    Step 3 – Update and stabilize

    This is the part that makes templating worth it: get the machine to a clean, current baseline so every future clone starts there. Inside the guest, fully update the system and install the QEMU guest agent (it is what lets Proxmox report the VM IP, do clean shutdowns, and run guest commands):

    # Update the package index and upgrade everything
    sudo apt update && sudo apt full-upgrade -y
    
    # Install the QEMU guest agent (and any tools you always want present)
    sudo apt install -y qemu-guest-agent
    
    # Enable the agent so it starts on every boot
    sudo systemctl enable --now qemu-guest-agent

    Now is also the time to bake in anything you want on every machine — your shell config, a monitoring agent, common packages, sane sshd settings. The whole point is that you do this once. When you are happy, reboot and confirm everything comes back cleanly:

    sudo reboot
    Tip

    The guest agent only fully works once it is enabled on the VM as well (the QEMU Guest Agent box from Step 1, also under Options). After a reboot the Summary tab should start showing the VM IP address, which confirms the agent is talking to the host.

    Step 4 – Generalize before templating

    If you clone a VM as-is, every clone inherits the same machine identity — the same machine-id, the same SSH host keys, and often the same DHCP behavior — which causes subtle, annoying problems on a network. Before you convert to a template, strip the machine-specific bits so each clone regenerates its own. Run these in the guest as the final thing before shutting down:

    # Clear the unique machine-id (it regenerates on next boot)
    sudo truncate -s 0 /etc/machine-id
    sudo rm -f /var/lib/dbus/machine-id
    sudo ln -s /etc/machine-id /var/lib/dbus/machine-id
    
    # Remove SSH host keys so each clone generates its own
    sudo rm -f /etc/ssh/ssh_host_*
    
    # Clear logs and shell history for a clean image
    sudo truncate -s 0 /var/log/*log 2>/dev/null
    cat /dev/null > ~/.bash_history && history -c

    Then shut the VM down — do not boot it again, or it will regenerate the very files you just cleared:

    sudo shutdown now
    Why this matters

    Duplicate SSH host keys make clients warn about possible man-in-the-middle attacks, and duplicate machine-ids confuse anything that keys off them (DHCP reservations, logging, some agents). Clearing them here means the first boot of every clone produces a fresh, unique identity.

    Step 5 – Convert to a template

    With the VM shut down and generalized, convert it. In the web UI, right-click VM 9000 and choose Convert to template. From the shell it is one command:

    qm template 9000

    Once converted, the VM icon changes and the machine becomes read-only — you can no longer start it directly, only clone from it. That is your golden image.

    Keeping it current

    Templates go stale as patches pile up. Every month or two I clone the template to a temporary VM, boot it, run the update and generalize steps again, re-template it, and delete the temporary clone. It keeps new machines from needing a big patch run the moment they come up.

    Step 6 – Clone and deploy

    Now the payoff. To stand up a real machine, clone the template. In the UI, right-click the template and choose Clone, then pick a new VMID and name and choose the clone mode. From the shell:

    # Full clone into VMID 120 -- an independent copy of the disk
    qm clone 9000 120 --name ad-dc01 --full
    
    # Or a linked clone -- fast and space-saving, but tied to the template
    qm clone 9000 121 --name test01

    A full clone is a completely independent VM; a linked clone shares the template base disk, so it is near-instant and uses almost no extra space but cannot outlive the template. I use full clones for anything that matters and linked clones for throwaway testing.

    Start the clone, open the console, and give it its own identity:

    # Set a unique hostname
    sudo hostnamectl set-hostname ad-dc01
    
    # Regenerate SSH host keys (skip if your first-boot tooling handles it)
    sudo dpkg-reconfigure openssh-server
    
    # If you use static IPs, edit the interfaces file / netplan, then reboot
    sudo reboot

    Because you cleared the machine-id and host keys before templating, the clone comes up with a fresh identity on first boot. Confirm the guest agent is reporting and the network is correct from the host:

    qm guest cmd 120 network-get-interfaces

    Automating it further

    Once the template exists, I rarely clone by hand for anything repeatable. A short script wraps the clone-and-customize steps so a new machine is one command:

    #!/usr/bin/env bash
    # new-vm.sh <newid> <name>  -- clone the template and start it
    set -euo pipefail
    TEMPLATE=9000
    NEWID="$1"
    NAME="$2"
    qm clone "$TEMPLATE" "$NEWID" --name "$NAME" --full
    qm start "$NEWID"
    echo "Cloned $TEMPLATE -> $NEWID ($NAME) and started it"

    From there it is easy to layer on whatever configuration management you use — Ansible against the new IP, for instance — but even the bare clone gets you a patched, ready machine in seconds instead of a fresh install every time.

    Wrap-up

    That is the whole loop: build one VM properly, update it, generalize it, and template it once — then clone forever. The up-front half hour of getting a single machine right pays for itself the moment you need your second, third, or tenth box. From here, clone whatever the lab needs: domain controllers, a GitLab runner, a SIEM collector, or a scratch VM you will delete in an hour.