Deploying a Linux VM on TrueNAS SCALE¶
This guide walks through creating a headless Linux virtual machine on TrueNAS SCALE using a cloud-init image — no VNC client or manual installer required. The VM is SSH-accessible immediately after first boot. After completing the steps here, continue with the DevSecNinja/docker repository to install Docker and deploy services.
Currently supported distributions:
- Debian 13 (Trixie)
- Debian 12 (Bookworm)
Tested on April 12, 2026
Tested on macOS Tahoe 26.3.1 with zsh on April 12, 2026. The full workflow — from downloading the cloud image to a running, SSH-accessible VM — took approximately 10 minutes from scratch.
Overview¶
Instead of booting a Debian ISO and clicking through an installer, this approach uses:
- Debian's official generic cloud image — a pre-installed, minimal Debian disk image in qcow2 format.
- cloud-init — a standard tool for first-boot VM configuration (users, SSH keys, hostname, packages). The configuration is passed to the VM as a small seed image mounted as a virtual CD-ROM.
The result: the VM boots, configures itself, and is reachable over SSH — all without a VNC console.
Credit
The cloud-init approach used here is based on Automating VM Setup with cloud-init on TrueNAS Scale and the linked Roberto Rosario guide.
Prerequisites¶
- TrueNAS SCALE installed and accessible over SSH (
ssh truenas_admin@truenas.local). - A storage pool already created (the examples below use
vm-pool). - Your SSH public key (
~/.ssh/id_ed25519.pubor similar) ready to embed in the cloud-init config.
On your local machine, install the cloud-init seed image tool if not already present:
Step 1: Create ZFS Datasets¶
Create a dataset for VM disk images (writable by truenas_admin), a parent dataset for zvols, and the zvol for the VM's root disk.
1a. ISO / images dataset¶
This dataset holds the Debian base image and cloud-init seed files. It is kept separate from the zvols so that truenas_admin can write to it and so the same base image can be reused for future VMs.
In the TrueNAS UI, go to Datasets and click Add Dataset on your pool:
| Setting | Value | Why |
|---|---|---|
| Name | iso |
Shared location for all VM base images and seed files |
| Path | vm-pool/iso |
|
| Dataset Preset | Generic | |
| Sync | Standard | |
| Compression | lz4 | |
| Enable Atime | Off | |
| Encryption | Inherit (or enabled) |
After creating it, set Unix permissions via Datasets → vm-pool/iso → Edit Permissions:
| Setting | Value | Why |
|---|---|---|
| User | truenas_admin |
Allows truenas_admin to upload images via SCP |
| Group | truenas_admin |
|
| Mode | rwxr-xr-x |
libvirt-qemu (the VM runtime user) is not in the truenas_admin group and needs r-x on this directory to read seed images |
1b. VM dataset¶
In the TrueNAS UI, go to Datasets and click Add Dataset on your pool:
| Setting | Value | Why |
|---|---|---|
| Name | vms |
Groups all VM zvols under one parent for easier administration |
| Path | vm-pool/vms |
|
| Dataset Preset | Generic | No special preset needed — zvols are children; this is just a container |
| Sync | Standard | Balances write durability and performance for general workloads |
| Compression | lz4 | Fast, low-overhead compression; reclaims space on sparse VM images |
| Enable Atime | Off | Eliminates unnecessary write I/O caused by access-time tracking |
| Encryption | Inherit (or enabled) | Inherit pool-level encryption, or enable explicitly if standalone |
1c. VM root disk (zvol)¶
Still in Datasets, click Add Zvol under vm-pool/vms:
| Setting | Value | Why |
|---|---|---|
| Zvol Name | svldev |
One zvol per VM for independent snapshots, rollbacks, and identification |
| Path | vm-pool/vms/svldev |
|
| Size | 50 GiB (adjust to your workload) |
Enough headroom for the OS, Docker images, and container volumes |
| Sync | Standard | Matches the parent dataset; appropriate for a VM disk |
| Compression | lz4 | VM filesystems compress well, especially blocks filled with zeros |
| Sparse | ✓ (checked) | Only allocates space as it is written; the cloud image starts at ~2 GiB |
| Deduplication | Off | Do not enable dedup on VM zvols — it requires ~1 GB RAM per 1 TB of data, adds significant I/O overhead, and rarely yields savings for VM disk images |
Step 2: Prepare the Cloud-Init Seed (local machine)¶
All the commands in this section run on your local machine, not on TrueNAS. Use bash or zsh — fish shell does not support the heredoc and variable-substitution patterns used here.
Start by creating a temporary working directory to keep the generated files together:
2a. Set variables¶
Set these once — all subsequent commands use them:
| Variable | Example value | What to set |
|---|---|---|
VM_NAME |
svldev |
VM hostname — used for zvol, cloud-init, and DNS record |
VM_IP |
192.168.1.50 |
Free static IP on your LAN |
VM_GW |
192.168.1.1 |
Your router / gateway IP |
VM_MAC |
52:54:00:a1:b2:c3 |
QEMU/KVM OUI (52:54:00) + 3 unique octets of your choice |
VM_USER |
your-user |
Non-root account cloud-init will create |
VM_DOMAIN |
yourdomain.com |
Internal domain resolved by AdGuard/Unbound — used for FQDN and DNS record |
VM_TZ |
Europe/Amsterdam |
Timezone for the VM — should match your TrueNAS timezone |
SSH_KEY |
ssh-ed25519 AAAA... |
Full contents of ~/.ssh/id_ed25519.pub |
TRUENAS |
truenas_admin@truenas.local |
SSH target for your TrueNAS host |
IMAGE_PATH |
/mnt/vm-pool/iso |
Path on TrueNAS where images are stored (the iso dataset from step 1a) |
DEBIAN_IMAGE |
debian-13-generic-amd64.qcow2 |
Cloud image filename — set below based on your target Debian version |
DEBIAN_URL |
(set by the tab below) | Full download URL for the image |
VM_NAME=svldev
VM_IP=192.168.1.50
VM_GW=192.168.1.1
VM_MAC=52:54:00:xx:xx:xx
VM_USER=your-user
VM_DOMAIN=yourdomain.com
VM_TZ=Europe/Amsterdam
SSH_KEY="ssh-ed25519 AAAA..."
TRUENAS=truenas_admin@truenas.local
IMAGE_PATH=/mnt/vm-pool/iso
Tip
The 52:54:00 prefix is QEMU/KVM's standard OUI. UniFi recognises it and labels the device
as a virtual machine. You can pre-register the device in UniFi with this MAC address before
the VM even boots, giving it a reserved IP and a friendly name.
2b. Download the Debian generic cloud image¶
Use generic, not genericcloud
The genericcloud variant is missing the CD-ROM drivers that cloud-init needs to read the seed image when it is mounted as a virtual CD-ROM. Only the generic image works for this setup.
2c. Write your cloud-init config¶
Write the seed config using a heredoc so the variables from step 2a are substituted automatically:
cat > ${VM_NAME}-seed.yaml << EOF
#cloud-config
hostname: ${VM_NAME}
fqdn: ${VM_NAME}.${VM_DOMAIN}
manage_etc_hosts: true
timezone: ${VM_TZ}
ntp:
enabled: true
servers:
- 0.pool.ntp.org
- 1.pool.ntp.org
users:
- name: ${VM_USER}
groups: [sudo]
shell: /bin/bash
ssh_authorized_keys:
- ${SSH_KEY}
sudo: ALL=(ALL) NOPASSWD:ALL # passwordless sudo
ssh_pwauth: false # disable SSH password authentication; key-only access
write_files:
- path: /etc/ssh/sshd_config.d/hardening.conf
content: |
# Prevent direct root login over SSH
PermitRootLogin no
# Drop idle sessions after 10 minutes (2 missed keepalives × 5 min interval)
ClientAliveInterval 300
ClientAliveCountMax 2
- path: /etc/sysctl.d/99-hardening.conf
content: |
# Drop packets that arrive on an interface that would not route them back the same way
net.ipv4.conf.all.rp_filter = 1
# Restrict kernel log (dmesg) to privileged users only
kernel.dmesg_restrict = 1
# Protect against SYN flood attacks by using cryptographic cookies instead of state allocation
net.ipv4.tcp_syncookies = 1
# Full ASLR: randomize stack, heap, and shared library addresses to harden against memory exploits
kernel.randomize_va_space = 2
# Static network configuration — no DHCP, predictable address from first boot
network:
version: 2
ethernets:
eth0:
match:
macaddress: ${VM_MAC}
set-name: eth0
addresses:
- ${VM_IP}/24
routes:
- to: default
via: ${VM_GW}
nameservers:
addresses:
- ${VM_GW}
package_update: true
package_upgrade: true
packages:
- curl
- git
- ca-certificates
- qemu-guest-agent
- unattended-upgrades
runcmd:
- sed -i 's/^# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen
- locale-gen
- echo 'LANG=en_US.UTF-8' > /etc/default/locale
- sysctl --system
- systemctl start qemu-guest-agent
- dpkg-reconfigure -f noninteractive unattended-upgrades
swap:
size: 0
power_state:
mode: poweroff
condition: true
EOF
On sudo: ALL=(ALL) NOPASSWD:ALL
This grants full passwordless root access. It is relatively safe here because ssh_pwauth: false ensures
only SSH key holders can log in — an attacker without your private key cannot reach the machine
at all, and requiring a sudo password at that point adds no meaningful protection. If you prefer
to require a password for sudo, remove NOPASSWD: from the sudo line and omit the NOPASSWD
field.
2d. Build the seed image¶
hdiutil bundles files from the source directory as-is. Cloud-init requires the config file
to be named exactly user-data inside the ISO — use a dedicated subdirectory so only the two
required files are included and the image stays small:
mkdir seed-files
cp ${VM_NAME}-seed.yaml seed-files/user-data
echo "instance-id: ${VM_NAME}" > seed-files/meta-data
echo "local-hostname: ${VM_NAME}" >> seed-files/meta-data
hdiutil makehybrid -o ${VM_NAME}-seed -hfs -joliet -iso \
-default-volume-name cidata seed-files
mv ${VM_NAME}-seed.iso ${VM_NAME}-seed.img
rm -rf seed-files
Verify the result before uploading — mount it and confirm you see exactly user-data and meta-data:
Note
The seed must have the volume label cidata — cloud-init identifies it by that label, not the file extension.
2e. Copy both images to TrueNAS¶
2f. Clean up the working directory¶
Once the images are on TrueNAS you no longer need the local copies. You can either delete the directory entirely:
Or keep the seed config for future reference (the qcow2 is large and can always be re-downloaded):
# Keep only the cloud-init config; remove the large images
rm /tmp/cloud-init/debian-12-generic-amd64.qcow2
rm /tmp/cloud-init/${VM_NAME}-seed.img
Step 3: Provision the VM on TrueNAS (TrueNAS SSH)¶
First, query the available NIC interfaces directly from your local machine so you can set VM_NIC before SSHing in:
ssh "${TRUENAS}" "midclt call interface.query | jq -r '.[] | select(.type == \"BRIDGE\" or .type == \"VLAN\") | \"\(.type)\\t\(.name)\"'"
This lists both bridge and VLAN interfaces. Pick the one appropriate for your VM:
- Use a BRIDGE interface (
br0,br1, etc.) to place the VM on the main LAN - Use a VLAN interface (e.g.
vlan60) to place the VM on a specific VLAN
Set VM_NIC locally:
Now SSH into TrueNAS, carrying all local variables over automatically:
ssh -t "${TRUENAS}" "export VM_NAME='${VM_NAME}' VM_MAC='${VM_MAC}' VM_NIC='${VM_NIC}' VM_PATH='vm-pool/vms/${VM_NAME}' IMAGE_PATH='${IMAGE_PATH}' DEBIAN_IMAGE='${DEBIAN_IMAGE}' VM_MEMORY='$((4 * 1024))'; exec bash"
All variables are now available in the TrueNAS session — no need to re-declare them. Keep this session open until the VM is started.
3a. Write the disk image to the zvol¶
First confirm the zvol's device node symlink is in place. If the TrueNAS UI did not trigger udev automatically, the symlink may be missing and qemu-img will create a plain file in its place instead of writing to the block device:
The output must show a symlink (lrwxrwxrwx), for example:
If the path does not exist or is a regular file (-rw), trigger udev and wait:
Once the symlink is confirmed, write the image:
This expands the qcow2 image and writes it raw onto the zvol. The zvol size (50 GiB) defines the maximum usable space — Debian's growpart service will automatically expand the root partition to fill it on first boot.
3b. Create the VM and its devices¶
RESULT=$(midclt call vm.create '{
"name": "'"${VM_NAME}"'",
"cpu_mode": "HOST-MODEL",
"bootloader": "UEFI",
"enable_secure_boot": true,
"cores": 2,
"threads": 2,
"memory": '"${VM_MEMORY}"',
"autostart": true
}')
VM_ID=$(echo "${RESULT}" | jq '.id')
Attach the root disk (zvol written above):
midclt call vm.device.create '{
"vm": '"${VM_ID}"',
"order": 1001,
"attributes": {
"dtype": "DISK",
"path": "/dev/zvol/'"${VM_PATH}"'",
"type": "VIRTIO"
}
}'
Attach the cloud-init seed as a virtual CD-ROM:
midclt call vm.device.create '{
"vm": '"${VM_ID}"',
"order": 1005,
"attributes": {
"dtype": "CDROM",
"path": "'"${IMAGE_PATH}/${VM_NAME}-seed.img"'"
}
}'
Attach the NIC:
midclt call vm.device.create '{
"vm": '"${VM_ID}"',
"order": 1010,
"attributes": {
"dtype": "NIC",
"type": "VIRTIO",
"nic_attach": "'"${VM_NIC}"'",
"mac": "'"${VM_MAC}"'"
}
}'
3c. Start the VM¶
A null response means success. To follow the boot progress, open the TrueNAS UI, go to Virtualization → svldev, and open Serial Shell. There is no supported CLI path to the serial console on TrueNAS SCALE — virsh requires a libvirt socket that is absent, and the pts/socket used internally by the middleware is not directly accessible.
You will see cloud-init progress through its stages. Once the login prompt appears, cloud-init continues running silently in the background — package_update and package_upgrade are downloading and installing packages at this point and produce no serial output. Depending on the age of the base image, this can take 2–10 minutes. The VM will power off automatically when cloud-init finishes successfully.
Step 4: Remove the CD-ROM and start the VM¶
Cloud-init runs on first boot and powers the VM off when done. Wait until the TrueNAS UI shows the VM as Stopped before continuing — this can take 5–10 minutes if package upgrades are large.
Verifying cloud-init ran correctly
In the Serial Shell you should see the hostname set to your VM_NAME (not localhost) and
the final line should mention your VM_NAME datasource, not DataSourceNone. If you see
DataSourceNone or hostname localhost, the seed image was not read — most likely the
user-data file was not named correctly in the ISO (see step 2d).
Once it is stopped, remove the CD-ROM device — it is no longer needed and keeping it attached would cause cloud-init to re-run on the next boot:
CDROM_ID=$(midclt call vm.device.query \
| jq ".[] | select(.vm == ${VM_ID} and .attributes.dtype == \"CDROM\") | .id")
midclt call vm.device.delete "${CDROM_ID}"
rm ${IMAGE_PATH}/${VM_NAME}-seed.img
Then start the VM:
Step 5: Connect via SSH¶
The VM boots in a few seconds on the second start. Connect using the static IP configured in the cloud-init seed:
No VNC client needed — cloud-init already installed your SSH key, created your user, and configured the network.
Step 6: Register the VM in Unbound¶
Add an A record for the VM so it is reachable by hostname on your LAN. In services/adguard/config/unbound/conf.d/a-records.conf, add:
Then add the corresponding variable to services/adguard/secret.sops.env (use the actual IP, not the shell variable):
Deploy AdGuard to pick up the change. Once active, ${VM_NAME}.${VM_DOMAIN} will resolve on your LAN and the cloud-init fqdn will be fully functional.
Step 7: Deploy Docker¶
Continue with the DevSecNinja/docker repository, which handles:
- Docker Engine installation
- Docker Compose plugin
- User and group configuration
- Any additional bootstrapping steps
Follow the instructions in that repository's README from this point forward.
Snapshot strategy (recommended)¶
Take ZFS snapshots of the zvol at key milestones so you can roll back cleanly:
# On TrueNAS
zfs snapshot vm-pool/vms/svldev@post-cloud-init
zfs snapshot vm-pool/vms/svldev@post-docker-install
You can also configure periodic auto-snapshots in the TrueNAS UI under Data Protection → Periodic Snapshot Tasks.
Teardown (removing the VM)¶
Permanent — no undo
The steps below destroy the VM, its zvol, and all ZFS snapshots. This cannot be undone. Take a final snapshot first if you want a recovery point:
To completely remove the VM and reclaim storage, run the following from a TrueNAS SSH session. Re-declare the variables if your session has expired:
Stop the VM if it is running:
VM_ID=$(midclt call vm.query | jq ".[] | select(.name == \"${VM_NAME}\") | .id")
midclt call vm.stop "${VM_ID}"
Delete the VM definition (removes all attached devices from TrueNAS's records):
Destroy the zvol and all its snapshots:
Optionally remove the base image and any leftover seed files from the ISO dataset if you no longer need them (the base image can always be re-downloaded):
Finally, remove the DNS record from services/adguard/config/unbound/conf.d/a-records.conf and the variable from services/adguard/secret.sops.env, then redeploy AdGuard.