WebVirtCloud is a web visualization and management layer for libvirt-based virtualization infrastructure.
libvirt is the single source of truth. WebVirtCloud reads, displays and triggers actions on libvirt state, but it is never the primary orchestrator.
- An administrator can connect to libvirt directly with
virt-managerorvirshand create, delete, rename or reconfigure virtual machines. WebVirtCloud detects these changes and updates its database accordingly. - Virtual machines keep running even when WebVirtCloud is stopped; the application only reflects the current state of libvirt.
- WebVirtCloud is not a full automation platform like Proxmox. Its goal is to expose libvirt's functionality through the browser in an accessible, role-based and secure way.
┌─────────────────────────────────────────────────────┐
│ Administrator / User │
└────────────┬───────────────────┬────────────────────┘
│ Web browser │ virt-manager / virsh
▼ ▼
┌────────────────────┐ ┌─────────────────────────────┐
│ WebVirtCloud │ │ libvirt API (direct) │
│ (web UI layer) │◄─┤ — separate, independent │
└────────┬───────────┘ └──────────────┬──────────────┘
│ libvirt API │
▼ ▼
┌──────────────────────────────────────────────────────┐
│ libvirtd (hypervisor daemon) │
│ QEMU / KVM │
└──────────────────────────────────────────────────────┘
WebVirtCloud and virt-manager/virsh can manage the same libvirt host concurrently and independently. WebVirtCloud picks up external changes the next time the instance list page is loaded.
- QEMU/KVM hypervisor management (multiple compute nodes), with each host's capacity and what its VMs were given
- Virtual machine lifecycle: create, delete, power management, clone (also of a running VM, with libvirt 11.10 or later), migrate (with or without shared storage), disk moves between pools
- Long operations (create, clone, delete, snapshots, migration, disk moves, ISO upload) run as jobs that the page follows and can cancel
- External snapshots (memory optional, guest file systems frozen through the agent)
- External change detection: VMs created, deleted or renamed via
virt-managerorvirshare reconciled into the database automatically - Real-time hypervisor and VM statistics (CPU, RAM, disk, network)
- Storage pool and volume management
- Network (with DHCP leases), interface, network filter and secret management
- PCI and USB devices of the host given to VMs
- UEFI Secure Boot and TPM 2.0 for new VMs, as Windows 11 needs
- Browser-based VNC console (noVNC) and serial console
- Role-based access control: users only see the VMs assigned to them, within their quota
- 2FA (OTP / TOTP) and failed-login lockout
- SSH public key and root password management (guest agent for a running VM, gstfsd for a shut-off one)
- Finding VMs by IP or MAC address, and a VM report as an Excel file
- An audit log of who changed what, with its result
- Cloud-init datasource interface (OpenStack metadata compatible) and NoCloud seed media
- REST API — OpenAPI 3.0 (Swagger & ReDoc), with API tokens
- LDAP / Active Directory integration (optional)
- Daily backups of the panel, with an optional off-site copy (restic over SFTP)
- English, Turkish and Simplified Chinese
- Installation: installer, Docker, manual, first login
- Adding a Compute Node
- Configuration: environment variables, HTTPS and reverse proxy, cloud-init datasource, LDAP, OTP, serial console
- Backups: off-site copy
- Updating: Docker, installer and manual, restore and rollback, moving to a new machine, behavior changes
- Usage: users and permissions, REST API, screenshots
- Development
The installer supports Ubuntu 22.04 / 24.04 / 26.04, Debian 12 / 13, RHEL / Rocky Linux / AlmaLinux / Oracle Linux 10, openEuler 24.03 LTS, openSUSE Leap 15.x / Tumbleweed and SLES 15; the panel runs on a virtual machine, a physical host or a KVM host itself and needs Python 3.10 or newer. CI tests Python 3.10–3.13 on Ubuntu (sqlite, and PostgreSQL on 3.12) and the Docker image; the distro installs are not tested in CI. Pick one of the three ways below, then do the first login.
curl -fsSL -O https://raw.githubusercontent.com/retspen/webvirtcloud/master/install.sh
chmod 744 install.sh
sudo ./install.shThe installer writes the server's names and IPs to ALLOWED_HOSTS. Running it again keeps settings.py, the database, the secret key and the main nginx config, but replaces /etc/nginx/conf.d/webvirtcloud.conf and the systemd units; back up your own lines in the nginx config (TLS) first. Put your own service settings in /etc/default/webvirtcloud or in drop-ins (systemctl edit webvirt-cloud), which a re-run keeps.
# 1. Clone repository:
git clone https://github.com/retspen/webvirtcloud
cd webvirtcloud
# 2. Set the names/IPs the panel is reached by (git pull never touches this file):
cat > docker-compose.override.yml <<'EOF'
services:
webvirtcloud:
environment:
- ALLOWED_HOSTS=wvc.example.com,192.0.2.10
EOF
# 3. Start services:
docker compose up -dThe panel and its console are at http://<server-ip>. The database and secret key live in the webvirtcloud-data volume, the SSH keys in webvirtcloud-ssh, the TLS client certificates in webvirtcloud-pki, the backups in webvirtcloud-backups. Other settings go in the same environment: list, see Environment variables.
Run the steps from a root shell (sudo -i). In step 3, replace the example ALLOWED_HOSTS with the names/IPs the panel (and the cloud-init datasource) is reached by.
The virtualenv is created with --system-site-packages: libvirt-python comes from the distro packages when its version satisfies conf/requirements.txt; otherwise pip builds it, which needs the -dev/-devel packages listed below. lxml is reused the same way; everything else comes from pip.
# 1. System packages
apt-get update && apt-get -y install git python3-venv python3-dev python3-lxml python3-libvirt libvirt-dev zlib1g-dev libxslt1-dev nginx libsasl2-modules gcc pkg-config python3-guestfs libssl-dev
# 2. Code
git clone https://github.com/retspen/webvirtcloud /srv/webvirtcloud
cd /srv/webvirtcloud
# 3. Settings, allowed hosts and secret key
cp webvirtcloud/settings.py.template webvirtcloud/settings.py
sed -i 's|^ALLOWED_HOSTS = .*|ALLOWED_HOSTS = ["wvc.example.com", "192.0.2.10", "localhost", "127.0.0.1", "[::1]"]|' webvirtcloud/settings.py
mkdir -p data && python3 conf/runit/secret_generator.py > data/secret_key && chmod 600 data/secret_key
# 4. Virtualenv and dependencies
python3 -m venv --system-site-packages venv
source venv/bin/activate
pip install -r conf/requirements.txt
# 5. Database and static files (the first migrate creates the "admin" user)
python3 manage.py migrate
python3 manage.py collectstatic --noinput
# 6. systemd units (gunicorn, novncd, socketiod, the job worker and the daily backup) and nginx
cp conf/systemd/webvirt-{cloud,novncd,socketiod,worker,backup}.service conf/systemd/webvirt-backup.timer /etc/systemd/system/
cp conf/nginx/webvirtcloud.conf /etc/nginx/conf.d/
rm -f /etc/nginx/sites-enabled/default
# 7. Ownership, private files, start (the groups let the services reach a local libvirt and KVM)
chown -R www-data:www-data /srv/webvirtcloud
usermod -a -G libvirt,kvm www-data 2>/dev/null || true # on a KVM host
chmod 700 data && chmod 600 webvirtcloud/settings.py db.sqlite3
systemctl daemon-reload && systemctl enable --now webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker webvirt-backup.timer
nginx -t && systemctl restart nginx# 1. System packages (libvirt-devel is in the CRB repository)
dnf -y install epel-release && crb enable
dnf -y install git python3-devel libvirt-devel python3-libvirt python3-lxml cyrus-sasl-md5 openssl-devel glibc gcc nginx python3-libguestfs iproute-tc policycoreutils-python-utils
# 2. Code
git clone https://github.com/retspen/webvirtcloud /srv/webvirtcloud
cd /srv/webvirtcloud
# 3. Settings, allowed hosts and secret key
cp webvirtcloud/settings.py.template webvirtcloud/settings.py
sed -i 's|^ALLOWED_HOSTS = .*|ALLOWED_HOSTS = ["wvc.example.com", "192.0.2.10", "localhost", "127.0.0.1", "[::1]"]|' webvirtcloud/settings.py
mkdir -p data && python3 conf/runit/secret_generator.py > data/secret_key && chmod 600 data/secret_key
# 4. Virtualenv and dependencies
python3 -m venv --system-site-packages venv
source venv/bin/activate
pip install -r conf/requirements.txt
# 5. Database and static files (the first migrate creates the "admin" user)
python3 manage.py migrate
python3 manage.py collectstatic --noinput
# 6. systemd units (gunicorn, novncd, socketiod, the job worker and the daily backup, as nginx) and nginx
for u in cloud novncd socketiod worker backup; do sed 's/^\(User\|Group\)=.*/\1=nginx/' conf/systemd/webvirt-$u.service > /etc/systemd/system/webvirt-$u.service; done
cp conf/systemd/webvirt-backup.timer /etc/systemd/system/
cp conf/nginx/webvirtcloud.conf /etc/nginx/conf.d/
# make sure the default server block in /etc/nginx/nginx.conf does not also listen on port 80
# 7. Ownership, private files, SELinux, firewall, start (the groups let the services reach a local libvirt and KVM)
chown -R nginx:nginx /srv/webvirtcloud
usermod -a -G libvirt,kvm nginx 2>/dev/null || true # on a KVM host
chmod 700 data && chmod 600 webvirtcloud/settings.py db.sqlite3
# nginx reads the static files, systemd runs the virtualenv's programs
semanage fcontext -a -t httpd_sys_content_t "/srv/webvirtcloud(/.*)?" 2>/dev/null || true
semanage fcontext -a -t bin_t "/srv/webvirtcloud/venv/bin(/.*)?" 2>/dev/null || true
restorecon -R /srv/webvirtcloud 2>/dev/null || true
setsebool -P httpd_can_network_connect on 2>/dev/null || true
firewall-cmd --add-service=http --permanent 2>/dev/null || true
firewall-cmd --reload 2>/dev/null || true
systemctl daemon-reload && systemctl enable --now webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker webvirt-backup.timer
systemctl enable nginx && nginx -t && systemctl restart nginx# 1. System packages (Python 3.11 stack)
zypper --non-interactive install -y git hostname python311 python311-base python311-devel python311-pip python311-libvirt-python python311-lxml libvirt-devel libopenssl-devel gcc pkg-config nginx
# 2. Code
git clone https://github.com/retspen/webvirtcloud /srv/webvirtcloud
cd /srv/webvirtcloud
# 3. Settings, allowed hosts and secret key
cp webvirtcloud/settings.py.template webvirtcloud/settings.py
sed -i 's|^ALLOWED_HOSTS = .*|ALLOWED_HOSTS = ["wvc.example.com", "192.0.2.10", "localhost", "127.0.0.1", "[::1]"]|' webvirtcloud/settings.py
mkdir -p data && python3.11 conf/runit/secret_generator.py > data/secret_key && chmod 600 data/secret_key
# 4. Virtualenv and dependencies
python3.11 -m venv --system-site-packages venv
source venv/bin/activate
pip install -r conf/requirements.txt
# 5. Database and static files (the first migrate creates the "admin" user)
python3 manage.py migrate
python3 manage.py collectstatic --noinput
# 6. systemd units (gunicorn, novncd, socketiod, the job worker and the daily backup, as nginx) and nginx
for u in cloud novncd socketiod worker backup; do sed 's/^\(User\|Group\)=.*/\1=nginx/' conf/systemd/webvirt-$u.service > /etc/systemd/system/webvirt-$u.service; done
cp conf/systemd/webvirt-backup.timer /etc/systemd/system/
cp conf/nginx/webvirtcloud.conf /etc/nginx/conf.d/
# make sure the default server block in /etc/nginx/nginx.conf does not also listen on port 80
# 7. Ownership, private files, start (the groups let the services reach a local libvirt and KVM)
chown -R nginx:nginx /srv/webvirtcloud
usermod -a -G libvirt,kvm nginx 2>/dev/null || true # on a KVM host
chmod 700 data && chmod 600 webvirtcloud/settings.py db.sqlite3
systemctl daemon-reload && systemctl enable --now webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker webvirt-backup.timer
systemctl enable nginx && nginx -t && systemctl restart nginxThe first migrate creates a superuser named admin with a random password, written to data/admin_password (mode 0600):
sudo cat /srv/webvirtcloud/data/admin_password # installer or manual installation
docker compose exec webvirtcloud cat data/admin_password # DockerSign in at http://<server-ip>; you must set a new password at the first login, and the file is then deleted. To choose the name or password yourself, set ADMIN_USERNAME and/or ADMIN_PASSWORD in the environment before that first migrate; a password you set is not written to the file and need not be changed. A lost password is reset with python3 manage.py changepassword admin (Docker: docker compose exec webvirtcloud /sbin/setuser www-data venv/bin/python3 manage.py changepassword admin).
A compute node is a KVM host the panel manages over libvirt.
-
Install KVM and libvirt. The bootstrap script handles Ubuntu, Debian, CentOS, Fedora, Rocky Linux and AlmaLinux 10, openEuler 24.03 LTS, openSUSE and SLES. On RHEL, which needs EPEL from its release RPM, install the
qemu-kvmandlibvirtpackages yourself and enablevirtqemud.socket(plusvirtproxyd-tcp.socketfor TCP):curl -fsSL https://raw.githubusercontent.com/retspen/webvirtcloud/master/dev/libvirt-bootstrap.sh | sudo sh # or, from a cloned repository: sudo sh ./dev/libvirt-bootstrap.sh
The new VM form offers UEFI, UEFI Secure Boot (with Microsoft's keys, for Windows 11) and a TPM 2.0 only when the host's libvirt finds the UEFI firmware and swtpm: install
ovmf swtpm-tools(Debian / Ubuntu) oredk2-ovmf swtpm swtpm-tools(RHEL family, openEuler) if they are missing. -
For SSH connections, give the user the panel runs as an SSH key and copy it to the compute node. Use only the block for your installation type: a key set up with the host commands is not seen by the panel in Docker.
ssh-copy-idasks for the compute node's root password once.accept-newaccepts a new host's key on first connect and refuses a changed one.Installer or manual installation, on the panel host:
U=www-data # the web service user: www-data on Debian/Ubuntu, nginx on RHEL/openSUSE H=$(getent passwd $U | cut -d: -f6) sudo install -d -m 700 -o $U -g $U $H/.ssh sudo -u $U ssh-keygen -t ed25519 -N "" -f $H/.ssh/id_ed25519 printf 'Host *\n StrictHostKeyChecking accept-new\n' | sudo -u $U tee $H/.ssh/config > /dev/null sudo chmod 600 $H/.ssh/config sudo -u $U ssh-copy-id root@<compute-node-ip> sudo -u $U ssh root@<compute-node-ip> true # no password or prompt: ready
Docker, on the Docker host from the directory with
docker-compose.yml; the commands run inside the container (always aswww-data: the keys are kept in thewebvirtcloud-sshvolume, and a key made as root is not used by the panel):docker compose exec -u www-data webvirtcloud ssh-keygen -t ed25519 -N "" -f /var/www/.ssh/id_ed25519 docker compose exec -u www-data webvirtcloud sh -c 'printf "Host *\n StrictHostKeyChecking accept-new\n" > ~/.ssh/config && chmod 600 ~/.ssh/config' docker compose exec -u www-data webvirtcloud ssh-copy-id root@<compute-node-ip> docker compose exec -u www-data webvirtcloud ssh root@<compute-node-ip> true # no password or prompt: ready
For TLS connections, put the CA certificate and the panel's client certificate and key in the panel user's
~/.pki/libvirt/ascacert.pem,clientcert.pemandclientkey.pem, readable only by that user:$H/.pki/libvirt/with the installer; in Docker/var/www/.pki/libvirt/(thewebvirtcloud-pkivolume), owned bywww-data. -
Install or update the
gstfsddaemon on the compute node (the bootstrap script does this too). It sets the root password and SSH key of a shut-off VM:curl -fsSL https://raw.githubusercontent.com/retspen/webvirtcloud/master/conf/daemon/gstfsd | sudo tee /usr/local/bin/gstfsd > /dev/null sudo chmod +x /usr/local/bin/gstfsd curl -fsSL https://raw.githubusercontent.com/retspen/webvirtcloud/master/conf/systemd/webvirt-gstfsd.service | sudo tee /etc/systemd/system/webvirt-gstfsd.service > /dev/null # its key, once: keep the file when you update gstfsd [ -s /etc/default/webvirt-gstfsd ] || sudo sh -c 'umask 077 && echo "GSTFSD_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")" > /etc/default/webvirt-gstfsd' sudo chown root:root /etc/default/webvirt-gstfsd && sudo chmod 600 /etc/default/webvirt-gstfsd sudo systemctl daemon-reload && sudo systemctl enable webvirt-gstfsd && sudo systemctl restart webvirt-gstfsd sudo cat /etc/default/webvirt-gstfsd # the key for step 4
gstfsd does only what the panel signs with this key. It listens on 127.0.0.1:16510 only, which serves a panel on the compute itself that adds it as
localhostor127.0.0.1. A panel on another machine or in a container (the Docker setup, also on the compute) reaches it at the compute's address; let it in:sudo sed -i '/^GSTFSD_BIND_HOST=/d; /^GSTFSD_ALLOW_REMOTE=/d' /etc/default/webvirt-gstfsd printf 'GSTFSD_BIND_HOST=0.0.0.0\nGSTFSD_ALLOW_REMOTE=1\n' | sudo tee -a /etc/default/webvirt-gstfsd > /dev/null sudo systemctl restart webvirt-gstfsd
Its traffic is not encrypted, and a password hash crosses it: allow TCP 16510 only from the panel's IP, on a network you trust.
A running VM gets them through its QEMU guest agent instead, without gstfsd. On RHEL-family guests, SELinux lets the agent add SSH keys only after
sudo setsebool -P virt_qemu_ga_manage_ssh oninside the guest.A compute that ran gstfsd under supervisor: remove its program first (
/etc/supervisor/conf.d/gstfsd.confon Debian / Ubuntu,/etc/supervisord.d/gstfsd.inion RHEL / openSUSE), runsudo supervisorctl update, and remove supervisor once nothing else uses it. -
Add the compute on the panel's Computes page, with the
GSTFSD_KEYvalue from step 3 as its gstfsd key.
Firewall: the compute's VNC ports (5900–65535) must accept connections only from the panel's IP, never from public networks.
- Shared storage: define it as a storage pool on every host. A VM migrates when its disk, backing and ISO files and block devices are volumes of the destination's pools.
- Address: a migrating VM goes to the destination's address on the Computes page; for a dedicated migration network, set the compute's Migration address on its edit page.
- Without shared storage: after choosing the destination, the Disks list shows each disk and the destination pools that can take a copy (directory, filesystem and NFS pools; a pool with a file of the same name refuses it); a disk on storage both hosts share can stay there. Plain raw or qcow2 disks without a backing file are copied, through the destination's migration port range (49152-49215 by default). Delete the source files of copied disks removes them after the migration, unless another VM or volume uses them.
- Shut-off VMs: a shut-off VM moves offline, with its NVRAM file (UEFI). When its disks are copied or it has a TPM, it is started paused for the migration instead (its CPUs never run) and shut off on the destination.
If libvirt warns Host SMBIOS information is not available, install dmidecode (apt-get, dnf or zypper install -y dmidecode) and restart libvirtd.
| Variable | Effect |
|---|---|
ALLOWED_HOSTS |
extra host names/IPs the panel answers to (comma-separated, * for any); other hosts get 400 |
CSRF_TRUSTED_ORIGINS |
extra trusted origins, e.g. https://wvc.example.com |
WEBVIRTCLOUD_HTTPS=1 |
HTTPS profile, see HTTPS and reverse proxy |
WEBVIRTCLOUD_SERIAL_CONSOLE=1 |
serial console, see Serial console |
WEBVIRTCLOUD_DB_HOST |
use PostgreSQL instead of sqlite (new installs); WEBVIRTCLOUD_DB_PORT/_NAME/_USER/_PASSWORD default to 5432/webvirtcloud/webvirtcloud/empty |
EMAIL_HOST, EMAIL_PORT, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD, EMAIL_USE_TLS |
SMTP for e-mail OTP |
WEBVIRTCLOUD_LOG_FILE |
log file (default data/webvirtcloud.log, rotated at 10 MB × 5) |
SECRET_KEY |
Django secret key (default: the data/secret_key file) |
ADMIN_USERNAME, ADMIN_PASSWORD |
the first superuser, read by the first migrate only, see First login |
Where to set them:
- Docker: the
environment:list ofdocker-compose.override.yml; every process in the container gets it. - Installer and manual installation: edit
webvirtcloud/settings.py, or put them in/etc/default/webvirtcloud(root, mode 600), oneNAME='value'per line (single quotes: systemd and the shell then read the same value); the panel's services read it. Thensystemctl restart webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker, and load the file before runningmanage.py:set -a; . /etc/default/webvirtcloud; set +a.
Some options exist only in settings.py (LDAP, OTP_ENABLED, LOGIN_TRUSTED_PROXIES). Docker builds that file from the template in each new container; to keep such options there, mount your own copy and update it from the template after each update:
docker compose exec webvirtcloud cat webvirtcloud/settings.py > settings.py # then edit itadd it to docker-compose.override.yml and run docker compose up -d:
services:
webvirtcloud:
volumes:
- ./settings.py:/srv/webvirtcloud/webvirtcloud/settings.pyThe container makes the file the panel user's (uid 33) with mode 600; edit it as root.
gunicorn runs at most 8 workers (fewer on hosts with under 4 CPUs) of 4 threads each; to change it, set GUNICORN_CMD_ARGS=--workers N in a drop-in of webvirt-cloud (systemctl edit webvirt-cloud: [Service] and Environment="GUNICORN_CMD_ARGS=--workers N") or in docker-compose.override.yml.
For a panel served over HTTPS:
- set
WEBVIRTCLOUD_HTTPS=1: session and CSRF cookies become HTTPS-only, HTTP is redirected to HTTPS (except/datasource/, which VMs fetch over HTTP), and HSTS is sent for one year; - add the HTTPS origin to
CSRF_TRUSTED_ORIGINS, e.g.CSRF_TRUSTED_ORIGINS=https://wvc.example.comandWEBVIRTCLOUD_HTTPS=1in/etc/default/webvirtcloud.
TLS can end at the bundled nginx or at a proxy in front of it; such a proxy must send X-Forwarded-Proto: https. Failed logins are counted per client address, which the bundled nginx passes in X-Real-IP. A proxy that replaces the bundled nginx must set X-Real-IP itself; one on another machine (gunicorn bound beyond 127.0.0.1) must also be in LOGIN_TRUSTED_PROXIES in settings.py. With a proxy in front of the bundled nginx, configure nginx's set_real_ip_from / real_ip_header for that proxy, or all clients share its address.
Every page is sent with X-Frame-Options: DENY and Content-Security-Policy: script-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none': the panel runs only scripts it serves itself, cannot be shown in a frame, and its forms post only to its own origin.
WebVirtCloud serves cloud-init metadata to guest instances: the hostname, and the SSH keys of the VM's users for the image's default user. The host in the URL must be in ALLOWED_HOSTS:
datasource:
OpenStack:
metadata_urls: [ "http://wvc.example.com/datasource" ]Without the network: tick Cloud-init seed (Advanced) when creating a VM. The VM gets a CD-ROM with its hostname and your SSH keys (a NoCloud seed, cidata-<uuid>.iso in the disk's directory pool), which the image's cloud-init reads at first boot; it is deleted with the VM's disks.
The options below are set in webvirtcloud/settings.py (Docker: see Environment variables for keeping your own). Variants for Active Directory and OpenLDAP are shown; this is a minimal config, see the django-auth-ldap documentation for more.
The Docker image includes the LDAP packages. On an installer or manual install, add them to the virtualenv first; pip builds python-ldap against these headers:
apt-get -y install libldap2-dev libsasl2-dev # Ubuntu / Debian
dnf -y install openldap-devel cyrus-sasl-devel # RHEL / Rocky / AlmaLinux
zypper --non-interactive install -y openldap2-devel cyrus-sasl-devel # openSUSE
/srv/webvirtcloud/venv/bin/pip3 install -r /srv/webvirtcloud/conf/requirements-ldap.txtEnable the LDAP backend:
sudo sed -i "s~#\"django_auth_ldap.backend.LDAPBackend\",~\"django_auth_ldap.backend.LDAPBackend\",~g" /srv/webvirtcloud/webvirtcloud/settings.pySet the LDAP server and bind DN:
# Active Directory
AUTH_LDAP_SERVER_URI = "ldap://example.com"
AUTH_LDAP_BIND_DN = "username@example.com"
AUTH_LDAP_BIND_PASSWORD = "password"
# OpenLDAP
AUTH_LDAP_SERVER_URI = "ldap://example.com"
AUTH_LDAP_BIND_DN = "cn=username,ou=users,dc=example,dc=com"
AUTH_LDAP_BIND_PASSWORD = "password"Set the user and group search base and filter:
# Active Directory
AUTH_LDAP_USER_SEARCH = LDAPSearch(
"CN=Users,DC=example,DC=com", ldap.SCOPE_SUBTREE, "(sAMAccountName=%(user)s)"
)
AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
"CN=Users,DC=example,DC=com", ldap.SCOPE_SUBTREE, "(objectClass=group)"
)
AUTH_LDAP_GROUP_TYPE = NestedActiveDirectoryGroupType()
# OpenLDAP
AUTH_LDAP_USER_SEARCH = LDAPSearch(
"ou=users,dc=example,dc=com", ldap.SCOPE_SUBTREE, "(uid=%(user)s)"
)
AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
"ou=groups,dc=example,dc=com", ldap.SCOPE_SUBTREE, "(objectClass=groupOfUniqueNames)"
)
AUTH_LDAP_GROUP_TYPE = GroupOfUniqueNamesType() # import needs to be changed at the top of settings.pySet the group required to access WebVirtCloud (False disables this filter):
AUTH_LDAP_REQUIRE_GROUP = "CN=WebVirtCloud Access,CN=Users,DC=example,DC=com"Populate user fields from LDAP:
AUTH_LDAP_USER_FLAGS_BY_GROUP = {
"is_staff": "CN=WebVirtCloud Staff,CN=Users,DC=example,DC=com",
"is_superuser": "CN=WebVirtCloud Admins,CN=Users,DC=example,DC=com",
}
AUTH_LDAP_USER_ATTR_MAP = {
"first_name": "givenName",
"last_name": "sn",
"email": "mail",
}An LDAP user is authenticated by LDAP and authorized through the WebVirtCloud permissions. To move a user from LDAP to WebVirtCloud, change its password in the UI and remove it from the LDAP group.
Set OTP_ENABLED = True in settings.py to ask for a one-time code at login. It is per user: a user without a confirmed device signs in with the password alone.
The QR code is mailed as a link to a page that shows it, good for one hour: the mail does not hold the code's secret. Email OTP QR code on a user's page (Users) sends it; Lost OTP? on the login page sends it to an enrolled user who gives their address.
A VM without graphics (no <graphics> device, a <console type='pty'>) opens a serial console (xterm.js) instead of VNC. It is off by default. To turn it on, set WEBVIRTCLOUD_SERIAL_CONSOLE=1 (Docker: in docker-compose.override.yml, then docker compose up -d) or SERIAL_CONSOLE_ENABLED = True in settings.py and systemctl restart webvirt-cloud webvirt-socketiod.
- The VM page's Settings → Console switches a shut-off VM between VNC and serial (Console type). A new VNC console takes the form's other fields; on a TCP or TLS compute it needs Listen on: All interfaces (SSH and socket computes reach a localhost console).
- The guest must use its serial port: a login prompt needs
systemctl enable --now serial-getty@ttyS0, boot messages need the kernel parameterconsole=ttyS0,115200. The console shows only what the guest writes after it opens; press Enter for a new prompt. - socketiod listens on 127.0.0.1:6081; the page reaches it through nginx's
/socket.io/location on the panel's own address. - It accepts the console page's signed token, as noVNC does: whoever gets that token within 120 s gets the console. An open console closes within 10 s once its user loses access to the VM.
- One console per VM, at most 20 at a time; a second one is refused with the reason, except a superuser's, which takes the console over.
- novncd and socketiod log to the journal (
journalctl -u webvirt-novncd -u webvirt-socketiod) or, in Docker, todocker logs: a console that was refused or could not connect says why there.
The panel backs itself up every day at 03:15 (cron in Docker, webvirt-backup.timer otherwise) into a new directory under /srv/webvirtcloud/backups, in Docker the webvirtcloud-backups volume, and keeps the newest 7. A backup holds:
db.sqlite3: a consistent copy taken while the panel runs, checked withPRAGMA integrity_check; with PostgreSQL it is left out, so back up that database yourselfsecret_key,node_idandsettings.pyssh/: the SSH keys of the panel's user, which the SSH computes acceptpki/: the TLS client certificates of the panel's user, which the TLS computes acceptcommit, outside Docker
The database holds compute passwords and keys: the directories are readable only by the panel's user. They are on the same host as the panel; the off-site copy keeps them on another server too. A failed backup is reported in docker logs or by systemctl status webvirt-backup and deletes no older backup.
Take one now:
docker compose exec webvirtcloud /sbin/setuser www-data venv/bin/python3 manage.py backup # Docker
systemctl start webvirt-backup && journalctl -u webvirt-backup -n 3 # otherwiseTo restore one, follow Restore and rollback with b set to its directory. In Docker, copy it out of the volume first (docker compose cp webvirtcloud:/srv/webvirtcloud/backups/webvirtcloud-backup-YYYYMMDD-HHMMSS ..) and leave out the git checkout and build lines: the backup has no commit. To move the panel to a new machine, see Moving to a new machine.
webvirt-offsite.timer copies the backups every day at 03:45 with restic over SFTP to another server, encrypted with a password that only you and the panel's host hold, and keeps 7 daily, 4 weekly and 6 monthly copies there. It runs on the panel's host as root, also with Docker. It fails, and journalctl -u webvirt-offsite says why, when the newest backup is older than 26 hours or the copy does not finish.
On the other server, create the user the copies go to (wvcbackup on backup.example.com below):
useradd -m wvcbackup && install -d -o wvcbackup -m 700 ~wvcbackup/.sshOn the panel's host, from a root shell in the panel's checkout (/srv/webvirtcloud, or the clone Docker runs from):
-
Install restic and curl (RHEL:
dnf -y install epel-release && dnf -y install restic, openSUSE:zypper -n install restic curl) and the job:apt-get -y install restic curl install -m 755 conf/webvirt-offsite.sh /usr/local/sbin/webvirt-offsite cp conf/systemd/webvirt-offsite.service conf/systemd/webvirt-offsite.timer /etc/systemd/system/
-
Give the host a key of its own for the other server:
install -d -m 700 /root/.ssh ssh-keygen -q -t ed25519 -N '' -f /root/.ssh/webvirtcloud-offsite cat >> /root/.ssh/config <<'EOF' Host webvirtcloud-offsite HostName backup.example.com User wvcbackup IdentityFile /root/.ssh/webvirtcloud-offsite EOF
Add
/root/.ssh/webvirtcloud-offsite.pubto~wvcbackup/.ssh/authorized_keyson the other server (owned bywvcbackup, mode 600), then connect once to record the server's key:ssh -o StrictHostKeyChecking=accept-new webvirtcloud-offsite true -
Write the settings with a new password and create the repository. With Docker, set
WVC_BACKUPSto the volume,$(docker volume inspect -f '{{.Mountpoint}}' webvirtcloud_webvirtcloud-backups).WVC_HEARTBEATis optional: a URL called after each good copy, such as a healthchecks.io check, which tells you when a day passes without one.install -m 600 /dev/null /etc/webvirtcloud-offsite.env cat > /etc/webvirtcloud-offsite.env <<EOF RESTIC_REPOSITORY=sftp:webvirtcloud-offsite:restic RESTIC_PASSWORD=$(head -c 30 /dev/urandom | base64) WVC_BACKUPS=/srv/webvirtcloud/backups WVC_HEARTBEAT= EOF (set -a && . /etc/webvirtcloud-offsite.env && restic init)
Keep
RESTIC_REPOSITORY,RESTIC_PASSWORDand the server's address in your password manager: without the password nobody can read the copies, you included. -
Start it and take the first copy:
systemctl daemon-reload && systemctl enable --now webvirt-offsite.timer && systemctl start webvirt-offsite && journalctl -u webvirt-offsite -n 5
To get a backup back when the panel's host is lost, give the new host a key as in step 2, then:
read -rs RESTIC_PASSWORD && export RESTIC_PASSWORD RESTIC_REPOSITORY=sftp:webvirtcloud-offsite:restic
restic snapshots --tag webvirtcloud
restic restore latest --tag webvirtcloud --target /root/offsite-restore
b=$(find /root/offsite-restore -type d -name 'webvirtcloud-backup-*' | sort | tail -n 1)and follow Moving to a new machine with that b (leave out its b= line).
Every update starts with a backup in its own directory (mode 700, named by date) that holds the database, the commit it was taken from and, for installer and manual installations, settings.py, the nginx config, /etc/default/webvirtcloud if there is one and migrations an old installer generated (local-migrations.tar). The database is copied with SQLite's backup API, which also finishes a write interrupted by a crash. This covers sqlite only; with PostgreSQL (WEBVIRTCLOUD_DB_HOST), back up that database yourself before updating.
Each new container builds settings.py from the template and runs the migrations at start; the database and the secret key stay in the webvirtcloud-data volume. Keep your settings in docker-compose.override.yml (at least ALLOWED_HOSTS, see Docker).
Run the commands in the directory you installed from. Compose names the deployment after that directory (webvirtcloud), so another clone in a directory of the same name acts on the same containers and volumes.
cd webvirtcloud &&
b=../webvirtcloud-backup-$(date +%Y%m%d-%H%M%S) &&
mkdir -m 700 "$b" && git rev-parse HEAD > "$b/commit" &&
git checkout master && git pull &&
docker compose build &&
docker compose stop -t 95 &&
docker compose run --rm --no-deps -T --entrypoint /srv/webvirtcloud/venv/bin/python3 webvirtcloud \
-c "import sqlite3,sys,tempfile; t=tempfile.mktemp(); sqlite3.connect('data/db.sqlite3').backup(sqlite3.connect(t)); sys.stdout.buffer.write(open(t,'rb').read())" > "$b/db.sqlite3.part" &&
mv "$b/db.sqlite3.part" "$b/db.sqlite3" &&
docker compose up -dVersions up to v3.0.0 ran the panel under supervisor. Once, before the update steps below, stop it there, keep its config and remove it:
supervisorctl stop webvirtcloud novncd socketiod webvirt-worker
mv /etc/supervisor/conf.d/webvirtcloud.conf /root/webvirtcloud-supervisor.conf # Debian / Ubuntu
mv /etc/supervisord.d/webvirtcloud.ini /root/webvirtcloud-supervisor.conf # RHEL / openSUSE
supervisorctl updateMove the environment= values of that config into /etc/default/webvirtcloud (root, mode 600), one NAME='value' per line; leave out the worker's WEBVIRTCLOUD_LOG_FILE. Step 4 installs and starts the systemd units. Remove supervisor once nothing else uses it; a compute moves gstfsd as in Adding a Compute Node, step 3.
git pull never overwrites webvirtcloud/settings.py or /etc/nginx/conf.d/webvirtcloud.conf; steps 2 and 4 bring them up to date. Run the steps from a root shell; they use $b, so in a new shell set it again to the backup directory.
-
Stop the panel, back up and pull:
systemctl stop webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker # once: root may use the repo, which belongs to the service user git config --global --add safe.directory /srv/webvirtcloud b=/root/webvirtcloud-backup-$(date +%Y%m%d-%H%M%S) cd /srv/webvirtcloud && ! systemctl is-active --quiet webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker && mkdir -m 700 "$b" && venv/bin/python3 -c "import sqlite3,sys; sqlite3.connect('db.sqlite3').backup(sqlite3.connect(sys.argv[1]))" "$b/db.sqlite3.part" && mv "$b/db.sqlite3.part" "$b/db.sqlite3" && cp -p webvirtcloud/settings.py /etc/nginx/conf.d/webvirtcloud.conf "$b"/ && { [ ! -f /etc/default/webvirtcloud ] || cp -p /etc/default/webvirtcloud "$b"/; } && git rev-parse HEAD > "$b/commit" && git ls-files -z --others --exclude-standard -- '*/migrations/*.py' | tar --null -T - --remove-files -cf "$b/local-migrations.tar" && git checkout -- $(git ls-files -m -- static dev/scss install.sh webvirtcloud.sh) && git checkout master && git pull
Older installers collected static files into the tracked
static/and deleted the trackedinstall.shandwebvirtcloud.sh; the second-to-last line puts those files back, so thatgit pullcan update them. -
Rebuild
settings.pyfrom the template (the file keeps its owner and mode), then put back your own values; thedifflists them:cat webvirtcloud/settings.py.template > webvirtcloud/settings.py diff "$b/settings.py" webvirtcloud/settings.py
Typical ones are
TIME_ZONE,WS_*,ALLOWED_HOSTS,CSRF_TRUSTED_ORIGINS, LDAP and, with PostgreSQL, yourDATABASES: the template's is sqlite, so without itmigratewould make a new, empty sqlite database and the panel would start on that. Two need care:- a literal
SECRET_KEY = "..."(older installers wrote it there): put the value intodata/secret_keyinstead (mkdir -p datafirst if there is nodata/). With no key,migratein step 3 stops with "The SECRET_KEY setting must not be empty" and every page fails; with another key, every user is signed out; SHOW_PROFILE_EDIT_PASSWORD: keep it for this onemigrateif you have it (migrationaccounts.0008reads it), then delete it.
- a literal
-
Install, migrate, and fix ownership and modes (
nginx:nginxinstead ofwww-data:www-dataon RHEL / openSUSE). With LDAP, also runvenv/bin/pip3 install -U -r conf/requirements-ldap.txt(headers: LDAP):venv/bin/pip3 install -U -r conf/requirements.txt && { ! tar -tf "$b/local-migrations.tar" | grep -qx accounts/migrations/0006_alter_userattributes_id_alter_userinstance_id_and_more.py || venv/bin/python3 manage.py migrate accounts 0005 --fake; } && venv/bin/python3 manage.py migrate && venv/bin/python3 manage.py collectstatic --noinput && chown -R www-data:www-data /srv/webvirtcloud && chmod -R go-rwx data webvirtcloud/settings.py db.sqlite3
Installers from before September 2025 generated migrations of their own; step 1 moved them into
local-migrations.tar. One of them has the name of a tracked migration, whichmigratewould skip; the second line marks it unapplied so that the tracked one runs. -
Update the nginx config the same way as
settings.py:cp conf/nginx/webvirtcloud.conf /etc/nginx/conf.d/webvirtcloud.conf diff "$b/webvirtcloud.conf" /etc/nginx/conf.d/webvirtcloud.confPut back your own lines (
server_name, TLS, the noVNC port). Install the systemd units; your own service settings stay in/etc/default/webvirtcloudand in drop-ins. Debian / Ubuntu:cp conf/systemd/webvirt-{cloud,novncd,socketiod,worker,backup}.service conf/systemd/webvirt-backup.timer /etc/systemd/system/RHEL / openSUSE:
for u in cloud novncd socketiod worker backup; do sed 's/^\(User\|Group\)=.*/\1=nginx/' conf/systemd/webvirt-$u.service > /etc/systemd/system/webvirt-$u.service; done cp conf/systemd/webvirt-backup.timer /etc/systemd/system/
Then reload nginx and start the panel:
nginx -t && systemctl reload nginx && systemctl daemon-reload && systemctl enable --now webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker webvirt-backup.timer
Older versions collected static files into static/; git clean -n static/ lists the leftovers, which can be deleted.
Restoring puts back the database as it was at the backup; changes made since are lost. Migrations are not reversed. This covers sqlite; restore a PostgreSQL database from your own backup before rolling back the code. Set b to the backup directory to restore. To restore the database without rolling back the code, leave out the build line and every line that takes another file from $b: the commit, local-migrations.tar, settings.py, the nginx config and /etc/default/webvirtcloud.
Docker, in the directory you installed from:
cd webvirtcloud &&
b=../webvirtcloud-backup-YYYYMMDD-HHMMSS &&
docker compose stop -t 95 &&
docker compose run --rm --no-deps -T --entrypoint /srv/webvirtcloud/venv/bin/python3 webvirtcloud \
-c "import os,sqlite3,sys,tempfile; t=tempfile.mktemp(); open(t,'wb').write(sys.stdin.buffer.read()); [os.remove(p) for p in ('data/db.sqlite3-journal','data/db.sqlite3-wal','data/db.sqlite3-shm') if os.path.exists(p)]; sqlite3.connect(t).backup(sqlite3.connect('data/db.sqlite3'))" < "$b/db.sqlite3" &&
git checkout "$(cat "$b/commit")" &&
docker compose build &&
docker compose up -d # the container fixes ownership and modes at startInstaller and manual installation, from a root shell (nginx:nginx on RHEL / openSUSE):
b=/root/webvirtcloud-backup-YYYYMMDD-HHMMSS
systemctl stop webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker
cd /srv/webvirtcloud &&
! systemctl is-active --quiet webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker &&
git checkout -- $(git ls-files -m -- static/css dev/scss) &&
git checkout "$(cat "$b/commit")" &&
{ [ ! -f "$b/local-migrations.tar" ] || tar -xf "$b/local-migrations.tar"; } &&
venv/bin/python3 -c "import os,sqlite3,sys; [os.remove(p) for p in ('db.sqlite3-journal','db.sqlite3-wal','db.sqlite3-shm') if os.path.exists(p)]; sqlite3.connect(sys.argv[1]).backup(sqlite3.connect('db.sqlite3'))" "$b/db.sqlite3" &&
cp "$b/settings.py" webvirtcloud/settings.py &&
{ [ ! -f "$b/webvirtcloud.conf" ] || cp "$b/webvirtcloud.conf" /etc/nginx/conf.d/webvirtcloud.conf; } &&
venv/bin/pip3 install -r conf/requirements.txt &&
venv/bin/python3 manage.py migrate &&
venv/bin/python3 manage.py collectstatic --noinput &&
chown -R www-data:www-data /srv/webvirtcloud &&
chmod 600 db.sqlite3 webvirtcloud/settings.pyPut back /etc/default/webvirtcloud if the backup has it (cp -p "$b/webvirtcloud" /etc/default/), then reload nginx and start the panel:
nginx -t && systemctl reload nginx && systemctl start webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-workerA version from before v3.0.0 has no worker: systemctl disable --now webvirt-worker. A rollback keeps the systemd units. A rollback leaves the clone on a detached HEAD at the restored commit; the update steps record that commit before they switch back to master.
A backup moves the panel to a new machine, or back onto lost volumes: the database, the secret key (signed-in users stay signed in), the worker's id and the SSH keys and TLS certificates the computes accept. Install the panel there first, the same version or a newer one, then copy the backup directory over and set b to it. The backup does not hold docker-compose.override.yml, the nginx config or /etc/default/webvirtcloud: bring your own lines over yourself. Users sign in with their passwords from the backup; the new installation's data/admin_password no longer applies. A SECRET_KEY set in the compose file has no secret_key in the backup: set the same value on the new machine. With PostgreSQL, stop the old panel before the new one starts: both would run the jobs of the one database.
Docker, in the directory you installed from:
cd webvirtcloud &&
b=../webvirtcloud-backup-YYYYMMDD-HHMMSS &&
docker compose stop -t 95 &&
docker compose run --rm --no-deps -T --entrypoint /srv/webvirtcloud/venv/bin/python3 webvirtcloud \
-c "import os,sqlite3,sys,tempfile; t=tempfile.mktemp(); open(t,'wb').write(sys.stdin.buffer.read()); [os.remove(p) for p in ('data/db.sqlite3-journal','data/db.sqlite3-wal','data/db.sqlite3-shm') if os.path.exists(p)]; sqlite3.connect(t).backup(sqlite3.connect('data/db.sqlite3'))" < "$b/db.sqlite3" &&
{ [ ! -f "$b/secret_key" ] || docker compose cp "$b/secret_key" webvirtcloud:/srv/webvirtcloud/data/; } &&
docker compose cp "$b/node_id" webvirtcloud:/srv/webvirtcloud/data/ &&
{ [ ! -d "$b/ssh" ] || docker compose cp "$b/ssh/." webvirtcloud:/var/www/.ssh/; } &&
{ [ ! -d "$b/pki" ] || docker compose cp "$b/pki/." webvirtcloud:/var/www/.pki/; } &&
docker compose up -d # the container migrates and fixes ownership and modes at startInstaller and manual installation, from a root shell (nginx instead of www-data on RHEL / openSUSE). Compare $b/settings.py with webvirtcloud/settings.py and carry your own values over:
b=/root/webvirtcloud-backup-YYYYMMDD-HHMMSS
systemctl stop webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-worker
cd /srv/webvirtcloud &&
venv/bin/python3 -c "import os,sqlite3,sys; [os.remove(p) for p in ('db.sqlite3-journal','db.sqlite3-wal','db.sqlite3-shm') if os.path.exists(p)]; sqlite3.connect(sys.argv[1]).backup(sqlite3.connect('db.sqlite3'))" "$b/db.sqlite3" &&
cp "$b/node_id" data/ && { [ ! -f "$b/secret_key" ] || cp "$b/secret_key" data/; } &&
mkdir -p ~www-data/.ssh && { [ ! -d "$b/ssh" ] || cp "$b"/ssh/* ~www-data/.ssh/; } &&
mkdir -p ~www-data/.pki && { [ ! -d "$b/pki" ] || cp -r "$b"/pki/. ~www-data/.pki/; } &&
venv/bin/python3 manage.py migrate &&
chown -R www-data:www-data data ~www-data/.ssh ~www-data/.pki &&
chmod 700 data ~www-data/.ssh ~www-data/.pki && chmod 600 db.sqlite3 &&
systemctl start webvirt-cloud webvirt-novncd webvirt-socketiod webvirt-workerWhat an update from an older version changes for operators and users:
- Requests for a host not in
ALLOWED_HOSTSget 400. The installer writes the server's names and IPs; add more with theALLOWED_HOSTSenv var. The cloud-init datasource host must be allowed too. - Logging out needs a POST; a GET to
/accounts/logout/gets 405. - The API accepts only a session from the login form or an API token (no HTTP Basic), and the API docs need a login. Sessions opened through the old password-only doors skip OTP; end them once after the update (everyone signs in again):
python3 manage.py shell -c "from django.contrib.sessions.models import Session; Session.objects.all().delete()". - Failed logins lock a user (django-axes). The limit and lock time are on the Settings page; unlock with
python3 manage.py axes_reset_username <username>. - Changing one's own password is the "Can change password" permission (per user or group; new users have it);
SHOW_PROFILE_EDIT_PASSWORDis gone. - The admin's first-login password change survives losing
data/admin_password(migrationaccounts.0007); runmigrateas a user who can read that file. - A new VM and each clone get a random VNC password of their own (clones no longer share their source's). The console page sends it for a user with Open a console without the VM's console password, which a new user has unless unticked; others are asked for it. Existing VMs keep theirs.
- An
adminaccount that still has the passwordadmin(the first user of versions up to v2.0.0) must change it at its next login, and its API tokens are refused until then (migrationaccounts.0013). - A
settings.pykept from an older version must match the template (apps, middleware, the SQLiteOPTIONS, the REST framework, knox, axes and password settings): rebuild it from the template and carry your own values over, as step 2 of the update does. - The VNC console connects to
/novncd/on the panel's own address through nginx, and the serial console to/socket.io/: they work on HTTPS and behind another proxy without settings.WS_PUBLIC_PORT,WS_PUBLIC_HOST,WS_PUBLIC_PATH,WS_CERTandSOCKETIO_PUBLIC_HOST/_PORT/_PATHare gone; remove them fromdocker-compose.override.yml,/etc/default/webvirtcloudor a keptsettings.py, setWS_HOST = "127.0.0.1"in a keptsettings.py, and close port 6080 in the firewall. - Installer and manual installations run the panel as systemd units (
webvirt-cloud,webvirt-novncd,webvirt-socketiod,webvirt-worker,webvirt-backup.timer) and computes runwebvirt-gstfsd, instead of supervisor; see Moving from supervisor to systemd. Service settings go into/etc/default/webvirtcloud, service output into the journal (journalctl -u webvirt-cloud); the app log stays indata/. novncd and socketiod run sandboxed (read-only system directories, no/home). - gstfsd does only what the panel signs with the host's key: it does not start without
GSTFSD_KEYin/etc/default/webvirt-gstfsd. On every compute, update gstfsd and its unit and make the key as in Adding a Compute Node, step 3 (a remote panel'sGSTFSD_BIND_HOSTandGSTFSD_ALLOW_REMOTEmove into that file fromsystemctl edit webvirt-gstfsd), then enter the key on each compute's edit page. - LDAP login's packages are in
conf/requirements-ldap.txtand no longer installed by default, nor their build headers by the installer; the Docker image keeps them. An existing virtualenv keeps them, but only installing that file updates them (see LDAP). - The themes ship compiled; picking one on the Settings page only saves its name, and the "Theme SASS Path" setting is gone. Browsers revalidate static files on each load: files cached before the update can stay until a hard reload (Ctrl+F5) of each page; an external caching proxy may need a purge.
- The panel backs itself up every day (Backups); in Docker into a new volume,
webvirtcloud-backups. - New passwords must pass Django's password validators: at least 8 characters, not a common password, not only digits, not like the user's name or e-mail. Existing passwords keep working.
- In Docker, the TLS client certificates of TLS computes are kept in a new volume,
webvirtcloud-pki(/var/www/.pki), and backed up; put them there once (Adding a Compute Node, step 2). - The SQLite database must be on a local file system (WAL). Outside compose, stop the Docker container with
docker stop -t 95so the worker can finish running jobs. - Long operations run as jobs in a worker process (
manage.py run_worker): creating, cloning, migrating and deleting VMs, moving disks, cloning volumes, taking and reverting snapshots and deleting external snapshots. The page returns at once and the job panel and the Jobs page follow them; a migration can be cancelled. The API's VM create, migrate and delete answer 202 with the job and need a newsubmission_keyper request. The create form no longer takes a console password; set it on the VM page. - An ISO upload is tracked as a job that can be cancelled. The browser sends the file, so stay on the page until it ends; an upload left idle for an hour is ended and its partial file deleted.
- A clone of a running VM (libvirt 11.10 or later, local file disks, no TPM) locks the VM until the copy ends and can be cancelled; cancelling a clone of a shut-off VM has no effect. A job that ends uncertain keeps its VM locked until a superuser resolves it on its job page.
- The job worker runs at most half of its concurrency (Settings, at least one) of one host's jobs at a time, so a host that does not answer cannot take every slot.
- A new VM is made in three steps (host, machine, details) on one form; flavors are presets that fill vCPU, memory and disk size. "VM Clone Auto Migrate" is removed; a clone stays on its source host.
- A user's quota counts, for each VM, the larger of its running and its next-start vCPUs, memory and disks, and a queued clone as one VM. A user can be over the quota after the update; nothing is stopped, but new VMs, clones and raises are refused until it fits.
- A user who is not a superuser names a clone with one of the free names the clone form offers; other names are refused.
- A template VM is changed or deleted only by superusers and staff owners with the matching permission; other owners can still view, open the console and clone it.
- On a running VM the edit forms show the settings as they will be at the next start, so saving an edit no longer reverts another pending change.
- External snapshots need libvirt 9.9 or newer on the computes. libvirt 9.10 (openEuler 24.03 LTS) refuses to revert a snapshot taken without memory of a running or paused VM ("Invalid target domain state 'disk-snapshot'"); 11.10 reverts it. A VM's snapshots form one chain: after a revert, delete the newer snapshots before taking another. An internal snapshot is reverted only on a shut-off VM. Snapshot names no longer get an
s1.prefix. A snapshot an older version took of a UEFI VM can only be deleted, not reverted. On NFS, the qemu user needs write access to the image directory to remove merged snapshot files. - A storage volume a VM still needs (a disk, a backing file or a snapshot's file) is not deleted; deleting a VM with its files keeps a file another compute's VM or volume uses at the same path, and is refused before the VM is stopped when one of the other computes cannot be reached.
- A VM that vanishes from its compute, or whose compute is deleted, keeps its owners for
INSTANCE_OWNERSHIP_RETENTION_DAYS(30) in case the same UUID comes back; superusers manage these records on the "Removed VMs" page. - The Logs page records who changed what (computes, users, groups, VM owners, settings, SSH keys, host networks, pools, volumes, interfaces, secrets and network filters), every login, failed login and lockout, and every page or API call refused to a signed-in user or an API token, with the address and the result.
- On a host whose libvirt cannot change network interfaces (the udev backend: RHEL 9 and later, Debian 12), the interface pages offer no create, start, stop or delete.
- The panel is offered in English, Turkish and Simplified Chinese.
- With SELinux (RHEL family, openEuler), systemd could not run the virtualenv's programs and the panel did not start. The installer now writes file context rules (
httpd_sys_content_tfor the panel,bin_tforvenv/bin) instead ofchcon, so a relabel keeps them. A manual installation runssemanage fcontext -a -t bin_t "/srv/webvirtcloud/venv/bin(/.*)?"andrestorecon -R /srv/webvirtcloudonce; an installer one runs the installer again.
See doc/permissions.md.
The REST API (Django REST Framework, documented with drf-spectacular) takes the login session or an API token. After logging in:
-
Swagger UI:
http://<server>/swagger/ -
ReDoc UI:
http://<server>/redoc/ -
OpenAPI 3.0 schema:
http://<server>/api/schema/(JSON or YAML) -
API tokens, for scripts and tools: Profile → API Tokens. A token acts as its user and is shown once; an administrator who sets the user's password deletes them. Send it as a header:
curl -H "Authorization: Token wvc_..." https://<server>/api/v1/instances/
| VM list | VM page |
|---|---|
![]() |
![]() |
| Computes | Host overview |
|---|---|
![]() |
![]() |
| New VM | Logs |
|---|---|
![]() |
![]() |
Run every step as your normal user, not with sudo: if manage.py ever runs as root, db.sqlite3 and data/ end up root-owned and later runs fail with "readonly database" or "Permission denied".
-
System packages. The virtualenv reuses the distro's
libvirt,lxmlandldapbindings when their versions satisfyconf/requirements.txt; otherwise pip installs or builds newer ones:# Rocky Linux / RHEL / Fedora sudo dnf -y install git python3-devel libvirt-devel python3-libvirt python3-lxml python3-ldap gcc # Ubuntu / Debian sudo apt-get update && sudo apt-get -y install git python3-venv python3-dev python3-lxml python3-libvirt python3-ldap libvirt-dev zlib1g-dev libldap2-dev libsasl2-dev gcc pkg-config # openSUSE Leap 15.x / Tumbleweed / SLES 15 (use python3.11 instead of python3 below) sudo zypper --non-interactive install -y git hostname python311 python311-devel python311-pip python311-libvirt-python python311-lxml python311-ldap libvirt-devel openldap2-devel cyrus-sasl-devel libopenssl-devel gcc pkg-config
-
Virtualenv and dependencies (
--system-site-packagesis required to see the distro bindings;dev/requirements.txtincludesconf/requirements.txt):python3 -m venv --system-site-packages .venv source .venv/bin/activate pip install -r dev/requirements.txt -
Settings and secret key:
cp webvirtcloud/settings.py.template webvirtcloud/settings.py mkdir -p data && python conf/runit/secret_generator.py > data/secret_key && chmod 600 data/secret_key
-
Migrate and run the dev server (
settings-devenablesDEBUGand the Django Debug Toolbar); the admin password is indata/admin_password, or setADMIN_PASSWORDbeforehand:python manage.py migrate python manage.py runserver 0.0.0.0:8000 --settings=webvirtcloud.settings-dev
Open
http://127.0.0.1:8000. The browser consoles connect to/novncd/and/socket.io/on the panel's own address, which the dev server does not proxy: try them in the Docker image, or behind nginx withconf/nginx/webvirtcloud.conf.
The VNC console's "Type it" sends the keys that type each character on the VM's keyboard layout. A layout is one file in console/layouts/, written from the XKB layout of the same name (needs libxkbcommon and xkeyboard-config, present on Linux desktops):
python3 dev/console_layout.py de "German" # XKB layout, the name the console shows
python3 dev/console_layout.py tr "Turkish F" --variant f # a variant
python3 dev/console_layout.py us "English (US)" --model pc104 # a keyboard without the key left of ZIt lists the dead keys it leaves out: a character only a dead key types cannot be typed. Type a sample with every character group of the layout into a VM set to that layout before committing the file.
The panel is translated into the languages of LANGUAGES in settings.py, each with a full catalog in locale/. A new or changed text needs its translation in each before CI passes (needs gettext):
python manage.py makemessages -l tr -l zh --no-obsolete --ignore=staticfiles --ignore=.venv
# translate the new and fuzzy entries of locale/tr/LC_MESSAGES/django.po and locale/zh/LC_MESSAGES/django.po
python manage.py compilemessages -l tr -l zhpython manage.py test # all tests, including vrtManager
ruff check .Each app keeps its tests in a tests/ package, and the tests of the libvirt layer are in vrtManager/tests, so python manage.py test instances runs one app; --parallel spreads the tests over the CPUs. The suite includes mocked regression tests and disk/clone tests on libvirt's in-process test:///default driver. Set WEBVIRTCLOUD_DB_HOST (and the other WEBVIRTCLOUD_DB_* variables) to run it against PostgreSQL, as CI does.
The compute and instance tests run only when TEST_LIBVIRT_HOST is set, never on the machine's own libvirt by default. A green result with skips does not establish live KVM compatibility. The instance tests create wvc-test-* VMs and a temporary wvc-test pool (/var/lib/libvirt/wvc-test), remove them afterwards, and fail if any other VM or volume on the host changed:
export TEST_LIBVIRT_HOST=compute1 TEST_LIBVIRT_TYPE=2 # 1 TCP, 2 SSH, 3 TLS, 4 socket
export TEST_LIBVIRT_LOGIN=root TEST_LIBVIRT_PASSWORD= # login/password for TCP and TLS
export TEST_LIBVIRT_GUEST_IMAGE=/var/lib/libvirt/images/linux.qcow2 # optional, see below
export TEST_LIBVIRT_LVM_DEVICE=/dev/vdb # optional: an empty disk of 4 GiB or more on the host, wiped by the LVM tests
export TEST_LIBVIRT_ISCSI_TARGET=iqn.2026-10.test.wvc:lun # optional: a target on the host's 127.0.0.1, open to any initiator, with a LUN
export TEST_LIBVIRT_NFS_EXPORT=/srv/wvc-nfs # optional: a directory the host exports to its 127.0.0.1 over NFS
python manage.py testTEST_LIBVIRT_GUEST_IMAGE enables the tests that need a guest OS (disk and NIC hot-unplug, vCPU hotplug, guest agent, ACPI power off). It must be an installed, BIOS-bootable Linux qcow2 image with qemu-guest-agent, in a storage pool of the host other than wvc-test; the tests boot copies of it from overlays and only read the image. Shut down any VM using it first.
Two disposable compute nodes for these tests come from the Vagrantfile: AlmaLinux 10 with nested KVM, set up by dev/libvirt-bootstrap.sh (with this checkout's gstfsd), a SASL user for TCP (wvc, password wvc-test) and your ~/.ssh/id_ed25519.pub for root; migration runs between them, and each has an empty second disk (/dev/vdb) for the LVM tests and serves a 1 GiB iSCSI LUN on 127.0.0.1 for the iSCSI test and an NFS export (/srv/wvc-nfs) to 127.0.0.1 for the NFS pool test. They accept TLS logins (SASL) with certificates from a throwaway CA that vagrant up makes in .vagrant/tls; the TLS run takes its client certificate from there through HOME. It needs Vagrant with the vagrant-libvirt plugin, a KVM host with qemu-kvm and cyrus-sasl-md5, and a user in the libvirt group. On a host that also runs Docker, let the VMs' network (the bridge of virsh net-info vagrant-libvirt, virbr1 here) through Docker's forward rules (sudo iptables -I DOCKER-USER -i virbr1 -j ACCEPT and sudo iptables -I DOCKER-USER -o virbr1 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT).
vagrant up --provider=libvirt
C1=$(vagrant ssh compute1 -c "hostname -I" | awk '{print $1}') C2=$(vagrant ssh compute2 -c "hostname -I" | awk '{print $1}')
for h in $C1 $C2; do ssh-keygen -R $h; ssh -o StrictHostKeyChecking=accept-new root@$h true; done # new VMs, new host keys
export TEST_LIBVIRT_HOST=$C1 TEST_LIBVIRT_TYPE=1 TEST_LIBVIRT_LOGIN=wvc TEST_LIBVIRT_PASSWORD=wvc-test
export TEST_LIBVIRT_SSH_HOST=$C2 TEST_LIBVIRT_SSH_LOGIN=root TEST_LIBVIRT_MIGRATE_ADDRESS=$C2 TEST_LIBVIRT_LVM_DEVICE=/dev/vdb TEST_LIBVIRT_ISCSI_TARGET=iqn.2026-10.test.wvc:lun TEST_LIBVIRT_NFS_EXPORT=/srv/wvc-nfs
python manage.py test
HOME=$PWD/.vagrant/tls TEST_LIBVIRT_TYPE=3 TEST_LIBVIRT_SSH_HOST= python manage.py test # compute1 again, over TLS
# a socket compute is the panel's own libvirt: the committed HEAD runs inside compute1
git archive HEAD | vagrant ssh compute1 -c "sudo sh -c 'rm -rf /root/wvc && mkdir /root/wvc && tar -x -C /root/wvc'"
vagrant ssh compute1 -c "sudo -i sh -ec 'dnf -y install python3-devel openldap-devel cyrus-sasl-devel gcc; cd /root/wvc; python3 -m venv --system-site-packages venv; venv/bin/pip install -r dev/requirements.txt; cp webvirtcloud/settings.py.template webvirtcloud/settings.py; mkdir data; venv/bin/python conf/runit/secret_generator.py > data/secret_key; TEST_LIBVIRT_HOST=localhost TEST_LIBVIRT_TYPE=4 TEST_LIBVIRT_LOGIN=wvc TEST_LIBVIRT_PASSWORD=wvc-test TEST_LIBVIRT_LVM_DEVICE=/dev/vdb TEST_LIBVIRT_ISCSI_TARGET=iqn.2026-10.test.wvc:lun TEST_LIBVIRT_NFS_EXPORT=/srv/wvc-nfs venv/bin/python manage.py test'"
vagrant destroy -fWebVirtCloud is licensed under the Apache Licence, Version 2.0.





