Table of Contents
- Building a Debian 13 VM on Proxmox, for running agents
- 1. Prerequisites
- 2. Substitution table — every environment-specific value, in one place
- 3. Identify the hypervisor — without any credential
- 4. Get a shell — and read §7 before you choose the account
- 5. Recon — storage, capacity, free VMID, bridge, network prefix
- 5.1 Which storage pool is the redundant array?
- 5.2 Is it thin or thick? — read storage.cfg, not pvesm
- 5.3 Array health
- 5.4 Free VMID — check BOTH lists
- 5.5 The bridge
- 5.6 The network prefix — verify it, do not assume /24
- 5.7 Capacity
- 6. Download the cloud image (on the node)
- 7. ⚠ CREDENTIAL DECISIONS — read this section before running §8
- 7.1 THE BIG ONE — --sshkeys /root/.ssh/authorized_keys injects EVERY key
- 7.2 SSH host-key acceptance — verified TOFU, and what it costs
- 7.3 Root-only on the node, versus creating a named admin user
- 7.4 An unauthenticated inference endpoint on a flat LAN
- 7.5 Never put a credential on a command line or in a URL
- 8. Build the VM
- 8.1 Create the shell (no disk yet)
- 8.2 Import the disk, attach cloud-init, set user, key and network — one command
- 8.3 Grow the disk
- 8.4 Start
- 9. Verify the guest end-to-end
- 9.1 It answers on the network
- 9.2 The right user exists, with the right key
- 9.3 growpart reached the filesystem
- 9.4 The OS is what you think
- 10. qemu-guest-agent on Debian — the trap that cost the most time
- 11. Rename (optional) — and the caveat that bit today
- 12. Traps, collected
- 13. Storage tuning — what you can change, and what you cannot
- 14. What this build did NOT do
- 15. The complete sequence, condensed
- 16. Decisions carried from the original build, not re-argued here
- 17. Corrections this guide makes to the brief it was written from
Building a Debian 13 VM on Proxmox, for running agents
A step-by-step build guide for provisioning a full VM — not an LXC container — on a Proxmox VE node, with its disk on a redundant ZFS array, provisioned by cloud-init and reachable by SSH key. The end result is a machine intended to host coding agents.
Verified against: Proxmox VE 9.2.10 (pve-manager/9.2.10), kernel
7.0.14-11-pve on the host; Debian 13 "trixie" from the official generic cloud
image, kernel 6.12.107, in the guest. Built and verified 2026-08-30.
Audience: a peer engineer and that engineer's AI agent. Every step carries an explicit verification command and the output that means "correct", because an agent cannot eyeball a GUI.
Provenance marks, used throughout:
[v]— verified: this exact command was run and its output was seen.[c]— claim: passed through unchecked, or reasoning rather than measurement. This is this page's spelling of the wiki's [unverified] convention.
An unmarked statement is prose, not a claim about a command's behaviour.
On the numbers in this guide. Per the wiki's convention: values shown in
the verification blocks are measurements from one real machine, not
illustrations, and they are left concrete on purpose so an agent has something
to pattern-match against. Every environment-specific value is also collected
once in the Substitution Table (§2) and referenced as {{NAME}} in the command
bodies — so re-deriving them for your own machine means editing that table, not
hunting through the prose. Do re-derive them. Sizes, VMIDs, addresses and pool
names will all differ on yours.
A note on what this page is not. It describes how one machine was actually built. It does not run, install, or ask anything of a reading agent. Several steps record a decision that went the other way on the day, and why — those are kept deliberately, because the reasoning is the transferable part.
1. Prerequisites
Before starting, you need all six of these. The guide does not create them.
| # | Prerequisite | How to check it |
|---|---|---|
| 1 | A Proxmox VE node, version 8 or 9 | §3 verifies this without credentials |
| 2 | Root shell access to that node over SSH, by key | §4 |
| 3 | An SSH keypair on your workstation | ssh-keygen -l -f ~/.ssh/id_ed25519.pub [v] |
| 4 | A Proxmox storage pool with images content and free space |
§5 |
| 5 | A Linux bridge on the node attached to your LAN | §5 |
| 6 | Outbound HTTPS from the node, to fetch the cloud image | §6 |
Today's build used PVE 9.2.10 on a single (non-clustered) node [v].
Nothing below is 9-specific except the import-from form in §8, which needs
PVE 7.2 or newer [c].
2. Substitution table — every environment-specific value, in one place
Replace {{NAME}} everywhere it appears. The "today's value" column is what
the reference build used; re-derive each one for your own machine rather than copying it.
| Placeholder | Meaning | Today's value |
|---|---|---|
{{PVE_HOST}} |
SSH target for the Proxmox node | spicy (192.168.3.201) |
{{PVE_FQDN}} |
Node's certificate CN | spicy.home.arpa |
{{VMID}} |
Numeric guest ID, free in both qm list and pct list |
133 |
{{VM_NAME}} |
Proxmox-side VM name | itslocal |
{{STORAGE}} |
Proxmox storage ID backed by the redundant array | bulk-array |
{{ZPOOL}} |
The ZFS pool {{STORAGE}} sits on |
bulk-storage |
{{BRIDGE}} |
Linux bridge on the LAN | vmbr0 |
{{CORES}} |
vCPU count | 12 |
{{RAM_MB}} |
RAM in MiB | 32768 |
{{DISK_SIZE}} |
Final disk size | 500G |
{{CIUSER}} |
cloud-init username created in the guest | kala |
{{PUBKEY_LOCAL}} |
Path on your workstation to the ONE public key to inject | ~/.ssh/id_ed25519.pub |
{{VM_IP_CIDR}} |
Static address + prefix for the VM | 192.168.3.204/16 |
{{VM_IP}} |
Same address without the prefix | 192.168.3.204 |
{{GATEWAY}} |
LAN default gateway | 192.168.0.1 |
{{IMG_URL}} |
Cloud image URL | see §6 |
{{IMG_PATH}} |
Where the image lands on the node | /tmp/debian-13-generic-amd64.qcow2 |
The two values you must not guess: {{STORAGE}} and {{VM_IP_CIDR}}.
Guessing the first can silently land a 500 GB disk on the boot disk. Guessing
the second is what the /16 trap in §12.1 is about.
3. Identify the hypervisor — without any credential
Useful when you are handed a host and told "there's a hypervisor on it". Proxmox VE serves its UI and API on fixed TCP 8006, and the banner is unauthenticated.
curl -k -s -S -m 10 -i https://{{PVE_HOST}}:8006/
VERIFICATION — correct output contains all three of these [v]:
HTTP/1.1 200 OK
Server: pve-api-daemon/3.0
<title>spicy - Proxmox Virtual Environment</title>
Server: pve-api-daemon is the tell. The CSS cache-buster carries component
versions [v for the string, c for the interpretation]:
<link ... href="/pve2/css/ext6-pve.css?ver=9.2.10" /> -> pve-manager 9.2.10
<link ... href="/pwt/css/ext6-pmx.css?ver=v5.2.7-..." /> -> widget-toolkit 5.2.7
The REST API, by contrast, is closed [v]:
curl -k -s -S -m 10 -i https://{{PVE_HOST}}:8006/api2/json/version
→ HTTP/1.1 401 No ticket. So there is no credential-free path to storage,
capacity or the guest list. You need §4.
Do not over-read the TLS certificate. OU = PVE Cluster Node appears on
every PVE node including a standalone one; it does not mean the node is
clustered [c].
4. Get a shell — and read §7 before you choose the account
ssh -o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=5 root@{{PVE_HOST}} id
VERIFICATION — correct output [v]:
uid=0(root) gid=0(root) groups=0(root)
BatchMode=yes matters: it makes the probe fail fast instead of hanging on a
password prompt, which is what an unattended agent needs.
If you get Permission denied (publickey,password) — that message is
identical whether your key is missing or the account does not exist. Today
that ambiguity cost hours: only a non-root account had been tested, and the
conclusion "we are locked out" was wrong; root authenticated fine on the
first try [v]. Test root@ explicitly before concluding you lack access.
Then confirm the version from inside:
ssh -o BatchMode=yes root@{{PVE_HOST}} pveversion
VERIFICATION: a line beginning pve-manager/9. (today: 9.2.10) [v].
5. Recon — storage, capacity, free VMID, bridge, network prefix
Run all of these before creating anything. Each is read-only.
5.1 Which storage pool is the redundant array?
ssh -o BatchMode=yes root@{{PVE_HOST}} pvesm status
VERIFICATION — today's output [v]:
Name Type Status Total (KiB) Used (KiB) Available (KiB) %
bulk-array zfspool active 23275080349 3542208037 19732872312 15.22%
local dir active 174683904 15057920 159625984 8.62%
local-zfs zfspool active 279044948 119418924 159626024 42.80%
bulk-array is the array (≈22 TiB total, ≈18 TiB free). local-zfs is the
boot pool — that is the one you must not land on if the requirement is
"storage on the array". Set {{STORAGE}} from this table, never from memory.
5.2 Is it thin or thick? — read storage.cfg, not pvesm
ssh -o BatchMode=yes root@{{PVE_HOST}} cat /etc/pve/storage.cfg
VERIFICATION — today's output [v]:
dir: local
path /var/lib/vz
content import,iso,backup,vztmpl
zfspool: local-zfs
pool rpool/data
content rootdir,images
sparse 1
zfspool: bulk-array
pool bulk-storage
content images,rootdir
mountpoint /bulk-storage
The trap: local-zfs has sparse 1; bulk-array does not. No sparse
line means thick provisioning — a 500 GB disk genuinely reserves ~507 GB
on the pool the moment you create it, rather than growing as it fills [v].
Plan capacity on the provisioned size, not the used size. If you want thin behaviour, that is a storage-config change on the node (out of scope here, and someone's decision, not an agent's).
Confirm the reservation after the build:
ssh -o BatchMode=yes root@{{PVE_HOST}} zfs list -o name,used,refreservation,volsize {{ZPOOL}}/vm-{{VMID}}-disk-0
VERIFICATION: refreservation is non-zero and close to volsize on a
thick pool [v]. On a thin pool it reads none.
5.3 Array health
ssh -o BatchMode=yes root@{{PVE_HOST}} zpool list
ssh -o BatchMode=yes root@{{PVE_HOST}} zpool status {{ZPOOL}}
VERIFICATION: state: ONLINE, and errors: No known data errors [v].
Today: raidz1 across 3 spinning disks, plus one SATA SSD as a cache (L2ARC)
vdev; 32.7T raw, 29.3T free [v]. Do not provision onto a DEGRADED pool.
5.4 Free VMID — check BOTH lists
Containers and VMs share one ID space on Proxmox. A VMID is free only if it is absent from both.
ssh -o BatchMode=yes root@{{PVE_HOST}} qm list
VERIFICATION — today [v]:
VMID NAME STATUS MEM(MB) BOOTDISK(GB) PID
112 poly-build-vm stopped 32768 64.00 0
131 blade-forge running 16384 80.00 7392
132 blade-vmtest stopped 2048 8.00 0
900 instr-template stopped 4096 10.00 0
ssh -o BatchMode=yes root@{{PVE_HOST}} pct list
VERIFICATION — today [v]: 16 containers at IDs
100 103 105 110 120 121 122 123 124 130 142 143 202 203 206 211.
This list is a moving target. Container 121 appeared in one reading and
was gone from a later one the same day, because it was live-migrated to another
node [v]. Re-run pct list immediately before qm create, not from notes taken
an hour earlier.
Union of the two: 100 103 105 110 112 120 121 122 123 124 130 131 132 142 143 202 203 206 211 900. 133 is free in both — hence {{VMID}}=133 [v].
qm create refuses a VMID already used by a VM, but checking pct list too is
what stops you from picking an ID a container holds [c].
5.5 The bridge
ssh -o BatchMode=yes root@{{PVE_HOST}} ip -br link
VERIFICATION — today's relevant lines [v]:
vmbr0 UP 44:a8:42:1e:be:c1 <BROADCAST,MULTICAST,UP,LOWER_UP>
vmbr1 UNKNOWN 4a:3f:8d:e9:cf:c7 <BROADCAST,MULTICAST,UP,LOWER_UP>
Two bridges exist. vmbr0 is the LAN one — proven by the next check, not by
its name.
5.6 The network prefix — verify it, do not assume /24
ssh -o BatchMode=yes root@{{PVE_HOST}} ip -4 addr show {{BRIDGE}}
ssh -o BatchMode=yes root@{{PVE_HOST}} ip route show default
VERIFICATION — today [v]:
6: vmbr0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 ...
inet 192.168.3.201/16 scope global vmbr0
default via 192.168.0.1 dev vmbr0 proto kernel onlink
/16, not /24. The whole first phase of today's work assumed a /24 and
was wrong [v]. A /16 here means hosts are scattered across 192.168.0.x,
192.168.3.x and 192.168.10.x, and the gateway (192.168.0.1) is outside
the /24 you would have guessed. A VM configured 192.168.3.204/24 with
gateway 192.168.0.1 would not route at all.
5.7 Capacity
ssh -o BatchMode=yes root@{{PVE_HOST}} free -h
ssh -o BatchMode=yes root@{{PVE_HOST}} lscpu
Today: 188 GiB host RAM, ZFS ARC capped at 64 GiB [v]. Budget the VM's RAM against what the running guests already hold, not against total.
6. Download the cloud image (on the node)
Debian's generic image is the direct equivalent of Ubuntu's
cloudimg — a pre-built, cloud-init-enabled disk image, not an installer
ISO. There is no qm install-media step and no console-driven installer.
ssh -o BatchMode=yes root@{{PVE_HOST}} wget -nv -O {{IMG_PATH}} https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
VERIFICATION — correct output [v]:
2026-08-30 14:14:23 URL:https://<mirror>/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2 [433651712/433651712] -> "/tmp/debian-13-generic-amd64.qcow2" [1]
Byte counts equal on both sides of the slash, and the redirect lands on a
Debian CDN mirror (today chuangtzu.ftp.acc.umu.se) — that is normal, not a
hijack [v].
Confirm the file:
ssh -o BatchMode=yes root@{{PVE_HOST}} ls -l {{IMG_PATH}}
VERIFICATION: size ≈ 433 MB (433651712 bytes today) [v].
Ubuntu translation, for a reader who knows Ubuntu:
| Ubuntu | Debian 13 equivalent |
|---|---|
noble-server-cloudimg-amd64.img |
debian-13-generic-amd64.qcow2 |
default cloud-init user ubuntu |
default cloud-init user debian |
cloud-images.ubuntu.com |
cloud.debian.org/images/cloud/trixie/latest/ |
snap, PPAs |
neither exists; apt only |
You override the default user with --ciuser in §8, so debian vs ubuntu
only bites you if you forget to set it and then try to log in as ubuntu.
Integrity, and an honest gap: Debian publishes SHA512SUMS and a detached
SHA512SUMS.sign alongside the image. Today's build did not verify the
checksum [v — it was not run]. If you care, fetch SHA512SUMS from the same
directory and check before importing. Stated as a gap rather than papered over.
7. ⚠ CREDENTIAL DECISIONS — read this section before running §8
Five decisions. Each was made deliberately today; each is stated with its reasoning so you can make it differently on purpose rather than by accident.
7.1 THE BIG ONE — --sshkeys /root/.ssh/authorized_keys injects EVERY key
Proxmox's qm set --sshkeys <file> takes a file of public keys and hands it to
cloud-init, which writes it into the new user's ~/.ssh/authorized_keys in the
guest. Every guide on the internet shows the convenient form:
# DO NOT DO THIS unless you have read the file and want every key in it
qm set {{VMID}} --sshkeys /root/.ssh/authorized_keys
The hazard, stated generically: a hypervisor's root authorized_keys is
usually a multi-party file. It accumulates the key of everyone who has ever
needed to administer the node — colleagues, a vendor, an old laptop, a
decommissioned CI runner. Seeding a new VM from it silently grants every one
of those parties login to the new machine, as your {{CIUSER}}, with
whatever sudo rights cloud-init gives that user. Nobody is notified. It does
not show up as a "sharing" action; it shows up as a one-line convenience flag.
On the reference node that file held four keys belonging to more than one
person [v], and the VM's user has passwordless sudo [v] — so the grant is
root in the guest, not a shell.
The safe recipe. Steps 2 and 3 below are exactly what was run today, and they worked [v]. Step 1 is this guide's addition — it was NOT run today, so treat it as recommended rather than proven [c].
# 1. Look at the file first. Count the keys. Decide, per key, on purpose.
ssh -o BatchMode=yes root@{{PVE_HOST}} 'wc -l < /root/.ssh/authorized_keys'
ssh -o BatchMode=yes root@{{PVE_HOST}} 'cut -d" " -f3 /root/.ssh/authorized_keys'
# 2. Copy exactly the ONE key you intend to grant, to its own file.
scp -o BatchMode=yes {{PUBKEY_LOCAL}} root@{{PVE_HOST}}:/tmp/onekey.pub
# 3. Point --sshkeys at THAT file.
ssh -o BatchMode=yes root@{{PVE_HOST}} qm set {{VMID}} --sshkeys /tmp/onekey.pub
Step 1's second command prints only the comment field of each key (usually an
email or user@host), not the key material — enough to identify them, without
copying secrets around.
VERIFICATION that only one key landed — check inside the guest after boot:
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} 'wc -l < ~/.ssh/authorized_keys'
Correct output: 1. If it is 4, you used the wrong file; the fix is to
edit the guest's authorized_keys, and to treat the other three keyholders as
having had access in the interim.
Cleanup: /tmp/onekey.pub is a public key, so leaving it is not a
disclosure — but delete it anyway so the next person does not reuse a stale
file believing it is current.
7.2 SSH host-key acceptance — verified TOFU, and what it costs
Two ways to accept a new host's key:
# Blind trust-on-first-use. Accepts whatever key answers, no questions asked.
ssh -o StrictHostKeyChecking=accept-new {{CIUSER}}@{{VM_IP}} id
versus
# Verified TOFU, route A -- the serial console, which needs no network at all.
# `--serial0 socket` in 8.1 exists for this. cloud-init prints an
# "SSH HOST KEY FINGERPRINTS" banner to the console on first boot.
ssh -o BatchMode=yes -t root@{{PVE_HOST}} qm terminal {{VMID}}
# (press Enter; scroll back to the fingerprint banner; ^O to exit qm terminal)
# Verified TOFU, route B -- via the guest agent. ONLY works after Section 10,
# so it does not help on a first connection. Use A, or accept that B is
# a re-verification rather than a first one.
ssh -o BatchMode=yes root@{{PVE_HOST}} "qm guest exec {{VMID}} -- ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub"
# Then, from your workstation, over the network you do not yet trust:
ssh-keyscan -t ed25519 {{VM_IP}} 2>/dev/null | ssh-keygen -lf -
# A human compares the two SHA256 strings and accepts only if identical.
Provenance on that block: the ssh-keyscan ... | ssh-keygen -lf - form was run
today, against a different host, and printed a fingerprint [v]. Routes A and B
were not run and are reasoned, not measured [c] -- route B in particular has
the ordering problem noted in its comment.
What accept-new actually costs you: it trusts whatever answers on that
address at that moment. On a flat LAN with no DHCP reservations, an address can
be held by something other than what you think — and accept-new will record
that impostor's key permanently, after which the real host trips a
REMOTE HOST IDENTIFICATION HAS CHANGED warning and looks like the attacker.
Recommendation: verified TOFU. It costs exactly one human step — a person comparing two fingerprint strings — and that step cannot be delegated to the agent doing the connecting, because an agent that can read the fingerprint over the untrusted channel is not independently verifying anything.
Honest record of what happened today, since this guide should not claim more discipline than was exercised [v]:
- For the new VM,
-o StrictHostKeyChecking=accept-newwas used, and it succeeded:Warning: Permanently added '192.168.3.204' (ED25519) to the list of known hosts.Blind TOFU, on a host that had existed for ten minutes on a LAN under the operator's control — low risk, but not the recommended path. - For a different host (
sloth), the sameaccept-newflag was denied by the workstation's permission classifier [v], and the fingerprint route (ssh-keyscan | ssh-keygen -lf -) was used instead so a human could verify out of band before accepting.
So the recommendation above is the lesson, drawn from the host where the guard rail fired — not a description of the VM step. Marked plainly because a guide that overstates its own rigour teaches the wrong habit.
7.3 Root-only on the node, versus creating a named admin user
Ruled: root-only, here. The reasoning, not just the ruling:
Proxmox has a real user/ACL system (pveum), and on a node several people
administer, named accounts with scoped roles are correct — you get per-person
audit trails in /var/log/pve/tasks/, and you can revoke one person without
rotating everyone.
It was not done here because:
- A named user did not exist and creating one is a write on the node. The task was to build a VM, not to restructure the node's identity model. A provisioning job that quietly adds an admin account is doing something nobody asked for.
qmneeds root or near-root anyway. Creating a VM, importing a disk to a ZFS pool and resizing it requireVM.Allocate,Datastore.AllocateSpaceandDisk.Resize— most ofPVEVMAdminplus storage rights. A named user with those permissions is not meaningfully less privileged for this task; it is better attributed, which is a different benefit [c].- The lower-privilege option was noted and is the right one for recon. A
read-only API token (
PVEAuditor, scoped to/) answers every question in §5 — storage, capacity, guest inventory, bridges — and can create nothing. If you are only surveying a node, ask for that, not for a shell [c].
Reconsider this if: more than one person administers the node, or you need
to answer "who created this VM" later. Then create a named user with
PVEVMAdmin on /vms and Datastore.AllocateSpace on the storage.
7.4 An unauthenticated inference endpoint on a flat LAN
The reference environment has a local LLM inference endpoint at
http://192.168.3.221:11434 -- an Ollama server in LXC 121 (ollama) on a
second Proxmox node (sloth), with two NVIDIA GPUs bind-mounted into the
container [v, per this campaign's item-#9 work]. That container was migrated
from the first node to the second earlier the same day, which is why an
earlier reading of the first node's pct list showed it and a later one did
not [v]. Reachability from the new VM was proved, not assumed:
curl -s -m 10 http://192.168.3.221:11434/api/tags from inside the VM returns
the full model list [v].
State this plainly, because the reader inherits it: HTTP, no TLS, no
authentication, on a flat /16 with no segmentation. Anything on that
network can use it — enumerate the models, send arbitrary prompts, consume
the GPUs, and read whatever any client sends through it. There is no per-client
identity, so there is no audit trail and nothing to revoke.
That is an accepted risk on a home LAN with known devices. It stops being acceptable the moment the network carries a guest SSID, an IoT VLAN, or a contractor's laptop. If you replicate this pattern, the honest mitigations are network-level (a separate VLAN, or an nftables rule restricting source addresses), not application-level — Ollama has no auth to turn on [c].
Related: the container's address is DHCP-assigned and will move [v].
Anything that hardcodes 192.168.3.221 breaks on the next lease change.
7.5 Never put a credential on a command line or in a URL
Anything you type as an argument is visible in at least three places you do not
control: your shell history file, the process table (ps aux, readable by
every user on the box for the lifetime of the process), and any log that
records commands — including, on this workstation, the agent transcript itself.
A credential in a URL is worse: it additionally reaches proxy logs, server
access logs, and Referer headers.
So, never:
curl https://user:token@host/... # NO
qm ... --password 'hunter2' # NO
export API_KEY=sk-... # NO, if typed interactively
Instead:
- Prefer key-based auth with no secret in the command at all. Every
authenticated command in this guide is an
sshthat carries a key by reference, never a password. That is why the guide has no secrets in it. - If a token is unavoidable, read it from a file:
curl -H @/path/to/header-fileorcurl --netrc-file /path/to/netrc, with the filechmod 600. - For a Proxmox API token, put it in a file and source it in a non-interactive script, so it never enters your interactive history.
--sshkeysis fine — a public key is not a credential. It is the selection of which public keys, per §7.1, that matters.
8. Build the VM
Four commands. Run them in this order.
8.1 Create the shell (no disk yet)
ssh -o BatchMode=yes root@{{PVE_HOST}} qm create {{VMID}} \
--name {{VM_NAME}} \
--memory {{RAM_MB}} \
--cores {{CORES}} \
--sockets 1 \
--cpu host \
--net0 virtio,bridge={{BRIDGE}} \
--scsihw virtio-scsi-single \
--ostype l26 \
--agent 1 \
--serial0 socket
This is the exact command run today (with --name dev-agents; the rename to
itslocal came later, §11) [v].
Each flag, since an agent should not have to guess:
| Flag | Why |
|---|---|
--cpu host |
passes the host CPU's features through; needed for anything using modern instruction sets |
--scsihw virtio-scsi-single |
one virtio-scsi controller per disk; the modern PVE default, enables discard/IO-thread |
--ostype l26 |
tells PVE "Linux 2.6+"; affects device defaults |
--agent 1 |
enables the QEMU guest agent channel hypervisor-side. The guest package is separate — see §10 |
--serial0 socket |
gives you qm terminal {{VMID}} as a console. Cloud images write their boot log to serial; without this you are blind if networking fails |
no --boot yet |
set in 8.2, after there is a disk to boot |
VERIFICATION:
ssh -o BatchMode=yes root@{{PVE_HOST}} qm config {{VMID}}
Correct output contains name: {{VM_NAME}}, cores: 12, memory: 32768,
net0: virtio=...,bridge=vmbr0, agent: 1, and no scsi0: line yet [v].
8.2 Import the disk, attach cloud-init, set user, key and network — one command
ssh -o BatchMode=yes root@{{PVE_HOST}} qm set {{VMID}} \
--scsi0 {{STORAGE}}:0,import-from={{IMG_PATH}} \
--ide2 {{STORAGE}}:cloudinit \
--boot order=scsi0 \
--ciuser {{CIUSER}} \
--sshkeys /tmp/onekey.pub \
--ipconfig0 ip={{VM_IP_CIDR}},gw={{GATEWAY}}
This is today's command, with two deliberate differences flagged below [v].
Reading the arguments:
--scsi0 {{STORAGE}}:0,import-from=<path>— the:0means "allocate a volume sized from the imported image" (the image's own 3 GB). This is the modern replacement forqm importdisk+ a separate attach; it does both in one step, and lands the volume on{{STORAGE}}[v].--ide2 {{STORAGE}}:cloudinit— creates the cloud-init drive: a tiny generated ISO carrying the user, key and network config into the guest on first boot. Without this,--ciuser,--sshkeysand--ipconfig0do nothing — they are only ever delivered through this drive.--boot order=scsi0— boot the imported disk. No install media, noide2in the boot order: the cloud-init drive is data, not bootable.--sshkeys /tmp/onekey.pub— §7.1. Not/root/.ssh/authorized_keys.--ipconfig0 ip=...,gw=...— the static address. §12.1 explains why static rather thanip=dhcp.
Two differences from what literally ran today, both improvements:
- Today's command used
--ipconfig0 ip=dhcp, DHCP failed to lease, and a secondqm set --ipconfig0 ip=192.168.3.204/16,gw=192.168.0.1plus a stop/start was needed [v]. Setting the static address here skips that. - Today's
--sshkeyspointed at/tmp/danyel.pub; renamed to/tmp/onekey.pubfor a general reader. Same mechanism [v].
VERIFICATION — the import is not instant. Correct output ends with [v]:
transferred 3.0 GiB of 3.0 GiB (100.00%)
scsi0: successfully created disk 'bulk-array:vm-133-disk-0,size=3G'
generating cloud-init ISO
Both lines matter: successfully created disk names the storage — read it
and confirm it says {{STORAGE}}, not local-zfs — and generating cloud-init ISO confirms --ide2 took effect.
Then:
ssh -o BatchMode=yes root@{{PVE_HOST}} qm config {{VMID}}
Correct output now includes [v]:
boot: order=scsi0
ciuser: kala
ide2: bulk-array:vm-133-cloudinit,media=cdrom
ipconfig0: ip=192.168.3.204/16,gw=192.168.0.1
scsi0: bulk-array:vm-133-disk-0,size=3G
sshkeys: ssh-ed25519%20AAAA...%20user%40example.com%0A
Note sshkeys is stored URL-encoded (%20 for space, %0A for newline).
That is normal. It also means the key comment — often an email address — is
readable in qm config output by anyone who can run it. Redact that line
before pasting qm config output anywhere.
size=3G is expected at this point: it is the cloud image's own size. §8.3
fixes it.
8.3 Grow the disk
ssh -o BatchMode=yes root@{{PVE_HOST}} qm disk resize {{VMID}} scsi0 {{DISK_SIZE}}
[v — run today, twice: once to 200G, then to 500G after the size decision
changed. Once is sufficient; resizing up again works.]
VERIFICATION:
ssh -o BatchMode=yes root@{{PVE_HOST}} qm config {{VMID}} | grep scsi0
Correct output: scsi0: bulk-array:vm-133-disk-0,size=500G [v].
The trap: this grows the virtual disk, not the filesystem. At this point
the guest would see a 500 GB block device with a 3 GB partition on it. The
filesystem reaches the new size only because the cloud image runs
cloud-init's growpart + resizefs modules on boot, which grow the
partition and then the filesystem to fill the device.
Consequences an agent must plan for:
- Resize before first boot (as here) and it is done in one step.
- Resize after boot and you must reboot the guest, or run
growpart /dev/sda 1andresize2fs /dev/sda1inside it by hand. - On an image without cloud-init,
growpartnever runs and the extra space stays invisible forever. This is why thegeneric/cloudimgimages are the right base and a plain installer ISO is not [c].
Verification that growpart actually ran is in §9 — df -h /, not qm config.
8.4 Start
ssh -o BatchMode=yes root@{{PVE_HOST}} qm start {{VMID}}
ssh -o BatchMode=yes root@{{PVE_HOST}} qm status {{VMID}}
VERIFICATION: status: running [v].
9. Verify the guest end-to-end
Do not accept "the start command exited 0" as proof the VM works. Four checks.
9.1 It answers on the network
ping -c 40 -W 1 {{VM_IP}}
VERIFICATION: replies begin within ~30 s of qm start [v]. A cloud image's
first boot runs cloud-init before sshd is fully up, so allow a minute.
9.2 The right user exists, with the right key
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} id
(First connection: accept the host key per §7.2.)
VERIFICATION — today's output [v]:
uid=1000(kala) gid=1000(kala) groups=1000(kala),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev)
27(sudo) is the one that matters — cloud-init put the user in sudo and
wrote /etc/sudoers.d/90-cloud-init-users granting ALL=(ALL) NOPASSWD:ALL
[v]. That is passwordless root in the guest for every key in
authorized_keys — which is why §7.1 is the section it is.
And the key count, per §7.1:
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} 'wc -l < ~/.ssh/authorized_keys'
VERIFICATION: 1.
9.3 growpart reached the filesystem
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} df -h /
VERIFICATION — today's output [v]:
Filesystem Size Used Avail Use% Mounted on
/dev/sda1 492G 1.2G 471G 1% /
492G against a 500G volume is correct — the difference is GiB-vs-GB plus
partition overhead. If this says 3.0G, growpart did not run: check
cloud-init status in the guest and that the disk was resized before first
boot.
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} lsblk
VERIFICATION: sda1 occupies essentially all of sda [v].
9.4 The OS is what you think
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} 'cat /etc/os-release; uname -r'
VERIFICATION — today [v]: Debian GNU/Linux 13.6 (trixie), kernel
6.12.107+deb13-amd64.
10. qemu-guest-agent on Debian — the trap that cost the most time
--agent 1 in §8.1 enabled the hypervisor side. Nothing is listening in the
guest until you install the package — and on Debian, installing it is not
enough.
Before installing, confirm the failure mode so you recognise it [v]:
ssh -o BatchMode=yes root@{{PVE_HOST}} qm guest cmd {{VMID}} network-get-interfaces
Output before the agent runs: QEMU guest agent is not running [v].
10.1 Install
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} sudo -n apt-get update
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} sudo -n DEBIAN_FRONTEND=noninteractive apt-get -y install qemu-guest-agent
(sudo -n = never prompt; it fails loudly instead of hanging an unattended
agent on a password prompt. DEBIAN_FRONTEND=noninteractive stops debconf
opening a dialog on a pipe.)
VERIFICATION — and here is the trap, in the installer's own words [v]:
Setting up qemu-guest-agent (1:10.0.11+ds-0+deb13u1) ...
qemu-guest-agent.service is a disabled or a static unit, not starting it.
Read that line. On Ubuntu, installing qemu-guest-agent enables and
starts it, and you are done. On Debian 13 the unit is static — it has no
[Install] section, so it cannot be enabled, and the package deliberately
does not start it. It is meant to be device-activated: systemd starts it
when /dev/virtio-ports/org.qemu.guest_agent.0 appears, via
dev-virtio\x2dports-org.qemu.guest_agent.0.device. On a VM that was already
running when you installed it, that device event has already passed, so nothing
starts it.
10.2 Start it explicitly
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} sudo -n systemctl start qemu-guest-agent
[v]
Do not run systemctl enable qemu-guest-agent — it will fail or warn,
because a static unit has no install target. That failure is not a problem to
fix; it is the design.
VERIFICATION — from the hypervisor side, which is the side that matters:
ssh -o BatchMode=yes root@{{PVE_HOST}} qm guest cmd {{VMID}} network-get-interfaces
Correct output: a JSON array of interfaces including lo and ens18/eth0
with the VM's IPv4 address [v]. The QEMU guest agent is not running message
is gone.
10.3 Prove it survives a reboot — do not assume it
Because the unit is device-activated rather than enabled, "will it come back?" is a real question, not a formality. Answer it:
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} sudo -n systemctl reboot
ping -c 45 -W 1 {{VM_IP}}
ssh -o BatchMode=yes root@{{PVE_HOST}} qm guest cmd {{VMID}} get-osinfo
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} uptime
VERIFICATION [v]: get-osinfo returns JSON naming Debian 13 after the
reboot, and uptime confirms the machine really restarted:
18:33:29 up 1 min, 1 user, load average: 0.08, 0.02, 0.01
This was run today and it passed — the device unit does fire on boot [v]. It is in the guide because proving it costs 90 seconds and assuming it costs a mystery later.
Ubuntu translation: on Ubuntu you would write
systemctl enable --now qemu-guest-agent and never think about it again. On
Debian, --now is the only half that works, and the persistence comes from a
different mechanism (device activation) that you should verify once.
11. Rename (optional) — and the caveat that bit today
Proxmox-side rename:
ssh -o BatchMode=yes root@{{PVE_HOST}} qm set {{VMID}} --name {{VM_NAME}}
[v — this is how dev-agents became itslocal.]
VERIFICATION: qm config {{VMID}} | grep name → name: itslocal [v].
The caveat: qm set --name changes the hypervisor's label only. The
guest's own /etc/hostname is untouched, and it was set at first boot by
cloud-init from the name the VM had then. Verified today [v]:
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} hostname
→ dev-agents, not itslocal.
So the machine answers to one name in the Proxmox UI and a different one at its own shell prompt. To change the guest's hostname too:
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} sudo -n hostnamectl set-hostname {{VM_NAME}}
[c — not run today. Note that cloud-init may reset it on next boot unless
preserve_hostname: true is set in /etc/cloud/cloud.cfg.]
Cleanest option: pick the final name before §8.1 and never rename.
12. Traps, collected
Each of these cost real time today. They are the part of this guide the official documentation will not give you.
| # | Trap | Where |
|---|---|---|
| 1 | qemu-guest-agent on Debian is a static, device-activated unit — installing it does not start it, and enable does not apply |
§10 |
| 2 | Debian's generic = Ubuntu's cloudimg; the default cloud-init user is debian, not ubuntu |
§6 |
| 3 | A ZFS pool exposed to Proxmox may be thick (no sparse 1 in storage.cfg) — a 500 GB disk really reserves 500 GB |
§5.2 |
| 4 | The guest disk is a zvol, so recordsize does not apply. volblocksize does, and it is immutable after creation |
§13 |
| 5 | Growing the disk needs cloud-init's growpart on the next boot to reach the filesystem | §8.3 |
| 6 | Verify the network prefix. Today's LAN is a /16 with the gateway outside the obvious /24, and no DHCP on the segment — hence a static address |
§5.6, §12.1 below |
| 7 | Containers and VMs share the VMID space — a free ID must be free in qm list AND pct list |
§5.4 |
| 8 | Permission denied (publickey,password) is identical for "missing key" and "no such account" — test root@ explicitly |
§4 |
| 9 | --sshkeys /root/.ssh/authorized_keys injects every key on the node |
§7.1 |
| 10 | qm set --name does not change the guest's hostname |
§11 |
12.1 No DHCP on the segment
Today's VM was created with --ipconfig0 ip=dhcp, booted, and after several
minutes held an IPv6 link-local address and no IPv4 lease [v]. There is no
DHCP server on that segment; the other hosts are statically addressed.
How to diagnose this rather than waiting:
ssh -o BatchMode=yes root@{{PVE_HOST}} qm guest cmd {{VMID}} network-get-interfaces
If the guest interface shows only fe80::... and no 192.168.x.x, DHCP is not
answering. The fix, applied today [v]:
ssh -o BatchMode=yes root@{{PVE_HOST}} qm set {{VMID}} --ipconfig0 ip={{VM_IP_CIDR}},gw={{GATEWAY}}
ssh -o BatchMode=yes root@{{PVE_HOST}} qm stop {{VMID}}
ssh -o BatchMode=yes root@{{PVE_HOST}} qm start {{VMID}}
A stop/start is required, not qm reboot — the cloud-init drive is
regenerated and re-read at machine start, and a warm reboot may not pick it up
[c, though the stop/start is what was actually done and it worked [v]].
Pick the static address by checking it is unused first:
ssh -o BatchMode=yes root@{{PVE_HOST}} ip neigh
ping -c 3 -W 1 {{VM_IP}}
VERIFICATION: the address is absent from ip neigh and the ping gets no
reply before you assign it.
13. Storage tuning — what you can change, and what you cannot
Worth stating because the obvious ZFS advice does not apply to a VM disk.
A Proxmox VM disk on a zfspool storage is a zvol — a block device — not a
filesystem dataset. Therefore:
ssh -o BatchMode=yes root@{{PVE_HOST}} zfs get recordsize {{ZPOOL}}/vm-{{VMID}}-disk-0
VERIFICATION: returns - [v]. recordsize is a dataset property and has no
meaning here. Every "tune ZFS recordsize for your workload" article is about
datasets, and following it here does nothing.
The property that does apply:
ssh -o BatchMode=yes root@{{PVE_HOST}} zfs get volblocksize,compression {{ZPOOL}}/vm-{{VMID}}-disk-0
Today: volblocksize=16K, compression=lz4 (achieving 1.49x on this
volume) [v].
volblocksize is fixed at creation and cannot be changed on an existing
zvol [c — documented ZFS behaviour]. If you want a different value you must
set the storage's blocksize in /etc/pve/storage.cfg before creating the
disk, and recreate. On a 3-wide raidz1 with ashift=12, 16K is a reasonable
default and there is nothing to do [c].
compression=lz4 is inherited from the pool and is the right choice for source
trees — cheap on CPU, pays for itself against slow disks. Leave it.
14. What this build did NOT do
Stated so nobody assumes it.
- No development toolchain. The guest is a bare Debian 13 generic cloud
image plus
qemu-guest-agent— 324 dpkg packages, of which the manually-selected set is exactly that [v]. Nogit, nonode, notmux, norg.python33.13.5 is present, pulled in as a cloud-init dependency rather than chosen [v].curl,vim,nano,sudo,ca-certificatesare present [v]. - No image checksum verification (§6).
- No firewall rules. Proxmox's own firewall was not enabled for this VM, and nothing restricts the guest from reaching the hypervisor's API on 8006.
- No backup job, no snapshot schedule.
qm snapshot {{VMID}} <name>is available and cheap on ZFS; nothing was configured. - No swap in the guest [v]. Memory exhaustion is a cliff ending in the OOM killer, not a slowdown.
- Two kernel limits left at their defaults that will bite a
many-process workload before RAM does:
fs.inotify.max_user_instances = 128(a per-uid limit, so every process run by the same user draws from one pool of 128) and the interactiveulimit -nsoft limit of 1024 [v]. Raise both if you intend to run many file-watching processes as one user.
15. The complete sequence, condensed
For an agent that has read the above and wants the list. Every line was run today except where marked.
# --- recon (read-only) ---
curl -k -s -S -m 10 -i https://{{PVE_HOST}}:8006/
ssh -o BatchMode=yes -o StrictHostKeyChecking=yes root@{{PVE_HOST}} id
ssh -o BatchMode=yes root@{{PVE_HOST}} pveversion
ssh -o BatchMode=yes root@{{PVE_HOST}} pvesm status
ssh -o BatchMode=yes root@{{PVE_HOST}} cat /etc/pve/storage.cfg
ssh -o BatchMode=yes root@{{PVE_HOST}} zpool status {{ZPOOL}}
ssh -o BatchMode=yes root@{{PVE_HOST}} qm list
ssh -o BatchMode=yes root@{{PVE_HOST}} pct list
ssh -o BatchMode=yes root@{{PVE_HOST}} ip -br link
ssh -o BatchMode=yes root@{{PVE_HOST}} ip -4 addr show {{BRIDGE}}
ssh -o BatchMode=yes root@{{PVE_HOST}} ip route show default
ssh -o BatchMode=yes root@{{PVE_HOST}} free -h
# --- key hygiene (see 7.1) ---
ssh -o BatchMode=yes root@{{PVE_HOST}} 'cut -d" " -f3 /root/.ssh/authorized_keys'
scp -o BatchMode=yes {{PUBKEY_LOCAL}} root@{{PVE_HOST}}:/tmp/onekey.pub
# --- image ---
ssh -o BatchMode=yes root@{{PVE_HOST}} wget -nv -O {{IMG_PATH}} https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
# --- build ---
ssh -o BatchMode=yes root@{{PVE_HOST}} qm create {{VMID}} --name {{VM_NAME}} --memory {{RAM_MB}} --cores {{CORES}} --sockets 1 --cpu host --net0 virtio,bridge={{BRIDGE}} --scsihw virtio-scsi-single --ostype l26 --agent 1 --serial0 socket
ssh -o BatchMode=yes root@{{PVE_HOST}} qm set {{VMID}} --scsi0 {{STORAGE}}:0,import-from={{IMG_PATH}} --ide2 {{STORAGE}}:cloudinit --boot order=scsi0 --ciuser {{CIUSER}} --sshkeys /tmp/onekey.pub --ipconfig0 ip={{VM_IP_CIDR}},gw={{GATEWAY}}
ssh -o BatchMode=yes root@{{PVE_HOST}} qm disk resize {{VMID}} scsi0 {{DISK_SIZE}}
ssh -o BatchMode=yes root@{{PVE_HOST}} qm start {{VMID}}
# --- verify ---
ssh -o BatchMode=yes root@{{PVE_HOST}} qm status {{VMID}}
ssh -o BatchMode=yes root@{{PVE_HOST}} qm config {{VMID}}
ping -c 40 -W 1 {{VM_IP}}
# accept the host key per 7.2 (verified TOFU), then:
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} id
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} 'wc -l < ~/.ssh/authorized_keys'
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} df -h /
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} lsblk
# --- guest agent (see 10) ---
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} sudo -n apt-get update
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} sudo -n DEBIAN_FRONTEND=noninteractive apt-get -y install qemu-guest-agent
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} sudo -n systemctl start qemu-guest-agent
ssh -o BatchMode=yes root@{{PVE_HOST}} qm guest cmd {{VMID}} network-get-interfaces
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} sudo -n systemctl reboot
ping -c 45 -W 1 {{VM_IP}}
ssh -o BatchMode=yes root@{{PVE_HOST}} qm guest cmd {{VMID}} get-osinfo
ssh -o BatchMode=yes {{CIUSER}}@{{VM_IP}} uptime
16. Decisions carried from the original build, not re-argued here
- A full VM, not an LXC container. Ruled explicitly. This guide does not offer the container path even though it is cheaper.
- Storage on the redundant array, not the boot disk. Ruled. This is what
{{STORAGE}}is for, and §5.1/§8.2's verification exists to catch a wrong substitution. - Debian 13. Chosen. Debian-specific behaviour is translated into Ubuntu terms wherever it differs (§6, §10).
- The VM is named
itslocal— renamed fromdev-agentsafter the build (§11). The guest OS hostname is a SEPARATE setting that renaming the VM does not touch; it was initially missed and has since been set toitslocalas well.
17. Corrections this guide makes to the brief it was written from
Recorded because the guide should not silently paper over a wrong brief.
accept-newwas NOT refused for the VM. The initial brief for this guide said the session was deniedStrictHostKeyChecking=accept-newand a human verified the fingerprint out of band. That happened for a different host (sloth). For this VM,accept-newwas used and succeeded [v]. §7.2 states both, and presents verified TOFU as a recommendation rather than a description.--sshkeys,--ciuser,--ide2,--scsi0and--ipconfig0were oneqm set, not five steps [v]. §8.2 keeps them together, as run.- The disk was resized twice (200G then 500G) [v]. The guide gives the single-resize form and notes the history.
--ipconfig0 ip=dhcpwas set first and failed; the static address came after, with a stop/start [v]. §8.2 sets static up front and §12.1 keeps the diagnosis.- The rename to
itslocalis Proxmox-side only; the guest still calls itselfdev-agentsAT THE TIME THIS GUIDE WAS WRITTEN [v]. FIXED SINCE:hostnamectl set-hostname itslocalwas run and /etc/hosts corrected;hostname -fnow returnsitslocal[v]. The underlying point stands and is the reason §11 exists -- the Proxmox VM name and the guest OS hostname are independent, and renaming one does not rename the other. qm createdid not carry--scsi0,--cdromor--boot. Earlier draft command sketches in the campaign record show an ISO-install shape that was never run; the cloud-image path replaced it [v].