---
title: "Hugo on KVM: Installing an Ubuntu VM for a Static Site"
description: "How to install an Ubuntu 24.04 VM on KVM with libvirt, deploy Hugo Extended with the LoveIt theme, and configure nginx as a reverse proxy on a NUC8i3BEH."
url: "https://www.arleo.eu/en/posts/hugo-vm-installation/"
language: "en"
datePublished: "2026-05-03T09:00:00+02:00"
dateModified: "2026-05-15T23:51:25+02:00"
tags: ["infrastructure","nginx","homelab","mcp"]
categories: ["infrastructure"]
contentSignal: "ai-train=no, search=yes, ai-input=yes"
---

# Hugo on KVM: Installing an Ubuntu VM for a Static Site
How to install an Ubuntu 24.04 VM on KVM with libvirt, deploy Hugo Extended with the LoveIt theme, and configure nginx as a reverse proxy on a NUC8i3BEH.



## ⚡ 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](https://hugo-test.arleo.eu)
- 🎨 **Theme**: [LoveIt](https://github.com/dillonzq/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

```bash
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:

```bash
# 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

```bash
# 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

```bash
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

```bash
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

```bash
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

```nginx
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

```bash
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

```nginx
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](https://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](https://arleo.eu/en/grav-mcp-server)
- 💡 Automate Hugo rebuilds via Git webhook
- 💡 Configure VM backup to the QNAP NAS



## Tags

- infrastructure
- nginx
- homelab
- mcp

## Categories

- infrastructure
