Hugo on KVM: Installing an Ubuntu VM for a Static Site

โก In short
Progressive migration from Grav CMS to Hugo โ a static site generator. The goal is to isolate Hugo in a dedicated KVM VM on the NUC8i3BEH, with nginx on the host as a reverse proxy. The generated static site is served by nginx inside the VM โ no PHP, no database, no application attack surface.
- ๐ฅ๏ธ Host: NUC8i3BEH Ubuntu 24.04 โ nginx proxy + KVM
- ๐๏ธ VM disk: Samsung X5 external NVMe (exFAT โ ext4 loop image)
- ๐ Access: hugo-test.arleo.eu
- ๐จ Theme: LoveIt
๐ง Why
Grav CMS is excellent but relies on PHP โ a non-negligible attack surface. Hugo generates pure static HTML: no PHP, no database, no application vulnerability. Performance is also radically better โ HTML is served directly by nginx without dynamic processing.
Isolation in a KVM VM provides an additional advantage: if the VM is compromised, the host remains intact. The host’s nginx proxy simply forwards requests to the VM without exposing other services.
Final architecture
Internet โ Cloudflare โ nginx (NUC host) โ KVM VM (192.168.122.69)
โ
Hugo + nginx
(static HTML)๐ง What was done
1. KVM installation on Ubuntu 24.04 host
sudo apt install -y \
qemu-kvm libvirt-daemon-system libvirt-clients \
virtinst bridge-utils cloud-image-utils \
cockpit-machines iptables
sudo usermod -aG libvirt jm
sudo usermod -aG kvm jm
sudo systemctl enable --now libvirtd
# Enable the default NAT network
sudo virsh net-start default
sudo virsh net-autostart default2. exFAT issue โ ext4 loop image on the NVMe
The Samsung X5 external NVMe is formatted as exFAT โ this filesystem does not support Unix permissions (chown). QEMU therefore cannot read disk images stored directly on it.
Solution: create an ext4 image file inside the exFAT volume:
# Create a 25 GB ext4 image on the exFAT NVMe
sudo dd if=/dev/zero of=/mnt/X5/libvirt-pool.img bs=1G count=25 status=progress
sudo mkfs.ext4 /mnt/X5/libvirt-pool.img
# Mount the image
sudo mkdir -p /mnt/libvirt-kvm
sudo mount -o loop /mnt/X5/libvirt-pool.img /mnt/libvirt-kvm
# Persist across reboots
echo "/mnt/X5/libvirt-pool.img /mnt/libvirt-kvm ext4 loop,nofail 0 0" \
| sudo tee -a /etc/fstab
# Permissions for QEMU
sudo chown libvirt-qemu:kvm /mnt/libvirt-kvm
sudo chmod 755 /mnt/libvirt-kvm
# Dedicated libvirt pool
sudo virsh pool-define-as kvm-pool dir --target /mnt/libvirt-kvm
sudo virsh pool-autostart kvm-pool
sudo virsh pool-start kvm-pool3. VM creation with cloud-init
# Download the Ubuntu 24.04 cloud image
wget -O /mnt/libvirt-kvm/ubuntu-24.04-server.img \
https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
# Create the VM disk (20 GB, backing file)
qemu-img create \
-F qcow2 -b /mnt/libvirt-kvm/ubuntu-24.04-server.img \
-f qcow2 /mnt/libvirt-kvm/hugo-vm.qcow2 20G
sudo chown libvirt-qemu:kvm /mnt/libvirt-kvm/hugo-vm.qcow2
# Cloud-init with SSH key
SSH_KEY=$(cat /home/jm/.ssh/authorized_keys | head -1)
cat > /tmp/user-data << EOF
#cloud-config
hostname: hugo-vm
users:
- name: jm
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
lock_passwd: false
ssh_authorized_keys:
- $SSH_KEY
packages:
- nginx
- git
- curl
runcmd:
- systemctl enable nginx
- systemctl start nginx
EOF
cloud-localds /mnt/libvirt-kvm/hugo-vm-cloud-init.img \
/tmp/user-data /tmp/meta-data4. Starting the VM
sudo virt-install \
--name hugo-vm \
--ram 1024 \
--vcpus 1 \
--disk path=/mnt/libvirt-kvm/hugo-vm.qcow2,format=qcow2,bus=virtio \
--disk path=/mnt/libvirt-kvm/hugo-vm-cloud-init.img,device=cdrom \
--os-variant ubuntu24.04 \
--network network=default,model=virtio \
--graphics none \
--import \
--noautoconsole \
--autostart
# Get the VM IP
sudo virsh net-dhcp-leases default
# โ 192.168.122.695. Installing Hugo Extended in the VM
ssh jm@192.168.122.69
HUGO_VERSION="0.147.0"
wget -q -O /tmp/hugo.deb \
"https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb"
sudo dpkg -i /tmp/hugo.deb
hugo version
# hugo v0.147.0+extended linux/amd646. Hugo site initialization with LoveIt
mkdir -p ~/hugo-site && cd ~/hugo-site
hugo new site . --force
git init
git submodule add https://github.com/dillonzq/LoveIt.git themes/LoveIt7. nginx configuration in the VM
server {
listen 80 default_server;
server_name _;
root /var/www/hugo;
index index.html;
location = / {
return 302 /fr/;
}
location / {
try_files $uri $uri/ =404;
}
}8. Deployment script
cat > ~/deploy.sh << 'EOF'
#!/bin/bash
cd ~/hugo-site
hugo --minify
sudo rsync -a --delete public/ /var/www/hugo/
sudo chown -R www-data:www-data /var/www/hugo/
echo "Deployed โ $(date)"
EOF
chmod +x ~/deploy.sh9. nginx reverse proxy on the host
server {
listen 443 ssl http2;
server_name hugo-test.arleo.eu;
include snippets/ssl.conf;
location / {
proxy_pass http://192.168.122.69:80;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Issues encountered
| Problem | Cause | Solution |
|---|---|---|
| libvirt network inactive | iptables missing | apt install iptables |
| Permission denied on qcow2 | exFAT disk โ no chown | ext4 loop image inside exFAT file |
| Cloud-init does not create user | No SSH key on host | virt-customize to inject directly |
| SSH refused (publickey) | No private key on host | Generate hugo_vm key + inject via virt-customize |
| nginx 404 | jm home permissions (750) | Move public/ to /var/www/hugo |
๐ Conclusion
The Hugo VM is operational at hugo-test.arleo.eu. The static site is served by nginx inside the VM, proxied by the host. The next step is migrating content from Grav to Hugo and developing a Hugo MCP to enable page management directly from Claude.ai.
Next steps:
- ๐ก Develop a Hugo MCP (FastAPI + OAuth) modeled on grav-mcp-server
- ๐ก Automate Hugo rebuilds via Git webhook
- ๐ก Configure VM backup to the QNAP NAS