Contents

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 default

2. 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-pool

3. 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-data

4. 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.69

5. 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/amd64

6. 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/LoveIt

7. 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.sh

9. 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

ProblemCauseSolution
libvirt network inactiveiptables missingapt install iptables
Permission denied on qcow2exFAT disk โ†’ no chownext4 loop image inside exFAT file
Cloud-init does not create userNo SSH key on hostvirt-customize to inject directly
SSH refused (publickey)No private key on hostGenerate hugo_vm key + inject via virt-customize
nginx 404jm 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