Skip to content

Termix Proxmox Sync

Generates a Termix JSON import file from the Proxmox dynamic inventory, so every VM and LXC shows up in Termix without being added by hand.

Hosts are emitted with authType: "credential" and reference credential IDs that already exist in Termix. No secret ever passes through this script — the generated JSON contains hostnames, IPs, tags and numeric credential references, nothing more.

Why not Termix’s built-in Proxmox import

Section titled “Why not Termix’s built-in Proxmox import”

Termix ships a native Proxmox import: it SSHes to your PVE node, runs pvesh, and lets you pick guests to import. It’s good, and worth using for a one-off.

This script exists for the cases it doesn’t cover:

  • It reuses ansible/inventory/proxmox.proxmox.yml, so the IP for each guest comes from the same logic Ansible uses — the LXC eth0 / QEMU guest-agent interface resolution already encoded there. One definition, not two.
  • Your Proxmox tags become Termix tags, and drive per-host settings through rules (a docker-tagged guest gets the Docker integration turned on).
  • It re-runs. Regenerate after adding guests and re-import, rather than hand-picking from a list each time.

The built-in import guesses SSH-vs-RDP from the guest name and folders by node. This script always emits SSH and folders by node, which keeps the two consistent if you use both.

Terminal window
pip install -r requirements.txt
cp config.example.yml config.yml

Then edit config.yml and set the credential IDs. Find an ID in Termix under Credentials → open the credential → the ID: text in the viewer.

The inventory plugin needs Proxmox credentials in the environment, the same ones the Ansible playbooks use:

Terminal window
export $(cat ../../ansible/.env | grep -v '^#' | xargs)
./termix_proxmox_sync.py -c config.yml -o termix-hosts.json
VariableNeeded forNotes
PROXMOX_URLinventoryRequired — see the caveat below
PROXMOX_TOKEN_SECRETinventoryRequired
TERMIX_API_KEY--push onlyStarts with tmx_

PROXMOX_URL looks optional in proxmox.proxmox.yml because of a | default('http://proxmox.local:8006'), but that fallback never fires: lookup('env', ...) returns an empty string for an unset variable, and Jinja’s default() only substitutes for undefined values, not empty ones. Unset it and the URL is empty, not the default. (Fixable upstream with | default('...', true).) The script warns when either variable is missing.

PROXMOX_USER and PROXMOX_PASSWORD appear in ansible/.env.example but this inventory ignores both — it hardcodes user: root@pam and token_id: ansible, so the API token must be root@pam!ansible.

Then in Termix: Host Manager → import → upload termix-hosts.json.

Useful flags:

FlagEffect
-o -Write JSON to stdout instead of a file
--dry-runPrint the summary, write nothing
--from-json FILERead a saved ansible-inventory --list dump instead of querying Proxmox
--ansible-dir DIRPoint at a different checkout (default: ../../ansible)
--inventory PATHUse a different inventory file

--from-json is handy offline, and for re-rendering after a config change without hitting the Proxmox API again:

Terminal window
cd ../../ansible && ansible-inventory -i inventory/proxmox.proxmox.yml --list > /tmp/inv.json
cd - && ./termix_proxmox_sync.py -c config.yml --from-json /tmp/inv.json -o -

A run summary goes to stderr, so -o - stays pipeable:

warning: skipped (excluded): pve-dell
warning: skipped (stopped): semaphore
warning: skipped (no IP from Proxmox): talos-plane-1
14 host(s) across folder(s): pve, pve2

See config.example.yml for the annotated version. The shape:

default_credential_id: 1 # "pve" / root
default_username: root
include_stopped: false
skip_unresolved: true
exclude: [pve-dell]
exclude_tags: [community-script]
defaults: # applied to every host
enable_terminal: true
enable_file_manager: true
overrides: # matching rules, applied in order
- match: { tag: docker }
enable_docker: true
- match: { hostname: traefik }
pin: true
notes: Reverse proxy — recreate via `bws run` only.

Every matching rule applies, in order, and later rules win on the fields they set. So in the example above traefik — which is both tagged docker and named in the second rule — ends up with enableDocker: true and pin: true. Rules layer rather than replace, which is what lets a broad tag rule and a narrow hostname rule coexist, and it is how one host gets a different credential_id from the rest.

Only reference credential IDs that exist. A host pointing at a missing credential is still created and then silently fails to connect. List the real ones with:

Terminal window
curl -H "Authorization: Bearer $TERMIX_API_KEY" http://192.168.1.102:8080/credentials

Match keys: hostname, tag, node, type (lxc or qemu). hostname and tag also accept a list. Unknown keys, unknown rule fields, and typos in defaults all produce a warning rather than failing silently.

