September 27, 2026
Proxmox VE API & Otomasi VM.
Membedah REST API Proxmox VE dari dalam: autentikasi token vs ticket, struktur endpoint, clone VM dari template cloud-init, polling task lewat UPID, LXC, alokasi IP, permission, sampai contoh client TypeScript yang tahan banting.
Web UI Proxmox VE di port 8006 sebenarnya cuma client biasa. Setiap klik di sana (buat VM, start, resize disk, buka console) diterjemahkan jadi request HTTP ke REST API yang sama persis dengan yang bisa kita panggil sendiri. Artinya, apa pun yang bisa dilakukan lewat UI bisa diotomasi.
Catatan ini lahir dari pengalaman membangun dua panel yang bicara langsung dengan API Proxmox: Proxmox Management Server (Fastify + TypeScript) dan modul virtualisasi di Web Host Manager , ditambah eksperimen di home server sendiri. Kalau Proxmox-nya belum terpasang, mulai dulu dari catatan Proxmox (VM) .
Gambaran Besar: pveproxy, pvedaemon, dan /api2/json
Di setiap node Proxmox ada dua daemon yang penting untuk kita:
pveproxy: mendengarkan HTTPS di port8006, melayani web UI dan API, lalu meneruskan request ke daemon lain bila perlu (termasuk ke node lain dalam satu cluster).pvedaemon: menjalankan operasi yang butuh hak istimewa (membuat VM, mengubah konfigurasi, dll.).
Semua endpoint berada di bawah base URL:
https://<host>:8006/api2/json/
Segmen json adalah format respons. Struktur path-nya hierarkis dan cukup mudah ditebak:
| Path | Isi |
|---|---|
/version | Versi Proxmox VE |
/cluster/resources?type=vm | Semua VM & container di seluruh cluster (satu request) |
/cluster/nextid | VMID berikutnya yang masih kosong |
/nodes | Daftar node |
/nodes/{node}/qemu | VM (QEMU/KVM) di satu node |
/nodes/{node}/qemu/{vmid}/config | Konfigurasi VM |
/nodes/{node}/qemu/{vmid}/status/start | Aksi power (start/stop/shutdown/reboot) |
/nodes/{node}/qemu/{vmid}/clone | Clone VM atau template |
/nodes/{node}/lxc | Container LXC |
/nodes/{node}/tasks/{upid}/status | Status task yang sedang berjalan |
/nodes/{node}/network | Interface jaringan node (bridge, bond, vlan) |
/access/... | User, role, ACL, token, login |
Setiap respons sukses dibungkus dalam objek { "data": ... }. Referensi lengkap semua endpoint beserta parameternya ada di API Viewer resmi
; bookmark halaman itu, karena akan sering dibuka.
Satu hal yang sering terlewat: API Proxmox adalah API per-node, tapi cluster-aware. Kamu bisa mengirim request /nodes/pve2/... ke pve1, dan pveproxy di pve1 akan meneruskannya ke pve2. Praktis, tapi berarti kalau pve1 mati, semua otomasi yang di-hardcode ke pve1 ikut mati.
Autentikasi: API Token vs Ticket
Ada dua cara untuk membuktikan identitas ke API.
1. Ticket (sesi berbasis login)
Kirim username dan password ke POST /access/ticket:
curl -k -d 'username=root@pam' --data-urlencode 'password=RAHASIA' \
https://pve.example.lan:8006/api2/json/access/ticket
Responsnya berisi dua nilai penting:
ticket: dikirim balik sebagai cookiePVEAuthCookiedi setiap request.CSRFPreventionToken: wajib dikirim sebagai headerCSRFPreventionTokenuntuk request yang mengubah data (POST/PUT/DELETE).
Ticket berlaku 2 jam. Artinya client harus menyimpan ticket, memperbaruinya sebelum kedaluwarsa, dan menangani 401 ketika ticket ternyata sudah tidak valid.
Ticket masih relevan untuk beberapa hal: login yang memakai 2FA (parameter otp/tfa-challenge), dan alur console (noVNC/xterm.js) di mana browser perlu cookie sesi.
2. API Token (stateless)
Token adalah cara yang disarankan untuk otomasi. Tidak ada login, tidak ada ticket, tidak ada CSRF token. Cukup satu header:
Authorization: PVEAPIToken=automation@pve!panel=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Formatnya USER@REALM!TOKENID=SECRET. Membuatnya:
pveum user add automation@pve --comment "User untuk panel otomasi"
pveum user token add automation@pve panel --privsep 1 --comment "Panel"
Nilai secret hanya ditampilkan sekali saat token dibuat; setelah itu tidak bisa diambil lagi lewat API. Simpan langsung di secret manager atau .env yang terenkripsi.
Perbandingan
| Aspek | Ticket | API Token |
|---|---|---|
| Masa berlaku | 2 jam, harus di-refresh | Sampai dicabut / --expire |
| CSRF token | Wajib untuk write | Tidak perlu |
| Butuh password user | Ya | Tidak |
| Bisa dibatasi lebih sempit dari user-nya | Tidak | Ya (--privsep 1) |
| Cocok untuk | UI, console, 2FA | Backend, cron, CI, panel |
Pelajaran dari Menulis Client Berbasis Ticket
Panel lama yang aku tulis ulang dulu memakai ticket, dan dari situ ada beberapa jebakan yang layak dicatat:
- Deduplikasi login. Kalau 20 request datang bersamaan dan ticket belum ada, jangan sampai terjadi 20 kali
POST /access/ticket. Simpan promise login yang sedang berjalan dan biarkan request lain menunggunya. - Batasi retry saat 401. Pola “kalau 401, login ulang lalu ulangi request” terlihat aman, sampai akun dicabut dan client terjebak rekursi tanpa akhir. Tetapkan batas (misal 2 kali), setelah itu lempar error.
- Simpan ticket di storage bersama (Redis/DB) bila ada banyak worker, supaya satu login dipakai bersama, bukan satu login per proses.
Kalau bisa memakai token, sebagian besar masalah di atas hilang dengan sendirinya.
Permission: Path, Role, dan Privilege Separation
Sistem izin Proxmox berbasis ACL dengan tiga unsur: path, subjek (user/group/token), dan role.
- Path menunjuk objek:
/,/nodes/{node},/vms/{vmid},/storage/{storeid},/pool/{pool},/sdn/.... - Role adalah kumpulan privilege. Bawaannya antara lain
Administrator,PVEVMAdmin,PVEVMUser,PVEAuditor,PVEDatastoreUser. - Privilege adalah unit terkecil:
VM.Allocate,VM.Clone,VM.PowerMgmt,VM.Config.Cloudinit,Datastore.AllocateSpace,SDN.Use,Sys.Audit, dan seterusnya.
Izin diwariskan ke bawah (--propagate 1 secara default), jadi role di /vms berlaku untuk semua VM.
Jebakan Privilege Separation
Dengan --privsep 1 (default), izin efektif token adalah irisan izin user dan izin token. Token baru tanpa ACL sendiri = token tanpa izin sama sekali, meskipun user-nya punya role lengkap. Ini penyebab klasik error 403 Permission check failed di hari pertama:
# Beri izin ke user DAN ke token-nya
pveum acl modify /vms --users automation@pve --roles PVEVMAdmin
pveum acl modify /vms --tokens 'automation@pve!panel' --roles PVEVMAdmin
pveum acl modify /storage/local-lvm --tokens 'automation@pve!panel' --roles PVEDatastoreUser
# Cek izin efektif token
pveum user token permissions automation@pve panel
Izin per Endpoint
API Viewer mencantumkan izin yang dibutuhkan setiap endpoint. Beberapa yang sering bikin bingung:
| Operasi | Izin yang dibutuhkan |
|---|---|
Buat VM (POST /nodes/{node}/qemu) | VM.Allocate di /vms/{vmid} atau pool; Datastore.AllocateSpace di storage; SDN.Use di bridge/VLAN yang dipakai |
| Clone | VM.Clone di VM sumber + VM.Allocate di VMID baru (atau pool) + izin storage & bridge |
| Buat LXC privileged | Tambahan Sys.Modify di / |
| Lihat status task milik user lain | Sys.Audit di /nodes/{node} |
| Baca IP via guest agent | VM.GuestAgent.Audit atau VM.GuestAgent.Unrestricted |
Poin SDN.Use sering terlupa: di versi Proxmox yang lebih baru, memasang NIC VM ke bridge vmbr0 pun butuh izin atas bridge tersebut.
Prinsipnya: buat user khusus otomasi, jangan pakai root@pam. Kalau token bocor, kerusakannya terbatas pada apa yang diizinkan ACL.
Request Body: Form, Bukan JSON
API Proxmox paling aman dipanggil dengan body application/x-www-form-urlencoded. Beberapa parameter yang kelihatan seperti struktur sebenarnya hanyalah string dengan format khusus:
net0 = virtio,bridge=vmbr0,firewall=1
scsi0 = local-lvm:32,discard=on
ipconfig0 = ip=10.10.0.21/24,gw=10.10.0.1
Semua opsi konfigurasi VM ada di satu namespace datar: cores, memory, net0, scsi0, ide2, ipconfig0, dan seterusnya. Untuk menghapus opsi, kirim delete=net1,serial0.
Task & UPID: Kenapa API-nya “Asinkron”
Operasi berat (clone, create, start, migrate, backup, resize) tidak ditunggu sampai selesai. API langsung membalas dengan string UPID (Unique Process ID):
UPID:pve1:000A1B2C:0123ABCD:66F5E3A1:qmclone:9000:automation@pve!panel:
Formatnya: UPID:node:pid:pstart:starttime:tipe:id:user: (angka dalam hex). Dari UPID kita tahu task berjalan di node mana, jenisnya apa, dan untuk VMID berapa.
Untuk mengetahui hasilnya, poll:
GET /nodes/{node}/tasks/{upid}/status
Respons berisi status (running atau stopped) dan, setelah selesai, exitstatus. Yang perlu diingat:
stoppedtidak berarti sukses. Task sukses hanya kalauexitstatus === "OK". Selain itu (misalnya teks error) berarti gagal. Ada juga statusWARNINGS: nuntuk task yang selesai dengan peringatan.- Log lengkap ada di
GET /nodes/{node}/tasks/{upid}/log, dan sangat berguna untuk ditampilkan di UI sebagai progress (misal persentase clone disk). - Poll dengan interval wajar (1β2 detik) dan beri batas waktu total. Toleransi beberapa kegagalan poll (node sibuk, jaringan putus sesaat), tapi jangan tanpa batas.
Di Proxmox Management Server, status task ini dimasukkan ke antrean BullMQ dan dipancarkan ke UI lewat WebSocket , sehingga pengguna melihat progress secara real time alih-alih menunggu spinner.
Konfigurasi: POST vs PUT
Endpoint /nodes/{node}/qemu/{vmid}/config punya dua varian:
PUT: sinkron, tidak mengembalikan apa-apa.POST: asinkron, bisa mengembalikan UPID. Dokumentasi menyarankanPOSTuntuk perubahan yang melibatkan hotplug atau alokasi storage.
Jadi client harus siap menerima UPID atau null dari POST config.
Timeout di Sisi Client
Sebagian endpoint memang lambat menjawab bahkan sebelum UPID dikembalikan (misalnya clone dan resize di storage lambat). Jangan pakai timeout global 10 detik untuk semuanya; beri pengecualian atau timeout lebih panjang untuk endpoint semacam ini.
Alur Otomasi VM: Template Cloud-Init + Clone
Membuat VM dari ISO lewat API itu merepotkan (butuh installer interaktif). Pola yang umum dipakai adalah template cloud-init: satu VM disiapkan sekali, dijadikan template, lalu setiap VM baru adalah clone yang dikonfigurasi lewat cloud-init.
Menyiapkan Template (Sekali Saja, di Node)
# Unduh cloud image resmi distro (contoh Ubuntu/Debian cloud image .img/.qcow2)
qm create 9000 --name tpl-ubuntu --memory 2048 --net0 virtio,bridge=vmbr0 --scsihw virtio-scsi-pci
qm set 9000 --scsi0 local-lvm:0,import-from=/root/ubuntu-cloudimg-amd64.img
qm set 9000 --ide2 local-lvm:cloudinit
qm set 9000 --boot order=scsi0
qm set 9000 --serial0 socket --vga serial0
qm set 9000 --agent enabled=1
qm template 9000
--ide2 ...:cloudinitmembuat drive CD kecil berisi data cloud-init yang di-generate Proxmox dari opsiciuser,sshkeys,ipconfig0, dll.--serial0 socket --vga serial0dibutuhkan karena banyak cloud image mengharapkan serial console.--agent enabled=1supaya nanti kita bisa membaca IP dari dalam VM lewat QEMU Guest Agent (paketqemu-guest-agenttetap perlu terpasang di dalam guest).
Alur via API
1. GET /cluster/nextid β VMID baru
2. POST /nodes/{node}/qemu/9000/clone β UPID (tunggu selesai)
3. POST /nodes/{node}/qemu/{vmid}/config β cores, memory, ipconfig0, sshkeys...
4. PUT /nodes/{node}/qemu/{vmid}/resize β perbesar disk (UPID)
5. POST /nodes/{node}/qemu/{vmid}/status/start β UPID (tunggu)
6. GET /nodes/{node}/qemu/{vmid}/agent/network-get-interfaces β IP aktual
Detail penting di tiap langkah:
nextidbukan reservasi. Endpoint ini hanya bilang “saat dicek, VMID ini kosong”. Dua worker yang memanggilnya bersamaan bisa mendapat angka yang sama, dan salah satu clone akan gagal. Solusinya: kunci di sisi aplikasi (mutex/lock DB per cluster) atau tangani error “already exists” dengan retry memakai ID berikutnya.- Clone: linked vs full. Parameter
fullmenentukan jenis clone. Clone dari VM biasa selalu full; clone dari template secara default dicoba sebagai linked clone (cepat, hemat ruang, tapi bergantung pada disk template). Untuk VM pelanggan yang harus berdiri sendiri, kirimfull=1danstorage=<target>. - Clone ke node lain (
target) hanya diizinkan kalau VM sumber ada di shared storage. sshkeysharus URL-encoded. Tipe parameternyaurlencoded, jadi nilai key perlu di-encodeURIComponentdulu, lalu di-encode lagi sebagai bagian dari form. Encoding ganda ini memang disengaja, dan kalau dilewatkan hasilnya error validasi yang membingungkan.cipasswordbisa dipakai, tapi dokumentasi sendiri menyarankan SSH key .- Disk resize hanya bisa memperbesar (
size=+20Gatau ukuran absolut), tidak bisa mengecilkan. - IP dari guest agent baru tersedia setelah OS boot dan agent jalan. Poll dengan jeda dan batas waktu; error di awal itu normal.
LXC: Container dengan API yang Mirip
Container LXC
punya endpoint paralel di /nodes/{node}/lxc. Bedanya, container dibuat dari template OS (tarball), bukan disk image:
pveam update
pveam available --section system
pveam download local debian-12-standard_12.7-1_amd64.tar.zst # nama persis lihat output 'available'
Parameter create yang utama:
| Parameter | Contoh |
|---|---|
vmid | 201 |
ostemplate | local:vztmpl/debian-12-standard_..._amd64.tar.zst |
hostname | ct-web-01 |
rootfs | local-lvm:8 |
net0 | name=eth0,bridge=vmbr0,ip=10.10.0.31/24,gw=10.10.0.1 |
unprivileged | 1 |
features | nesting=1 (misal untuk Docker di dalam LXC) |
ssh-public-keys | isi public key |
Perbedaan penting dibanding QEMU:
- Tidak ada cloud-init. IP ditulis langsung di
net0(ip=.../24,gw=...atauip=dhcp), dan Proxmox yang mengonfigurasi jaringan container. - IP bisa dibaca tanpa agent lewat
GET /nodes/{node}/lxc/{vmid}/interfaces. - Privileged container butuh
Sys.Modifydi/. Tetaplah di unprivileged kecuali benar-benar perlu. - Console container memakai
termproxy(xterm.js), sedangkan VM memakaivncproxy(noVNC). Keduanya berujung pada WebSocket yang perlu di-proxy oleh panel. Di Proxmox Management Server keduanya dilewatkan melalui proxy WebSocket di backend supaya browser tidak perlu akses langsung ke port8006.
Networking: Bridge dan Alokasi IP
Bridge di Node
Di instalasi standar, vmbr0 adalah Linux bridge yang menjembatani NIC fisik. VM yang dipasang ke vmbr0 terlihat seperti mesin lain di LAN. Daftar bridge bisa diambil dari:
GET /nodes/{node}/network?type=any_bridge
Perubahan jaringan node lewat API (POST /nodes/{node}/network) tidak langsung aktif; perlu PUT /nodes/{node}/network untuk me-reload. Salah konfigurasi di sini bisa memutus akses ke node itu sendiri, jadi otomasi jaringan node sebaiknya sangat hati-hati atau tidak dilakukan sama sekali.
Proxmox Tidak Mengurus IP Kamu
Ini kesalahpahaman yang paling sering: Proxmox tidak punya IPAM untuk bridge biasa. ipconfig0 hanya menulis IP yang kamu berikan ke cloud-init; Proxmox tidak mengecek apakah IP itu sudah dipakai VM lain. (Fitur SDN punya IPAM dan DHCP sendiri untuk zona tertentu, tapi itu arsitektur yang berbeda.)
Jadi aplikasi otomasi harus menjadi source of truth untuk IP, port, dan MAC. Di Proxmox Management Server aku memodelkan setiap alamat sebagai baris di database dengan state machine:
free ββreserveβββΆ reserved ββassignβββΆ assigned ββreleaseβββΆ quarantine ββ(timeout)βββΆ free
β
βββ(provisioning gagal)βββΆ free
reserveddiambil sebelum clone, supaya dua request paralel tidak mendapat IP yang sama.quarantinemenahan IP yang baru dilepas selama beberapa waktu, supaya cache ARP di router dan DNS sempat kedaluwarsa sebelum IP dipakai VM lain.
Query pengambilan alamat memakai row lock supaya aman saat konkuren:
SELECT id, address FROM ip_allocations
WHERE pool_id = ? AND state = 'free'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;
SKIP LOCKED membuat transaksi lain langsung melompati baris yang sedang dikunci alih-alih menunggu, sehingga sepuluh provisioning paralel mendapat sepuluh IP berbeda tanpa saling blok.
Lalu ada proses rekonsiliasi berkala: bandingkan isi database dengan kenyataan (/cluster/resources, config tiap VM) dan tandai selisihnya. Tanpa ini, database dan cluster pasti pelan-pelan tidak sinkron (VM dihapus manual lewat UI, clone gagal di tengah jalan, dan sebagainya).
Error Handling: Membaca Pesan Error Proxmox
Ada satu kebiasaan Proxmox yang sering membuat log tidak berguna: pesan error yang sebenarnya ada di HTTP reason phrase (status text), sementara body JSON-nya berisi {"data": null}. Kalau client hanya mencetak body, yang terlihat cuma 500 - {"data":null}.
Contoh reason phrase yang bisa muncul:
HTTP/1.1 500 unable to create VM 105 - VM 105 already exists
HTTP/1.1 403 Permission check failed (/vms/105, VM.Allocate)
Untuk error validasi parameter (400), body biasanya berisi objek errors yang memetakan nama parameter ke pesannya. Client yang baik:
- Membaca
statusTextsebagai pesan utama. - Menggabungkan
errorsper parameter bila ada. - Membedakan kategori: jaringan (node tidak terjangkau, TLS), autentikasi (
401), izin (403), validasi (400), dan kegagalan task (exitstatusbukanOK).
Kategori ini berguna untuk UI: “node sedang offline” dan “IP sudah dipakai” butuh tindakan yang sangat berbeda dari operator.
Sertifikat Self-Signed
Proxmox memakai sertifikat self-signed secara default. Jalan pintas rejectUnauthorized: false memang berhasil, tapi membuka peluang MITM. Pilihan yang lebih baik, dari yang paling disarankan:
- Pasang sertifikat valid di node (Proxmox mendukung ACME/Let’s Encrypt bawaan).
- Pin CA milik cluster (
/etc/pve/pve-root-ca.pem) di client. - Baru terakhir: nonaktifkan verifikasi, dan hanya di jaringan privat.
Contoh Client TypeScript
Client minimal berbasis fetch bawaan Node (18+) dengan token, body form, error yang informatif, dan penunggu task. undici dipakai untuk memasang CA khusus.
// proxmox.ts
import { readFileSync } from 'node:fs';
import { Agent } from 'undici';
export class ProxmoxError extends Error {
constructor(
message: string,
readonly status: number,
readonly fieldErrors?: Record<string, string>,
) {
super(message);
this.name = 'ProxmoxError';
}
}
export class TaskFailedError extends Error {
constructor(readonly task: TaskStatus) {
super(`Task ${task.type} gagal: ${task.exitstatus}`);
this.name = 'TaskFailedError';
}
}
type Params = Record<string, string | number | boolean | undefined>;
export interface TaskStatus {
status: 'running' | 'stopped';
exitstatus?: string;
upid: string;
node: string;
type: string;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export class ProxmoxClient {
private dispatcher: Agent;
constructor(
private baseUrl: string, // contoh: https://pve.example.lan:8006
private tokenId: string, // contoh: automation@pve!panel
private tokenSecret: string, // dari env, jangan di-hardcode
caPath?: string, // contoh: salinan pve-root-ca.pem
) {
this.dispatcher = new Agent({
connect: caPath ? { ca: readFileSync(caPath) } : {},
});
}
private toForm(params: Params): URLSearchParams {
const form = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined) continue;
form.append(key, typeof value === 'boolean' ? (value ? '1' : '0') : String(value));
}
return form;
}
async request<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
params: Params = {},
timeoutMs = 30_000,
): Promise<T> {
const url = new URL(`/api2/json${path}`, this.baseUrl);
const init: RequestInit & { dispatcher: Agent } = {
method,
headers: { Authorization: `PVEAPIToken=${this.tokenId}=${this.tokenSecret}` },
dispatcher: this.dispatcher,
signal: AbortSignal.timeout(timeoutMs),
};
if (method === 'GET' || method === 'DELETE') {
url.search = this.toForm(params).toString();
} else {
init.body = this.toForm(params); // otomatis application/x-www-form-urlencoded
}
const res = await fetch(url, init);
const body = (await res.json().catch(() => null)) as
| { data: T; errors?: Record<string, string> }
| null;
if (!res.ok) {
// Pesan asli Proxmox ada di status text, bukan di body.
const detail = body?.errors
? ' | ' + Object.entries(body.errors).map(([k, v]) => `${k}: ${v}`).join(', ')
: '';
throw new ProxmoxError(`${res.status} ${res.statusText}${detail}`, res.status, body?.errors);
}
return body!.data;
}
async waitForTask(node: string, upid: string, timeoutMs = 10 * 60_000): Promise<TaskStatus> {
const deadline = Date.now() + timeoutMs;
let consecutiveErrors = 0;
while (Date.now() < deadline) {
try {
const task = await this.request<TaskStatus>(
'GET',
`/nodes/${node}/tasks/${encodeURIComponent(upid)}/status`,
);
consecutiveErrors = 0;
if (task.status === 'stopped') {
if (task.exitstatus !== 'OK') {
throw new TaskFailedError(task);
}
return task;
}
} catch (err) {
if (err instanceof TaskFailedError) throw err; // task gagal: jangan di-retry
if (++consecutiveErrors >= 5) throw err; // node benar-benar tidak menjawab
}
await sleep(2000);
}
throw new Error(`Timeout menunggu task ${upid}`);
}
}
Lalu alur provisioning VM dari template:
// provision.ts
import { ProxmoxClient } from './proxmox';
interface ProvisionInput {
node: string;
templateId: number;
name: string;
cores: number;
memoryMb: number;
diskGrow: string; // contoh: '+20G'
storage: string; // contoh: 'local-lvm'
ip: string; // dari alokator IP aplikasi, contoh '10.10.0.21/24'
gateway: string;
sshPublicKey: string;
}
export async function provisionVm(pve: ProxmoxClient, input: ProvisionInput) {
const { node } = input;
// 1. VMID baru (bukan reservasi; lindungi dengan lock di sisi aplikasi)
const vmid = Number(await pve.request<string>('GET', '/cluster/nextid'));
// 2. Full clone dari template
const cloneUpid = await pve.request<string>(
'POST',
`/nodes/${node}/qemu/${input.templateId}/clone`,
{ newid: vmid, name: input.name, full: true, storage: input.storage },
120_000,
);
await pve.waitForTask(node, cloneUpid);
// 3. Resource + cloud-init
const configUpid = await pve.request<string | null>('POST', `/nodes/${node}/qemu/${vmid}/config`, {
cores: input.cores,
memory: input.memoryMb,
ciuser: 'deploy',
// Tipe 'urlencoded': encode dulu, lalu URLSearchParams meng-encode lagi.
sshkeys: encodeURIComponent(input.sshPublicKey.trim()),
ipconfig0: `ip=${input.ip},gw=${input.gateway}`,
nameserver: '1.1.1.1',
});
if (configUpid) await pve.waitForTask(node, configUpid);
// 4. Perbesar disk utama
const resizeUpid = await pve.request<string>(
'PUT',
`/nodes/${node}/qemu/${vmid}/resize`,
{ disk: 'scsi0', size: input.diskGrow },
120_000,
);
if (resizeUpid) await pve.waitForTask(node, resizeUpid);
// 5. Start
const startUpid = await pve.request<string>('POST', `/nodes/${node}/qemu/${vmid}/status/start`);
await pve.waitForTask(node, startUpid);
return { vmid, node };
}
Beberapa catatan desain:
- Idempotensi. Kalau proses mati di tengah (misal setelah clone), job yang diulang harus bisa mendeteksi VM yang sudah ada dan melanjutkan, bukan membuat VM kedua. Simpan progres langkah di database job.
- Rollback. Kalau langkah 3β5 gagal, putuskan: hapus VM (
DELETE /nodes/{node}/qemu/{vmid}denganpurge=1) dan kembalikan IP kefree, atau tandai untuk ditangani manual. Yang penting, keputusannya eksplisit. - Jalankan di worker, bukan di request HTTP. Clone bisa makan menit. Endpoint API aplikasi cukup membuat job dan mengembalikan ID-nya, lalu progress dikirim lewat WebSocket/SSE.
Pitfall yang Sering Ditemui
| Gejala | Penyebab umum |
|---|---|
401 terus-menerus dengan token | Format header salah (harus PVEAPIToken=user@realm!id=secret), atau token sudah kedaluwarsa/dihapus |
403 Permission check failed padahal user admin | privsep=1 dan token belum diberi ACL sendiri; atau lupa SDN.Use/Datastore.AllocateSpace |
Error body {"data":null} tanpa pesan | Pesan ada di status text respons |
| Clone “sukses” tapi VM tidak ada | Tidak menunggu task, atau tidak mengecek exitstatus |
| Dua VM rebutan VMID | nextid dipanggil paralel tanpa lock |
| VM boot tanpa SSH key | sshkeys tidak di-URL-encode, atau image bukan cloud image |
| IP tidak pernah muncul | agent belum di-enable di config, atau qemu-guest-agent belum terpasang di guest |
| Request timeout padahal task jalan | Timeout client terlalu pendek untuk clone/resize |
| Otomasi mati saat satu node mati | Semua request dikirim ke satu hostname node; pakai beberapa endpoint atau VIP |
| IP bentrok antar VM | Mengandalkan Proxmox untuk IPAM; alokasi harus diurus aplikasi |
Penutup
API Proxmox itu jujur tapi “mentah”: ia melakukan persis yang diminta, tanpa IPAM, tanpa reservasi VMID, dan tanpa menunggu task selesai. Karena itu hampir semua kerumitan otomasi justru ada di lapisan aplikasi: mengelola state, menunggu task dengan benar, menangani konkurensi, dan menerjemahkan error ke sesuatu yang bisa ditindaklanjuti. Setelah lapisan itu kokoh, membuat panel sendiri, entah untuk home lab atau bisnis hosting, jadi jauh lebih mudah.
Referensi:

Hey! I’m Fanny, the software engineer tending to this digital garden. You can read more about me, or subscribe by email.