Configuration reference
Location: /usr/local/bin/vm_autoscale/config.yaml. The path is hardcoded in autoscale.py's main().
The file is read once at startup. There is no reload signal — restart the service after editing.
This file holds credentials
ssh_password, smtp_password and app_token are stored in plain text. The file must be root-owned and mode 600. Verify with ls -l, and see the hardening guide.
Required sections
Startup fails unless all four of these are present:
scaling_thresholds
scaling_limits
proxmox_hosts
virtual_machinesMissing any of them:
CRITICAL Failed to start VM Autoscaler:
Missing required configuration sections: scaling_limitsNote the validator only checks that the keys exist — not that their contents make sense.
scaling_thresholds
Usage percentages that trigger a scaling decision. Global; not overridable per VM.
scaling_thresholds:
cpu:
high: 80
low: 20
ram:
high: 85
low: 25| Key | Type | Required | Notes |
|---|---|---|---|
cpu.high | number | when cpu_scaling is used | Scale up above this |
cpu.low | number | when cpu_scaling is used | Scale down below this |
ram.high | number | when ram_scaling is used | Scale up above this |
ram.low | number | when ram_scaling is used | Scale down below this |
Comparisons are strict, so a value exactly on a threshold does nothing. Keep the gap between low and high wide — it is your only protection against oscillation.
scaling_limits
Hard bounds on what any VM may be scaled to. Global.
scaling_limits:
min_cores: 1
max_cores: 8
min_ram_mb: 1024
max_ram_mb: 16384| Key | Type | Default | Notes |
|---|---|---|---|
min_cores | int | 1 | |
max_cores | int | 8 | |
min_ram_mb | int | 512 | Keep at 1024 or above — NUMA misbehaves below 1 GB |
max_ram_mb | int | 16384 |
These are the defaults for every VM. A VM may override any of them in its own entry, so a 2-core web server and a 16-core database can share one instance:
virtual_machines:
- vm_id: 102
scaling_limits:
max_cores: 16
max_ram_mb: 32768Resolution order: the VM's own block, then this section, then a flat top-level key (older layout), then the built-in default.
Behaviour change
Older versions read these values under names that do not exist in this file, so the defaults above were enforced regardless of what you configured. If you are upgrading, your limits are about to take effect for the first time. Flat top-level min_cores / max_cores / min_ram / max_ram keys are still accepted as a fallback for legacy configs.
Timing
check_interval: 300
scale_cooldown: 300| Key | Type | Default | Notes |
|---|---|---|---|
check_interval | int (s) | 300 | Time between polling cycles |
scale_cooldown | int (s) | 300 | Minimum time between two changes to the same resource on the same VM. Not present in the shipped config.yaml — add it if you want a value other than the default |
CPU and RAM have independent cooldown timers. Only an actual change starts one. Timers are in memory and are lost on restart. Setting scale_cooldown below check_interval has no effect.
proxmox_hosts
proxmox_hosts:
- name: pve1
host: 192.168.1.10
ssh_user: root
ssh_password: your_password_here
ssh_key: /root/.ssh/id_rsa
ssh_port: 22| Key | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Referenced by virtual_machines[].proxmox_host; must match exactly |
host | string | yes | Hostname or IP |
ssh_user | string | yes | Needs privileges to run qm set — in practice root |
ssh_password | string | one of the two | Takes precedence over ssh_key when both are set |
ssh_key | path | one of the two | Ed25519, ECDSA, RSA and DSS all supported. The key must be unencrypted — there is no passphrase option |
ssh_port | int | no | Defaults to 22. Earlier versions indexed this key directly, so omitting it raised KeyError and every VM on that host failed |
Two footguns in the shipped example
The example config fills in both ssh_password and ssh_key with placeholders. Password wins, so a half-edited config authenticates with the literal string your_password_here. Also, host2 in the example omits ssh_port — and the code reads that key unconditionally, so the host raises a KeyError. Set ssh_port on every host.
Connections are retried five times with exponential backoff (1, 2, 4, 8, 16 s). Authentication failures are not retried.
virtual_machines
virtual_machines:
- vm_id: 101
proxmox_host: pve1
scaling_enabled: true
cpu_scaling: true
ram_scaling: true| Key | Type | Required | Notes |
|---|---|---|---|
vm_id | int or string | yes | Proxmox VMID. Both types work; strings are used as-is in shell commands |
proxmox_host | string | yes | Must match a proxmox_hosts[].name. A mismatch silently skips the VM |
scaling_enabled | bool | no | Defaults to false — omitting it means the VM is never processed |
cpu_scaling | bool | no | Defaults to false |
ram_scaling | bool | no | Defaults to false |
thresholds | map | no | Per-VM overrides of scaling_thresholds. Flat (cpu_high, cpu_low, ram_high, ram_low) or nested (cpu: { high, low }). Any bound you omit keeps the global value |
scaling_limits | map | no | Per-VM overrides of the global scaling_limits. Same keys; any you omit keep the global value |
Validation
The whole file is validated at startup: types, ranges, enumerations, cross-field consistency and referential integrity. Every problem is reported at once, one per line, and the service refuses to start:
CRITICAL Refusing to start. 3 configuration problem(s) found:
- scaling_thresholds.cpu.high: expected a value between 0 and 100, got 150
- virtual_machines[0].proxmox_host: 'pve-typo' does not match any
proxmox_hosts name (pve1, pve2)
- proxmox_hosts[1]: needs either ssh_password or ssh_keyAn unknown key is a warning, not an error — rejecting outright would break configurations carrying a key from a newer version — but it is reported by path, so a typo stops being invisible:
WARNING Configuration: scaling_limits.max_ram: unknown key; it is not read by
anything and will have no effectThis is the fix for a whole class of defect
scaling_limits, per-VM thresholds, ssh_port and per-VM limits each shipped broken in the same way: a key that was written, documented and never read, with no error anywhere. That is what validation exists to make impossible.
dry_run
dry_run: false| Type | Default | Notes |
|---|---|---|
| bool | false | Evaluate everything, change nothing |
No command that touches a VM is issued — hotplug auto-configuration included. Reads still happen, so the evaluation is real; the cooldown still applies, so the cadence in the log is the cadence you would get. Notifications are prefixed [DRY RUN] and billing records nothing. See operations.
metrics
metrics:
enabled: false
bind: 127.0.0.1
port: 9808
path: /metrics| Key | Type | Default | Notes |
|---|---|---|---|
enabled | bool | false | |
bind | string | 127.0.0.1 | |
port | int | 9808 | |
path | string | /metrics |
No authentication
The endpoint has none, and the series name your nodes and VMIDs. Keep it on localhost unless something in front of it authenticates. A bind failure is logged and the service continues without metrics.
Full metric list: operations.
Scaling behaviour
scale_down_after_cycles: 2
notification_dedup_seconds: 900| Key | Type | Default | Notes |
|---|---|---|---|
scale_down_after_cycles | int | 2 | Consecutive readings below the low threshold required before shrinking |
notification_dedup_seconds | int | 900 | Suppress identical notifications for this long; 0 disables |
Growing and shrinking are not symmetric
Adding capacity fails safe. Reclaiming memory from a guest that is using it drives it into swap or to the OOM killer, and a vCPU unplug may simply be refused. A shrink therefore has to be sustained: any reading back inside the dead band resets the streak. Growth still acts on the first reading.
Deduplication collapses messages that differ only in their measured values, so CPU: 91.2% and CPU: 93.7% count as one event — but node identifiers are preserved, so pve1 unreachable and pve2 unreachable remain distinct. When suppression ends, the log states how many messages were withheld.
SSH host key verification
ssh_host_key_policy: accept-new
ssh_known_hosts: /etc/vm_autoscale/known_hosts| Key | Type | Default | Notes |
|---|---|---|---|
ssh_host_key_policy | string | accept-new | accept-new, strict or auto |
ssh_known_hosts | path | /etc/vm_autoscale/known_hosts | Created mode 600 in a 700 directory if absent |
| Policy | Behaviour |
|---|---|
accept-new | Trust a node the first time it is seen, record its key, and refuse to connect if that key ever changes. Equivalent to ssh -o StrictHostKeyChecking=accept-new |
strict | Only connect to nodes already listed in ssh_known_hosts. Pre-populate it with ssh-keyscan |
auto | Accept any key, every time, and record nothing. No protection whatsoever |
auto was the old behaviour
Before this was configurable the client used an auto-add policy with no known_hosts file loaded or saved, so every connection accepted whatever key it was offered. auto reproduces that and exists only as an escape hatch. A host key mismatch under the other two policies is fatal and is not retried — see the hardening guide.
host_limits
host_limits:
max_host_cpu_percent: 90
max_host_ram_percent: 90| Key | Type | Required | Notes |
|---|---|---|---|
max_host_cpu_percent | number | yes | No default; a missing key raises KeyError per VM |
max_host_ram_percent | number | yes | Same |
Both gates block scale-down as well as scale-up. See host safety limits.
auto_configure_hotplug
auto_configure_hotplug: true| Type | Default | Notes |
|---|---|---|
| bool | true | When on, the service issues qm set -hotplug cpu,memory,network,disk,usb -numa 1 on guests missing them |
Runs once per VM per service lifetime. NUMA changes need a guest reboot. See hotplug and NUMA.
logging
logging:
level: INFO
log_file: /var/log/vm_autoscale.log| Key | Type | Default | Notes |
|---|---|---|---|
level | string | INFO | |
log_file | path | /var/log/vm_autoscale.log |
logging_config.json overrides this
If /usr/local/bin/vm_autoscale/logging_config.json exists it is loaded via dictConfig and this whole section is ignored. Since the installer ships that file, the logging block in config.yaml is inert in a default installation. Edit the JSON instead — see operations.
gotify
gotify:
enabled: false
server_url: https://gotify.example.com
app_token: your_gotify_app_token_here
priority: 5| Key | Type | Required | Notes |
|---|---|---|---|
enabled | bool | yes | |
server_url | url | when enabled | Trailing slash stripped automatically |
app_token | string | when enabled | Application token |
priority | int | no | Default 5; per-event priorities override it |
Validated at startup. See notifications.
alerts (SMTP)
alerts:
email_enabled: false
email_recipient: admin@example.com
smtp_server: smtp.example.com
smtp_port: 587
smtp_user: your_smtp_user
smtp_password: your_smtp_password| Key | Type | Required | Notes |
|---|---|---|---|
email_enabled | bool | yes | |
smtp_server | string | when enabled | |
smtp_user | string | when enabled | Also the From address |
email_recipient | string or list | when enabled | |
smtp_port | int | no | Default 587 |
smtp_password | string | no | Empty string skips login() |
starttls() is always called. See notifications.
billing
billing:
enabled: false
billing_period_days: 30
cost_per_cpu_core_per_hour: 0.01
cost_per_gb_ram_per_hour: 0.005
csv_output_dir: /var/log/vm_autoscale/billing/
webhook_script: ""
webhook_url: ""| Key | Type | Default | Notes |
|---|---|---|---|
enabled | bool | false | Only spec recording is automatic |
billing_period_days | int | 30 | |
cost_per_cpu_core_per_hour | float | 0.01 | |
cost_per_gb_ram_per_hour | float | 0.005 | |
csv_output_dir | path | /var/log/vm_autoscale/billing/ | Created at startup; holds billing_data.json |
webhook_script | path | "" | Only fires from report generation |
webhook_url | url | "" | Only fires from report generation |
See billing tracking for what is and is not automatic.
Complete example
scaling_thresholds:
cpu: { high: 80, low: 20 }
ram: { high: 85, low: 25 }
scaling_limits:
min_cores: 1
max_cores: 8
min_ram_mb: 1024
max_ram_mb: 16384
check_interval: 300
scale_cooldown: 600
auto_configure_hotplug: false
proxmox_hosts:
- name: pve1
host: 10.0.0.11
ssh_user: root
ssh_key: /root/.ssh/vm_autoscale_rsa
ssh_port: 22
- name: pve2
host: 10.0.0.12
ssh_user: root
ssh_key: /root/.ssh/vm_autoscale_rsa
ssh_port: 22
virtual_machines:
- { vm_id: 101, proxmox_host: pve1, scaling_enabled: true, cpu_scaling: true, ram_scaling: true }
- { vm_id: 102, proxmox_host: pve1, scaling_enabled: true, cpu_scaling: true, ram_scaling: false }
- { vm_id: 201, proxmox_host: pve2, scaling_enabled: false, cpu_scaling: true, ram_scaling: true }
host_limits:
max_host_cpu_percent: 85
max_host_ram_percent: 90
logging:
level: INFO
log_file: /var/log/vm_autoscale.log
gotify:
enabled: true
server_url: https://gotify.internal.example
app_token: REPLACE_ME
priority: 5
alerts:
email_enabled: false
billing:
enabled: falseValidating before restart
python3 -c "import yaml; yaml.safe_load(open('/usr/local/bin/vm_autoscale/config.yaml'))" \
&& echo "YAML OK"This catches syntax errors only. Semantic problems — a proxmox_host that matches nothing, a missing ssh_port — surface at runtime.