Settable fields: credential_id, username, override_credential_username, port, folder, default_path, notes, pin, tags_extra, enable_terminal, enable_tunnel, enable_file_manager, enable_docker, and the five show_*_in_sidebar toggles.

Ansible’s unsafe-string wrapping is undone on load. Facts the Proxmox inventory plugin marks unsafe — which is most of them — serialise as {"__ansible_unsafe": "running"} rather than "running". Left wrapped, every comparison fails silently: every guest looks stopped, types look unknown, and tags become dicts. Both input paths unwrap recursively before anything else runs.

Stopped guests are skipped by default (include_stopped: true to keep them). LXC status comes from proxmox_status, QEMU from proxmox_qmpstatus — the inventory reports them under different keys.

IP-address tags are stripped automatically. The ProxmoxVE community-script installers record each guest’s addresses as Proxmox tags, so a typical guest carries 172.17.0.1;172.18.0.1;192.168.1.175;community-script;docker. Anything that parses as an IP is dropped; use exclude_tags for name-based noise like community-script.

Guests that share a name collapse. Ansible keys hostvars by name, so two Proxmox guests called sonarr become one entry and the other is lost before this script sees it — it cannot detect or warn about this. Rename one in Proxmox if both matter.

Guests with no resolvable IP are skipped by default. proxmox.proxmox.yml falls back to the inventory hostname when neither the QEMU guest agent nor the LXC interface list reports an address, which would produce a Termix host that cannot connect. Set skip_unresolved: false to emit them anyway and let internal DNS resolve the name. Either way the affected hosts are named in a warning.

username is cosmetic unless you override. With authType: "credential", Termix uses the credential’s own username and ignores the host’s, unless override_credential_username: true.

Re-running is not a diff. The script always emits the full desired state. --overwrite is what makes a re-run idempotent: it matches on ip:port:username and updates in place. Without it a re-run creates duplicates. Note the match key is the address, not the name — renaming a guest updates the existing host, but re-addressing one creates a second host under the same name, which needs a manual tidy-up.

Nothing here ever deletes a host that has disappeared from Proxmox — prune those in the Termix UI.

--push POSTs the hosts to Termix instead of leaving you to upload a file by hand.

Create an API key in Termix under Admin Settings → API Keys (tokens start with tmx_), then:

Terminal window
export TERMIX_API_KEY=tmx_your_token_here
./termix_proxmox_sync.py -c config.yml --push --termix-url http://192.168.1.102:8080

Set termix_url in config.yml to skip the flag. The API key is only ever read from the environment — there is no config field for it, so no token lands in git.

FlagEffect
--pushPOST to Termix (still writes the JSON file as well)
--no-fileWith --push, skip writing the file
--overwriteUpdate hosts that already exist instead of skipping them
--termix-url URLBase URL, e.g. http://192.168.1.102:8080
--insecureSkip TLS verification, for internal step-CA certs
--timeout NPer-request seconds (default 60)
--endpoint-path PATHOverride the route (default /host/bulk-import)

--dry-run works with --push too — it reports how many hosts and requests it would send without sending anything.

Termix caps each request at 100 hosts, so the script splits larger fleets into chunks automatically and aggregates the totals:

pushing chunk 1/3 (100 hosts)...
pushing chunk 2/3 (100 hosts)...
pushing chunk 3/3 (50 hosts)...
pushed to http://192.168.1.102:8080/host/bulk-import: 250 created, 0 updated, 0 skipped, 0 failed

Use --overwrite on every run after the first. Termix only builds its existing-host lookup when overwrite is set — without it every host takes the create branch, so a second push duplicates the first rather than skipping it. Verified against a live instance: two identical pushes with overwrite:false produced two hosts; a third with overwrite:true reported 0 created, 1 updated. The script warns before pushing without it.

The skipped counter in the response is never incremented by this endpoint. It is always 0 and means nothing.

The script exits non-zero if Termix reports any failures, which makes it safe to run from cron or a CI job.

The route is POST /host/bulk-import. Three sources agree: the OpenAPI spec (static/openapi.json in Termix-SSH/Docs), the server code (the host router is mounted at /host, the route registered as /bulk-import), and the live API — an empty hosts array returns 400 Hosts array is required and must not be empty, which proves the route resolves.

The published reference page at docs.termix.site/api/bulk-import-ssh-hosts displays POST http://localhost:30001/ssh/bulk-import. That page is wrong. /ssh/bulk-import appears nowhere in openapi.json, and posting to it returns 405 Not Allowed from nginx — the /ssh/ prefix is served as frontend routing, so the request never reaches the API. It looks like a stale generated artifact; /ssh/* in the spec proper is tunnel and file-manager territory.

--endpoint-path exists in case a future version genuinely moves the route. A 404 or 405 from the push points at it in the error message.