Cloud at home with minimum toil - PXE / Proxmox / Saltstack / k3s

20250128_224935

My home lab was 7 years old and it was time to replace it. It was based on five Odroid HC1 nodes and 1 Odroid N1 (which never reached the mass production stage, Hardkernel sent it to me as a gift for a debug party). 4 HC1 nodes were used for a Docker Swarm cluster and 1 was dedicated to Nginx as a reverse proxy / WAF / SSL offloader. Regarding the Odroid N1, it was used as a NAS and also as a saltstack master.

Everything has been replaced with a major upgrade: faster infrastructure, better power efficiency, GitOps approach, virtualized environments, Kubernetes cluster, improved Saltstack implementation and last but not least: everything can be built automaticaly from scratch using PXE and the help of custom scripts  !

This is the (very long) story of my new homelab build…

Concept: the big picture

Before diving into all the details, I think it is important to understand what was goind through my mind, the concept I tried to implement (and hopefully succeeded in doing so).

I’m not saying that it is the best approach. After all, it’s a homelab,which, to me, means a way to learn, have fun, express creativity, and, of course, self-host some services.

Simplified GitOps approach

I did not use any CI/CD components, no branches (at least for now), but the bare minimum for Infrastructure as Code is in place:

  • 100% “as code” infrastructure
  • Git repository
  • IaC frontend through Saltstack
  • IaC backend implementation with Proxmox VE

Infrastructure overview

A homelab comes with certain constraints that sometimes prevent you from making the best technical choices. For me, the main concerns were:

  • Money: Obviously, resources are limited and must be optimized based on what you want or need to achieve.
  • Space: Hardware takes up room, can be noisy, etc.
  • Power consumption: For hardware running 24/7, this can be a real game-changer for the annual electricity bill.
  • Ease of replacement: How easily things can be replaced in case of failure, as I don’t have tons of free time!

Hardware

In this homelab revision, I chose x86 hardware over ARM. The Intel N100 is a powerhouse in terms of performance, efficiency, and price. Not to mention that it offers far greater expansion options than ARM SBCs, especially when it comes to RAM and virtualization support.

Devices

Basically, my “cloud@home” setup consists of two Intel N100 nodes and one N6000 (Liva Z3). Since I have a small, wall-mounted 19" rack with a depth of 30 cm, I decided to build custom servers using rack enclosures.

Below is the list of components I used:

Item Quantity Comments
Intertech 1.5U-1528L 1 Case for Node-1. A surprisingly good enclosure for the price: only 1.5U in height, with two externally accessible 3.5″ bays for SATA drives. It supports a Mini-ITX motherboard and has a depth of 27.8 cm.
Intertech IPC 1HU-K-125L 1 Case for Node-2. Similar to the previous one but with a 1U height and no externally accessible 3.5″ bay.
Fortron FSP250-50FEB 2 A 250W Flex ATX power supply with up to 85% efficiency—useful for a setup running 24/7.
ASUS Prime N100I-D D4-CSM 2 Mini-ITX motherboard with an integrated N100 processor. I found it to have a good balance of features and price.
32Gb Kingston FURY Impact, DDR4 3200 SO-DIMM 2 Contrary to the official specs, the N100 is not limited to 16GB of RAM—it works perfectly with 32GB.
Kingston NV2 – 1Tb 2 Used for virtual machines and container storage. Offers good performance and capacity for the price. More than enough for my home lab.
SATA SSD kingston kc600 256Gb 2 Boot disks and /root storage for Proxmox on both Node-1 and Node-2.
Uni USB 3.0 to 2.5Gbps ethernet adatpter. 4 The ASUS motherboard has only a single gigabit NIC, which was too limiting for my needs. Adding two additional ports allows for bonding (LACP) plus a dedicated 2.5G node-to-node connection for the replication network and Corosync. I initially tried dual-port PCIe cards, but none worked properly (r8123, r8125, Intel 82571, and Intel i225-V).
JMB582 based SATA 3 M.2 controller with 2 ports (Key A + E) 1 Used to connect the two hard drives for the NAS VM. I could have used a PCIe card, but the only available slot was initially occupied by a network card… well, initially.
Seagate HDD 8Tb Sata 3 2 Used with the JMB582 controller for NAS storage.
Liva Z3 – 128 Gb 1 Used as Node-3 for Proxmox quorum, Salt Master, Zabbix monitoring, and a third Kubernetes node (mainly to improve etcd cluster stability).
Kingston fury DD4 3200 8Gb 2 16GB dual-channel RAM for the Liva Z3.
Patriot P400 Lite 250 Gb 1 Low-power PCIe M.2 SSD for the Liva Z3

Bios settings

I did some BIOS “tweaking” mostly to save power and to allow for PXE boot on all nodes (including wake-on-lan activation)

Asus Prime N100I-D

Here are the settings for the two N100 nodes:

  • dvmt graphic memory set from 64 to 32 : no video memory needed for a headless server
  • enabled sr-iov support for better offloading on NIC
  • disabled all usb port but usb_3, usb_4, u32g1_e3 and u32g1_e4 which are the one mounted on the face and the one used for the etherner adapters.
  • enabled network stack, ipv4 PXE and set PXE boot as first priority (can be done only after a reboot)
  • set restore AC power loss to last state in case of power failure, so it will be powered back on
  • enabed wake-on-lan (which is called “power on by pci-e”) so I could turn on my servers from pfsense easily
  • disabled hdaudio, not needed, save power
  • disabled wifi and bluetooth. Through, there are no such functionnalities installed
  • disabled serial and parallel ports, save power
  • enabled XMP as the memory I use support it. It might set timings to better ones, can’t hurt.
  • Disable fast boot : server’s are not willing to reboot a lot and I prefer letting all hardware checks to be done when I reboot it
  • enabled native aspm for power saving

Liva z3

  • Power management / resume via PME : mandatory to allow wake-on-lan
  • Disabled ACPI sleep state: I don’t need and don’t want it to be able to sleep as a 24/7 server
  • Wireless function : disabled wifi and bluetooth, I do not need then and it can save power
  • System agent configuration: set all memory values to mininum as it will be a headless machine, no need for GPU memory
  • PCH configuration: disabled audio (not needed + power save), set restore AC power lost to power on
  • Boot / network stack: enabled (wol), disabled quiet boot and set boot order to  “usb, network, harddisk”
  • disabled EUP else wake-on-lan won’t work

Photos

I took some photos during the “first” assembly process. There have been some change since I replaced the PCIe network cards by USB-3 adapters and finaly used 32Gb So-dimms.

Base infrastructure

Prepare for PXE boot

As described previously, I configured all 3 nodes to boot via Network / PXE by default and fallback to internal storage. The main goal is to be able to “factory reset” any server while rebooting (all “post-install” configuration is then done by Saltstack).

If I want to restart one node from scratch, I just enable pxeboot on my PFsense box, set the corresponding boot file and reboot the device. Once the install has finished, I disable pxeboot and the device fallback to the media on which the fresh install has been done.

In Pfsense, I installed the package “tftpd”. then I configured it via the menu “Services / TFTPD Server”:

  • Enable it
  • Restrict to ip adresses of vlan with PXE enabled devices
  • Set to ipv4 only as I don’t use IPv6 for now
  • Open udp port 69 in the corresponding vlan
  • Download IPXE and and add files on the TFTP Server (http://boot.ipxe.org/undionly.kpxe and http://boot.ipxe.org/ipxe.efi). In my case, I used scp to copy files on my PFsense box.
  • Add an autoxec.ipxe file that will be launched by ipxe

Bellow is the my autoxec.ipxe file:

#!ipxe
dhcp

set webserver tftp://aaa.bbb.ccc.dddd

initrd ${webserver}/pve-8.2/initrd || echo "error loading initrd"
kernel ${webserver}/pve-8.2/linux26 initrd=initrd ramdisk_size=16777216 rw proxmox-start-auto-installer || echo "error loading kernel"

boot

Note that calling “dhcp” in first place seems to be redundant as the Bios / EFI already got an ip address to load ipxe. But In fact, without calling “dhcp”, I had timeout while loading the (quite big) Proxmox initrd file…

Then I configured PFsense’s DHCP service to send required information for PXE boot to work:

  • Uncheck ignore bootp queries
  • set TFTP Server to the corresponding IP of the router (for each vlan on which tftpd should respond)
  • Enable network booting
  • Set the boot files : undionly.kpxe for BIOS and ipxe.efi for UEFI

In order to be able to control and customize any node on my network, each one has a specific “autoexec.ipxe” and “initrd” files that I just rename when I need to re-stage them.

I keep the default autoexec.ipxe with the “exit 1” command so by default, my servers boot to the next BIOS option. It is not mandatory but it makes the boot process faster when PXE is not enabled.

Now booting any node from my network is possible and simply controled via my PFSense box.

Proxmox nodes provisioning

PXE image creation

What I wanted was to boot any node from the Proxmox ISO in “auto install” mode as explained on the wiki page https://pve.proxmox.com/wiki/Automated_Installation. This implies to generate a custom ISO image with my own answers file, then to convert the ISO into a PXE bootable file with  https://github.com/morph027/pve-iso-2-pxe.

I also needed to add some customizations to both install process and first boot.

What I added to the the install process are:

  • Patch Proxmox to allow installing on an emmc disk (for the Liva z3)
  • Add a custom network interfaces file to setup the network correctly (vlan, bond, etc.)
  • Add a custom rc.local file to further customize after the installation process

During the first boot, I wanted to execute some actions so each node could be ready and fully configured with very few manual actions. This the purpose of the custom rc.local:

  • Configure network interfaces with the file injected in the ISO image
  • Configure /dev/sda to be fully dedicated to proxmox root (other storages are on nvme)
  • Remove enterprise repository and enable the community one
  • Remove subscription nag uppon login
  • Install saltstack so nodes can be automaticaly configured after the first boot
  • Create a zfs pool on the nvme disk
  • Enable wake-on-lan
  • Create a first LXE container on node-3 to host the salt-master
  • Create the Proxmox cluster on node-1

Each node is fully configured at first boot either by rc.local file or by saltstack which runs highstate for any new node.

This leads me to the create two Github projects:

  • A public “generic” one that allows to generate PXE image with automated Proxmox install and custom files : https://github.com/jit06/pve-auto-pxe
  • A private “specific” one: which contains my own custom files and a small build system to create all needed files in a folder that can be mounted as “/config” for pve-auto-pxe.

Obviously, I cannot share the private repository as it contains some informations I don’t want to share, but basically this is what it does:

  • Create a build folder with hostname sub folders in it
  • If a hostname’s specific rc.local exists, merge it with the main rc.local
  • Generate a finalized rc.local and autoexec.pxe files for all hosts
  • Ask for, then inject root password in answer.toml files
  • Ask for, and inject saltstack and git passwords for the Liva z3 node

This build script generates a kernel and initrd files that can be copied to the TFTP server on my Pfsense. The initrd file is 1.6Go in size and PFSense does not allow to copy such big file from the web interface. It has to be copied though ssh / scp.

Customized Proxmox images

The Proxmox ISO image customization is here to set up things that are more or less hardware related or needed right after a clean install. Everything else is set and / or tuned with saltstack.

As explained earlier, the initial configuration is done via a custom rc.local script injected in the Proxmox ISO image. All nodes have the same rc.local base plus a specific one.

Below is the common rc.local. It is executed only once as it is replaced by a new one via saltstack as soon as the salt-minion is connected.

Click to expand code
#!/bin/sh -e
#
# rc.local
#
# This script is executed at the end of each multiuser runlevel.
# any non 0 exit will be reported as failure by systemd

# set new interfaces file if any
if [ -f /etc/network/interfaces.install ]; then
    echo "found a network interfaces file to install"
    rm /etc/network/interfaces
    mv /etc/network/interfaces.install /etc/network/interfaces
    systemctl restart networking
fi

# /dev/sda is fully dedicated to pve root
if [ -e /dev/pve/data ]; then
    echo "found /dev/pve/data LVM partition: remove it and extend /dev/pve/root"
    lvresize -l +100%FREE /dev/pve/root
    resize2fs /dev/mapper/pve-root
    sleep 10
fi

# remove enterprise repo and enable the commnunity one's
if [ -f /etc/apt/sources.list.d/pve-enterprise.list ]; then
    echo "Enterprise repository found: disable it and enable commnunity repo"
    rm /etc/apt/sources.list.d/pve-enterprise.list
    rm /etc/apt/sources.list.d/ceph.list
    echo "deb http://download.proxmox.com/debian/pve bookworm pve-no-subscription" >> /etc/apt/sources.list

    echo "Trigger update and full upgrade"
    apt update && apt -y full-upgrade
fi

# remove the subscription NAG uppon login
if [ ! -f /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.bak ]; then
    echo "subscription NAG found, removing it"
    sed -Ezi.bak "s/(Ext.Msg.show\(\{\s+title: gettext\('No valid sub)/void\(\{ \/\/\1/g" /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js
    systemctl restart pveproxy.service
fi

# install saltstack Onedir repo
if [ ! -f  /etc/apt/keyrings/salt-archive-keyring.pgp ]; then
    echo "Saltstack keyring not found, install it"
    wget --tries=3 --waitretry=3 --no-dns-cache --retry-on-host-error -O /etc/apt/keyrings/salt-archive-keyring.pgp https://packages.broadcom.com/artifactory/api/security/keypair/SaltProjectKey/public
fi

if [ ! -f /etc/apt/sources.list.d/salt.sources ]; then
    wget --tries=3 --waitretry=3 --no-dns-cache --retry-on-host-error -O /etc/apt/sources.list.d/salt.sources https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.sources;
    apt update && apt -y install salt-minion
    mkdir -p /etc/salt/minion.d
    echo "master: saltmaster.local.lan" >> /etc/salt/minion.d/master.conf
    echo "startup_states: highstate" >> /etc/salt/minion.d/minion.conf
    systemctl enable --now salt-minion
fi

# create zfs volumes on first nvme disk if not exists
if [ $(zpool list | grep zfs-storage | awk '{print $1}') = "zfs-storage" ]; then
    # do nothing
    echo "zfs-storage found"
else
    echo "no zfs-storage pool found, creating it on /dev/nvme0n1"

    # erase partition table
    dd if=/dev/zero of=/dev/nvme0n1 bs=512 count=1

    #create the pool
    zpool create -o autotrim=on -o ashift=12 zfs-storage /dev/nvme0n1

    # disable access time and sync for better performnances
    zfs set atime=off zfs-storage
    zfs set sync=disabled zfs-storage

    # set compression
    zfs set compression=lz4 zfs-storage

    pvesm add zfspool storage -pool zfs-storage
fi

if [ -f /etc/default/grub.d/installer.cfg ]; then
    rm /etc/default/grub.d/installer.cfg
    update-grub
fi

# enable wake on lan on all physical interfaces
nics=$(ip -pretty link show | \grep enp | awk '{print $2}' | cut -d':' -f 1)
if [ -n "$nics" ]; then
    for interface in $nics
    do
        echo "Enable WOL for $interface"
        ethtool -s $interface wol g
    done
fi

As seen in the first lines, a dedicated /etc/network/interfaces.install is moved to replace the original one. This specific file is copied during the customization of the Proxmox ISO image. This file is very important because it set up the whole network. It is pretty similar on both node: a bridge dedicated to VM and LXC, and another bridge dedicated to the replication (corosync, zfs, etc.).

The interfaces file is like the following:

Click to expand code
##########################################################
#
# Physical interfaces : only used though virtual networks
#
##########################################################
auto lo
iface lo inet loopback

# USB 2.5G nic wired to the other node
auto enx00e04c68030c
iface enx00e04c68030c inet manual

# internal 1GB nic bonded with USB 2.5GB
auto enp2s0
iface enp2s0 inet manual

# 2nd USB NIC bonded with internal NIC
auto enx00e04c680b32
iface enx00e04c680b32 inet manual

##########################################################
#
# link aggregations
#
##########################################################
# main bond to host all vlans
auto bond0
    iface bond0 inet manual
    hwaddress xx:xx:xx:xx:xx:xx
    bond-slaves enp2s0 enx00e04c680b32
    bond-miimon 100
    bond-xmit-hash-policy layer2+3
    bond-mode 802.3ad  # LACP

##########################################################
#
# bridges
#
##########################################################
# bridge to host all vlan + management (untagged vlan 40)
auto vmbr0
iface vmbr0 inet dhcp
    bridge-ports bond0
    bridge-stp off
    bridge-fd 0
    bridge-vlan-aware yes
    bridge-vids 2-4094    # vlan are handled on VM network card
    bridge_ageing 0

# Network for replication on dedicated 2.5G NIC
auto vmbr1
    iface vmbr1 inet static
    address 10.0.0.2/24
    bridge-ports enx00e04c680b32
    bridge-stp off
    bridge-fd 0

The node-1 has a special treatment because it has NL drives for NAS storage. At this stage, the script assumes that NAS drives are already formated (mkfs.ext4 -F -b 4096 /dev/sdx1) because in case of restaging, I dont want to risk any loss of data.

This first rc.local also creates the corosync cluster:

Click to expand code
#### create cluster if it does not exists
if [ ! -f /etc/pve/corosync.conf ]; then
    pvecm create cluster --link0 address=aaa.bbb.ccc.ddd
    echo "migration: secure,network=10.0.0.0/24" >> /etc/pve/datacenter.cfg
fi

#### add qdevice for corosync quorum
if [ ! -f /sbin/corosync-qdevice ]; then
    apt -y install corosync-qdevice
fi

#### setup storages
if [ -z "$(grep 'dir: vz' /etc/pve/storage.cfg)" ]; then
    # dedicated directory on zfs for iso and lxc templates
    zfs create zfs-storage/vz
    pvesm add dir vz --path /zfs-storage/vz --content 'vztmpl,iso,snippets'

    # no content allowed on system disk
    pvesm set local --content ''

    # NL drives for nas and backup
    mkdir -p /nas-storage /nas-mirror
    echo "LABEL=NAS-STORAGE     /nas-storage    ext4    nofail,nodev,nosuid,relatime,noexec,async           0 2" >> /etc/fstab
    echo "LABEL=NAS-MIRROR      /nas-mirror     ext4    nofail,noauto,nodev,nosuid,relatime,noexec,async    0 2" >> /etc/fstab
    systemctl daemon-reload
    mount /nas-storage
    pvesm add dir backup --path /nas-storage/backup --content 'backup' --nodes 'node-1'
fi

Finally, the node-3 has a special treatment too on its rc.local: as this is the host for saltstack master, a dedicated LXC is created to be able to initialize everything else when all nodes are ready, including PGP initilization needed to encrypt and decrypt secrets that are stored on my private saltstack git repository (more details on this subject later).

Click to expand code
#### install corosync-qnetd package for external vote support on the cluster
if [ ! -f /usr/bin/corosync-qnetd ]; then
    apt -y install corosync-qnetd
fi

if [ -z "$(grep 'dir: vz' /etc/pve/storage.cfg)" ]; then
    # only template and iso allowed on system disk
    pvesm set local --content 'vztmpl,iso'
    # zfs-storage host vm and lxc contents
    pvesm set storage --content 'images,rootdir'
fi

#### create saltstack lxc if not exists
if [ "$(pct list | grep saltmaster | awk '{print $1}')" -eq 100 ]; then
    echo "found saltmaster lxc"
else
    pveam update

    # download the needed template
    TEMPLATE=$(pveam available --section system | grep debian-12 | awk '{print $2}')
    pveam download local $TEMPLATE

    # create the container
    pct create 100 /var/lib/vz/template/cache/$TEMPLATE \
    --cores 2 \
    --memory 2048 \
    --storage storage \
    --description "saltstack master for home cloud" \
    --hostname "saltmaster" \
    --onboot 1 \
    --ostype debian \
    --password "SALTMASTER_ROOT_PWD" \
    --swap 0 \
    --features nesting=1 \
    --net0 name=eth0,bridge=vmbr0,ip=dhcp,tag=xx

    sleep 2
    pct start 100

    # prepare and upgrade OS
    pct exec 100 -- bash -c "apt update && apt -y upgrade && apt -y install git gpg sudo"

    # install saltstack onedir repo
    pct exec 100 -- bash -c '\
    wget --tries=3 --waitretry=3 --no-dns-cache --retry-on-host-error -O /etc/apt/keyrings/salt-archive-keyring-2023.gpg https://repo.saltproject.io/salt/py3/debian/12/amd64/SALT-PROJECT-GPG-PUBKEY-2023.gpg
    echo "deb [signed-by=/etc/apt/keyrings/salt-archive-keyring-2023.gpg arch=amd64] https://repo.saltproject.io/salt/py3/debian/12/amd64/latest bookworm main" | tee /etc/apt/sources.list.d/salt.list'

    # install needed packages
    pct exec 100 -- bash -c "apt update && apt -y install salt-master salt-minion"
    pct exec 100 -- bash -c "salt-pip install pyinotify croniter IPy"

    # clone the saltstack IAC git repo
    pct exec 100 -- bash -c "\
    rm -Rf /srv/*
    git clone https://XXX:SALTMASTER_GIT_PWD/XXX/saltstack /srv"

    # configure saltmaster
    pct exec 100 -- bash -c '\
    mkdir -p /etc/salt/master.d
    echo "cli_summary: True" >> /etc/salt/master.d/saltmaster.conf
    echo "auto_accept: True" >> /etc/salt/master.d/saltmaster.conf
    echo "state_output: changes" >> /etc/salt/master.d/saltmaster.conf
    systemctl enable --now salt-master'

    sleep 5

    # configure saltminion
    pct exec 100 -- bash -c '\
    mkdir -p /etc/salt/minion.d
    echo "master: saltmaster.local.lan" >> /etc/salt/minion.d/minion.conf
    echo "startup_states: highstate" >> /etc/salt/minion.d/minion.conf
    systemctl enable salt-minion'

     # configure gpg
    pct exec 100 -- bash -c 'echo "SALTMASTER_GPG_PUB" > /tmp/pubkey.asc'
    pct exec 100 -- bash -c 'echo "SALTMASTER_GPG_PRIV" > /tmp/privkey.secret.b64'
    pct exec 100 -- bash -c '\
        mkdir -p /etc/salt/gpgkeys
        chmod 0700 /etc/salt/gpgkeys
        echo 'homedir /etc/salt/gpgkeys' >> /root/.gnupg
        base64 -d /tmp/privkey.secret.b64 > /tmp/privkey.secret
        gpg --homedir /etc/salt/gpgkeys --import /tmp/privkey.secret
        gpg --homedir /etc/salt/gpgkeys --import /tmp/pubkey.asc
        echo 'gpg_keydir: /etc/salt/gpgkeys' >/etc/salt/master.d/gpg-pillar.conf
        chown -R salt:salt /etc/gpgkeys
        rm /tmp/privkey.*
        rm /tmp/pubkey.*'

    pct reboot 100

    # reload salt-minion to register correctly
    systemctl restart salt-minion
fi

Words in upper case like “SALTMASTER_ROOT_PWD” are injected by my custom build script during the ISO creation. As all rc.local files got deleted after the first connection to the saltmaster, I do not consider that as a big security concern.

The git repository used is a private one, which serves for GitOps via saltstack (more on that later).

The only remaining manual operations are listed below. After these actions, everything is set up and ready (including a fully functionnal k3s cluster with apps, again: more on that later):

  • reboot all nodes to unsure that all configurations are taken into account

  • adding node-2 to the cluster : it needs root password thus I can’t provide it in rc.local

    pvecm add node-1.local.lan --link0 address=aaa.bbb.ccc.ddd
    
  • add the qdevice on node-3 :

    pvecm qdevice setup aaa.bbb.ccc.ddd
    
  • apply saltstack map states (iac_backend.host-map.sls) : can’t do it before the cluster is created

  • set saltmaster not to auto accept minions (auto_accept: false)

Saltstack + Git as IAC frontend

Concept

As seen previously, the salt master is an LXC container that is automatically configured during the very first boot of node-3 (the Liva Z3). This makes it fully reproducible, eliminating the need to rely on backups. The goal is to quickly set up a salt master from scratch and use it to configure everything else, including the salt master itself.

Basically, the concept is based on the following principles:

  • A private Git repository contains all state and pillar definitions
  • The repository is cloned regularly on the salt master LXC.
  • Any file change in the cloned repository triggers a highstate application on all registered minions.
  • Any new minion automatically applies a highstate upon registration.
  • A highstate is periodically applied to all minions.

This way, SaltStack acts as an Infrastructure as Code (IaC) frontend via Git: any push triggers changes in the infrastructure, removing the need for manual shell commands or direct connections to any server—whether it is an LXC, a virtual machine, or a Proxmox node.

Additionally, any new server is automatically configured, allowing me to rebuild parts or even the entire infrastructure with a simple salt '*' state.apply command.

Implementation

To implement the GitOps approach, one possible solution could have been to use GitFS to host SaltStack’s files. However, since I do not plan to make changes directly on the salt master (which, in my opinion, is an anti-pattern), I adopted a KISS approach: : a simple scheduled git pull task combined with an inotify-based state to apply any modifications.

To achieve this, the salt master has a scheduled task that pulls the repository every 2 minutes and a reactor configuration together with an inotify beacon.

refresh_saltstack_repo:
    schedule.present:
        - function: state.apply
        - job_args:
            - iac_frontend.repository
        - cron: '*/2 * * * *'
        - enabled: True

salt-master:
    file.managed:
        - names:
            - /etc/salt/master.d/reactor.conf:
                - source: salt://iac_frontend/files/reactor.conf
    service.running:
        - watch:
            - file: /etc/salt/master.d/reactor.conf

salt_states_changed:
    beacon.present:
        - save: True
        - enable: True
        - interval: 2
        - beacon_module: inotify
        - disable_during_state_run: False
        - files:
            /srv:
                mask:
                    - create
                    - moved_to
                recurse: True
                auto_add: True
                exclude:
                    - /srv/salt/reactor

The repository pull state is like the following (url and credentials are stored on pillar values, more on that later):

saltstack_repo:
    git.latest:
        - name: https://path_to_git/repo
        - target: /srv
        - https_user: user
        - https_pass: password

The “reactor.conf” file defines a state to be executed on each detected change:

- 'salt/beacon/*/salt_states_changed/*':
        - salt://reactor/handle_changed_states.sls

And here is the “handle_changed_states.sls” content which simply applies hightstate on all registered minions

apply_highstate:
  salt.state:
        - tgt: '*'
        - highstate: True

As explained earlier, with such a simple mecanism, any push to the git repository triggers any infrastructure and configuration changes, no need to login into the saltmaster nor any server. Of course any error will also be deployed very fast : “with great power come great responsibilities” :)

Secured secrets

Even though my SaltStack repository is private, it is still hosted on external cloud servers. Since the entire architecture relies on secrets such as passwords or private keys, I needed to set up a secure way to store this kind of information.

My setup follows SaltStack’s approach, which consists of managing secrets with pillar values for the storage and GnuPG for encryption

As previously mentioned, the custom rc.local file of node-3, executed during the first boot, initializes the GnuPG environment and injects the key pair (which I store in a private local location).

Thus, the SaltMaster LXC contains everything needed to encrypt a new secret with a command like:

echo -n 'value to encrypt' | gpg --homedir /etc/salt/gpgkeys --trust-model always -ear <MY KEY-ID>

The output is an encrypted string that can be used as saltstack pillar:

#!yaml|gpg

standard_value: not encrypted
encrypted_vlue: |
  -----BEGIN PGP MESSAGE-----

  PLb8RzQsA+XVp8SqaB/h2IsbSlwxC5auXxkJtQiZfeSJPVINAXIlT8F6KDRO5Aqe
  HaV2577PsEVRNeY9mMxPe0KVpuV3mPYL+2lpemEtwpJYDP1kByKMDiXt66sbyCNp
  v9lGMZI9ZnBsdGgLisZwDdaS0Vs+4MniIbw==
  -----END PGP MESSAGE-----

Saltstack repository architecture

I tried to follow the best pratices : pillar contains variables and customized values while states are mostly generics and depend on pillar’s values.

Bellow is a commented overview of the saltstack directory structure. Each element is detailled later.

level 1 level 2 comments
pillar
k3s settings to deploy the k3s cluster
mapsdefine all virtual machines and LXC specifications for all nodes
servicessettings for specific services like reverse proxy (nginx)
usersdefines users and groups that should exists or be deleted
zabbixsettings dedicated to zabbix states
git.slssettings for my saltstack git repository
iac_backend.slsdefine sysctl values and custom scripts for all proxmox nodes
kubeapps.slsdefine applications that must be deployed in the k3s cluster
mail.slssettings for e-mail account and aliases for servers to be able to send e-mails
top.sls
salt
iac_backend dedicated to proxmox backend deployments
iac_frontenddedicated to saltstack and gitops
kubeappsstates that deploy applications in k3s
reactordeploy reactor configuration, essentialy for the gitops approach
servicesdeploy services as defined in states
sysadminstates dedicated to apply a standard configuration all servers, being LXC or VM
usersensure that users and groups exist or are absents
top.sls

Proxmox nodes as IAC_backend

Proxmox customization

I did some adjustements on Proxmox nodes either to “optimize” things for my modest hardware, to reduce power consumption or simply to install my custom scripts

The table below show the optimization related settings.

What How
Allow swap usage only when less than 1% free RAM. This preserve my SSD set vm.swappiness to 1
disable ipv6 as I don’t use it set net.ipv6.conf.all.disable_ipv6 to 1
Ensure there is at least 256Mb free in order to always be able to execute sysadmin tools (ssh, screen, netstate, etc.) set vm.min_free_kbytes to 262144
Better I/O multitasking performances by limitting the size of writebacks (ram cache to disk) Tset vm.dirty_ratio to 20

These settings are applied through states and pillar values.

Pillar are defined like the follwing:

Click to expand code
iac_backend:
# custom scripts to install to /usr/loc/bin
  framework:
    - set_lxc.sh
    - set_vm.sh
    - set_templates.sh
    - set_common.sh
    - set_zfspv.sh

# sysctl settings to set
  sysctl:
    vm.swappiness: 1
    net.ipv6.conf.all.disable_ipv6: 1
    vm.min_free_kbytes: 262144
    vm.dirty_ratio: 20

And states applied to Promox nodes (more on udev rules in the next chapter):

Click to expand code
##############################################################
# Deploy framework to handle IAC on Proxmox:
# - define cluster hosts in /etc/hosts
# - install requiered packages
# - set a new rc.local to remove the one used during first boot
# - install all custom script used to manage operations
# - tune some parameters for better performances
# - deploy a set of udevrules for better power management
##############################################################

proxmox-cluster-hosts:
  file.managed:
    - name: /etc/hosts
    - source: salt://iac_backend/files/hosts
    - user: root
    - group: root
    - mode: 644

proxmox_packages:
  pkg.installed:
    - pkgs:
      - libguestfs-tools

proxmox-rc-local:
  file.managed:
    - name: /etc/rc.local
    - source: salt://iac_backend/files/rc.local
    - user: root
    - group: root
    - mode: 750

{% if salt['pillar.get']('iac_backend:framework', none) is not none %}
proxmox_scripts:
  file.managed:
    - names: {% for file in pillar['iac_backend']['framework'] %}
        - /usr/local/bin/{{ file }}:
            - source: salt://iac_backend/files/{{ file }}
            - mode: 750
             {%- endfor -%}
{% endif %}

{% if salt['pillar.get']('iac_backend:sysctl', none) is not none %}
proxmox_sysctl_settings:
  sysctl.present:
    - names: {% for setting in pillar['iac_backend']['sysctl'] %}
      - {{ setting }}:
        - value: {{ pillar['iac_backend']['sysctl'][setting] }}
             {%- endfor -%}
{% endif %}

proxmox_udev:
  cmd.run:
    - name: udevadm control --reload-rules
    - onchanges:
      - file: proxmox_udev_rules

proxmox_udev_rules:
  file.managed:
    - names:
      - /etc/udev/rules.d/99-powermgmt.rules:
        - source: salt://iac_backend/files/99-powermgmt.rules
        - mode: 640

Power consumption

One major advantage of my old ARM-based home lab solution was its low power consumption: it was around 60W, including my 24-port switch.

Switching to x86 required some BIOS tuning (see the beginning of this article), even with low-power CPUs. Unfortunately, I wasn’t able to achieve a huge reduction in power consumption. The initial power draw of the N100 nodes was about 19 watts. After applying BIOS tweaks and Linux adjustments, I managed to reduce it to 17.5W.

Here are my “low-power” udev rules, installed in /etc/udev/rules.d/99-powermgmt.rules:

ACTION=="add", SUBSYSTEM=="pci", ATTR{power/control}="auto"
ACTION=="add", SUBSYSTEM=="ahci", ATTR{power/control}="auto"
ACTION=="add", SUBSYSTEM=="scsi_host", KERNEL=="host*", ATTR{link_power_management_policy}="min_power"
ACTION=="add", SUBSYSTEM=="usb", ATTR{power/autosuspend_delay_ms}="1000"
ACTION=="add", SUBSYSTEM=="usb", ATTR{power/control}="auto"
ACTION=="add", SUBSYSTEM=="scsi", ATTR{power/control}="auto"
ACTION=="add", SUBSYSTEM=="acpi", ATTR{power/control}="auto"
ACTION=="add", SUBSYSTEM=="block", ATTR{power/control}="auto"
ACTION=="add", SUBSYSTEM=="workqueue", ATTR{power/control}="auto"
ACTION=="add", SUBSYSTEM=="i2c", ATTR{power/control}="auto"

With everything running, including the NL drives, ethernet switch, pfSense box, and rack cooling fans, I measured between 68W and 75W total. I found this reasonable: it’s a 25% increase in power consumption compared to my old ARM cluster, but with more than 25% performance gains. In the end, this translates to about 150 euros per year, which is much cheaper than a similar cloud service.

Desired state configuration

As mentioned earlier, I use Proxmox as my IAC backend. To have a system that allows defining LXC containers and virtual machines in a desired state configuration style, I had to write some scripts.

I should have used salt-cloud, but at the time of writing, the current Proxmox extension isn’t very useful: it lacks reliable error reporting and is not yet officially integrated.

Terraform or OpenTofu could have been good candidates, but they are too complex for a simple infrastructure like mine.

My approach is based on VMs, templates, and LXC definitions through pillar values, with deployment managed via states and custom scripts to handle both creation and modification.

I wrote four scripts for VM and LXC creation/update. Their outputs follow SaltStack’s stateful script requirements.

set_common.sh: Common tools and definitions for all scripts.

Click to expand code
#!/bin/bash

### COMMON VALUES ###########################
DEBIAN12_CMD_INSTALL_SALTMINION=('\
        wget --tries=3 --waitretry=3 --no-dns-cache --retry-on-host-error -O /etc/apt/keyrings/salt-archive-keyring.pgp https://packages.broadcom.com/artifactory/api/security/keypair/SaltProjectKey/public; \
        wget --tries=3 --waitretry=3 --no-dns-cache --retry-on-host-error -O /etc/apt/sources.list.d/salt.sources https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.sources; \
        apt -qq update > /dev/null'
)

DEBIAN12_CMD_CONFIG_SALTMINION=('\
        mkdir -p /etc/salt/minion.d; \
        echo "master: saltmaster.local.lan" >> /etc/salt/minion.d/minion.conf; \
        systemctl enable salt-minion'
)

### GLOBAL VARIABLES ###########################
CHANGED="no"
COMMENT=""
REPORT=""
SPECIAL_ARGS=""
MANDATORY=""
declare -A arguments
declare -A specials

### FUNCTIONS ##################################

# print a message that can be interpreted by saltstack cmd.script state
# $1=exit code
ExitMessage()
{
    echo
    echo $REPORT
    echo "changed=$CHANGED comment='$COMMENT'"
    exit $1
}

# populate the arguments array waiting for key=value pairs
# $1 are key exceptions that are mapped in $specials array
# $2...$x all argumnts to parse
ParseParameters()
{
    # read parameters and store them in a hashmap
    for arg in "$@"
    do
        # separate argumente name and value (expecting name=value)
        name=${arg%%=*}
        value=${arg#*=}

        # extract special parameter for future usage
        if [[ $SPECIAL_ARGS == *"$name"*  ]]; then
            specials[$name]=$value
        else
            # drop description as it causes problem to pass it to cli commands (quotes)
            if [[ $name != 'description' ]]; then
                arguments[$name]=$value
            fi
        fi
    done
}

# check that mandatory parameters are set
CheckMandatory()
{
    for key in $MANDATORY
    do
        if [[ -z ${arguments[$key]}  ]] && [[ -z ${specials[$key]} ]] ; then
            COMMENT="'$key' parameter is mandatory"
            ExitMessage 2
        fi
    done

}

# parse a config output from proxmox cli command (qm config or pct config)
# Call ConfigValue() function (defined in host script) for each parsed value.
#
# $1 = config string
Parseconfig()
{
    OIFS="$IFS"
    IFS=$'\n'

    for setting in $1
    do
        name=${setting%%:*}
        value=${setting#*': '}

        ConfigValue "$name" "$value"
    done
    IFS="$OIFS"
}

set_lxc.sh: handle LXC creation and update

Click to expand code
#!/bin/bash

source /usr/local/bin/set_common.sh

### GLOBAL VARIABLES ###########################
CREATE=""
OSVER=""
SPECIAL_ARGS="osver"
MANDATORY="id"

### FUNCTIONS ##################################
Help()
{
   # Display Help
   echo
   echo "Unsure a proxmox container exists as defined."
   echo "Either create it, modify it or do nothing"
   echo
   echo "Syntax: $0 parameter1=value1 [paramter2=value2 [...] ]"
   echo "Parameters and values are passed directly to 'pct'commands."
   echo
   echo There is one special parameter for creation:
   echo "  osver: defines the version of 'ostype'. E.g: 12, base_20240911"
   echo
   echo IMPORTANTS NOTES:
   echo   - description parameter is always dropped
   echo   - rootfs parameter is ignored if container esists
   echo
}

# compare given value and set new config if changed
# $1 = name
# $2 = value
ConfigValue()
{
    # call pct set if anything changed, but for rootfs setting
    if [[ ! -z ${arguments[$1]} ]] && [[ ${arguments[$1]} != $2 ]] && [[ $1 != "rootfs" ]] ; then
        pct set ${arguments['id']} --$1 "${arguments[$1]}"
        CHANGED="yes"
        REPORT="$REPORT $1='${arguments[$1]}'"
    fi

    if [[ $CHANGED == "yes" ]]; then
        COMMENT="Container ${arguments[id]} updated"
    else
        COMMENT="Container ${arguments[id]} already configured"
    fi
}

### SCRIPT LOGIC ################################

# Display help if needed
if [[ ($# -lt 1) || $1 == "help" ]] ; then
    Help
    exit 2
fi

ParseParameters "$@"
CheckMandatory

# check if container exists
if [[ "$(pct list | grep ${arguments['id']} | awk '{print $1}')" -eq ${arguments['id']} ]]; then
    Parseconfig "$(pct config ${arguments['id']})"

# when container does not exists we create it
else
    # to create a container, we need osver (e.g. 12) and ostype (e.g. debian) to find the required template (e.g. debian-12)
    if [[ -z ${specials['osver']} ]] || [[ -z ${arguments['ostype']} ]] ; then
        COMMENT="osver and ostype parameters are mandatory for non existing container"
        ExitMessage 2
    fi

    # build 'pct create' arguments as well as saltstack report for cmd.script state
    for param in ${!arguments[@]}
    do
        if [[ $param != 'id' ]]; then
            CREATE="$CREATE --$param ${arguments[$param]}"
            REPORT="$REPORT $param='${arguments[$param]}'"
        fi
    done

    COMMENT="Container ${arguments['id']} created with"

    # get template filename or download it if not found
    TEMPLATE=$(pveam available --section system | grep "${arguments['ostype']}-${specials['osver']}" | awk '{print $2}')

    TEMPLATE_PATH="/var/lib/vz/template/cache"
    TEMPLATE_STORAGE_NAME="local"
    # define wether to use default local storage of zfs if available
    if [[ -d "/zfs-storage/vz" ]]; then
        TEMPLATE_PATH="/zfs-storage/vz/template/cache"
        TEMPLATE_STORAGE_NAME="vz"
    fi

    if [[ ! -f "$TEMPLATE_PATH/$TEMPLATE" ]]; then
        pveam download $TEMPLATE_STORAGE_NAME $TEMPLATE > /dev/null
        COMMENT="$COMMENT new template: $TEMPLATE"
    else
        COMMENT="$COMMENT existing template"
    fi

    # create container
    pct create ${arguments['id']} $TEMPLATE_PATH/$TEMPLATE $CREATE > /dev/null

    # if container has been created, install salt-minion (depending on OS)
    if [[ $? -eq 0 ]]; then
        CHANGED="yes"

        if [[ "${arguments['ostype']}-${specials['osver']}" == "debian-12" ]]; then

            # set salt-minion requirements
            pct exec ${arguments['id']} -- bash -c "${DEBIAN12_CMD_INSTALL_SALTMINION[@]}"
            # upgrade and install needed packages
            pct exec ${arguments['id']} -- bash -c "apt -yqq upgrade > /dev/null && apt -yqq install salt-minion > /dev/null"
            # configure saltminion
            pct exec ${arguments['id']} -- bash -c "${DEBIAN12_CMD_CONFIG_SALTMINION[@]}"
            # reboot container to make it ready
            pct reboot ${arguments['id']}

            COMMENT="$COMMENT. Salt-minion installed"
        fi
    else
        exit 1
    fi

fi

# print saltstack readble message then exit with success
ExitMessage 0

set_templates.sh: handle Proxmox VM templates

Click to expand code
#!/bin/bash

source /usr/local/bin/set_common.sh

### GLOBAL VARIABLES ###########################
MANDATORY="id name image"

### FUNCTIONS ##################################
Help()
{
   # Display Help
   echo
   echo "Unsure a VM Template is present."
   echo "Either create it or do nothing"
   echo
   echo "Syntax: $0 id=<template id> name=<template name> image=<http link to cloudinit image> [ param=value ]"
   echo "Where param=value can be any "qm set" / "wm create" attributes"
   echo
   echo "NOTE: Created template will have guest-agent and salt-minion installed"
   echo
}

### SCRIPT LOGIC ################################

# Display help if needed
if [[ ($# -lt 3) || $1 == "help" ]] ; then
    Help
    exit 2
fi

ParseParameters "$@"
CheckMandatory

cd /root

# check if template exists
if [[ "$(qm list | grep ${arguments['id']} | awk '{print $1}')" -eq ${arguments['id']} ]]; then
    COMMENT="Image with ID ${arguments['id']} already exists"
    ExitMessage 0
fi

# download image
wget --tries=3 --waitretry=3 --no-dns-cache --retry-on-host-error -nv -c -O "temp.img" ${arguments['image']}
if [[ ! $? -eq 0 ]]; then
    COMMENT="Unable to download image ${arguments['image']}"
    ExitMessage 1
else
    COMMENT="New template created with ID ${arguments['id']} (${arguments['name']})"
fi

# install saltstack on supported images
if [[ ${arguments['image']} == *"debian-12"* ]]; then
    virt-customize -a temp.img --run-command "${DEBIAN12_CMD_INSTALL_SALTMINION[@]}"

    virt-customize -a temp.img --install salt-minion
    if [[ ! $? -eq 0 ]]; then
        COMMENT="Unable to install salt-minion on debian-12 image"
        ExitMessage 1
    fi

    virt-customize -a temp.img --run-command "salt-pip install croniter"
    virt-customize -a temp.img --run-command "${DEBIAN12_CMD_CONFIG_SALTMINION[@]}"

    COMMENT="$COMMENT. Salt-minion installed"
fi

# install guest agent
virt-customize -a temp.img --install qemu-guest-agent
virt-customize -a temp.img --run-command 'systemctl enable qemu-guest-agent'
if [[ ! $? -eq 0 ]]; then
    COMMENT="Unable to install qemu-guest-agent"
    ExitMessage 1
fi

# generate the template
qm create ${arguments['id']} --memory 2048 --core 2 --name ${arguments['name']} --net0 virtio,bridge=vmbr0
qm importdisk ${arguments['id']} temp.img storage > /dev/null
qm set ${arguments['id']}   --scsihw virtio-scsi-pci \
                            --scsi0 storage:vm-${arguments['id']}-disk-0 \
                            --ide2 storage:cloudinit \
                            --boot c --bootdisk scsi0 \
                            --serial0 socket --vga serial0 \
                            --agent enabled=1

qm template ${arguments['id']}
rm /root/temp.img

CHANGED="yes"
ExitMessage 0

set_vm.sh: handle virtual machines creation and update

Click to expand code
#!/bin/bash

source /usr/local/bin/set_common.sh

### GLOBAL VARIABLES ###########################
SPECIAL_ARGS="sshkey id name template start"
MANDATORY="id name template"

### FUNCTIONS ##################################
Help()
{
   # Display Help
   echo
   echo "Unsure a proxmox VM exists as defined."
   echo "Either create it, modify it or do nothing"
   echo
   echo "Syntax: $0 id=<id> name=<name> template=<id> [start=<0 | 1>] parameter1=value1 [paramter2=value2 [...] ]"
   echo "Parameters and values are passed directly to 'qm' commands."
   echo
   echo "IMPORTANTS NOTES:"
   echo "  - description parameter is always dropped"
   echo "  - sshkey is ignored and always defaulted to ~/.ssh/id_rsa.pub"
   echo
}

# compare given value and set new config if changed
# $1 = name
# $2 = value
ConfigValue()
{
    # call pct set if anything changed, but for rootfs setting
    if [[ ! -z ${arguments[$1]} ]] && [[ ${arguments[$1]} != $2 ]] ; then
        # special handling for scsi0: only size is changeable
        if [[ $1 == "scsi0" ]]; then
            disksize=$(echo "${arguments[$1]}" | \grep -oP '(?<=size\=).*(?=G)')
            qm resize ${specials['id']} scsi0 "${disksize}G"
        else
            qm set ${specials['id']} --$1 "${arguments[$1]}"
        fi
        CHANGED="yes"
        REPORT="$REPORT $1='${arguments[$1]}'"
    fi

    if [[ $CHANGED == "yes" ]]; then
        COMMENT="VM ${specials[id]} updated"
    else
        COMMENT="VM ${specials[id]} already configured"
    fi
}

### SCRIPT LOGIC ################################

# Display help if needed
if [[ ($# -lt 3) || $1 == "help" ]] ; then
    Help
    exit 2
fi

ParseParameters "$@"
CheckMandatory

# check if vm exists
if [[ "$(qm list | grep ${specials['id']} | awk '{print $1}')" -eq ${specials['id']} ]]; then
    Parseconfig "$(qm config ${specials['id']})"
else
    # clone template
    qm clone ${specials['template']} ${specials['id']} --name ${specials['name']}
    if [[ ! $? -eq 0 ]]; then
        COMMENT="Error cloning template"
        ExitMessage 1
    fi
    CHANGED="yes"

    # set ssh key
    qm set ${specials['id']} --sshkey ~/.ssh/id_rsa.pub

    # apply all setings from argumemts
    for param in ${!arguments[@]}
    do
        if [[ $param == "scsi0" ]]; then
            disksize=$(echo "${arguments[$param]}" | \grep -oP '(?<=size\=).*(?=G)')
            qm resize ${specials['id']} scsi0 "${disksize}G"
        else
            qm set ${specials['id']} --$param ${arguments[$param]}
        fi
        REPORT="$REPORT $param='${arguments[$param]}'"
    done

    if [[ ! -z ${specials['start']} ]] && [[ ${specials['start']} -eq 1 ]] ; then
        qm start ${specials['id']}
        if [[ $? -eq 0 ]]; then
            COMMENT="$COMMENT. VM started"
        else
            COMMENT="$COMMENT. Error trying to start the VM"
        fi
    fi
fi

# print saltstack readble message then exit with success
ExitMessage 0

Below is how I defined templates, LXC and VM as pillar values to create and/or update them:

Click to expand code
templates:
  tpl-debian-12: >
    id=9200
    name=debian-12
    image=https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2

map:
  lxc-goldorak:
    password: |
      -----BEGIN PGP MESSAGE-----

      [...]
      -----END PGP MESSAGE-----
    settings: >
      id=300
      hostname=goldorak
      cores=2
      memory=1024
      rootfs=storage:6
      net0=name=eth0,bridge=vmbr0,ip=dhcp,tag=2,type=veth
      ostype=debian
      osver=12
      onboot=1
      swap=0
      features=nesting=1
      start=1

  vm-k3s-master-1:
    password: |
      -----BEGIN PGP MESSAGE-----

      [...]
      -----END PGP MESSAGE-----
    settings: >
      id=1200
      name=k3s-master-1
      template=9200
      cores=4
      scsi0=storage:base-9200-disk-0/vm-1200-disk-0,size=20G
      memory=12288
      net0=model=virtio,bridge=vmbr0,tag=3
      ipconfig0=ip=dhcp
      searchdomain=local.lan
      onboot=1
      start=1

Now, the part of the states that handle these pillar values:

Click to expand code
# handle VM templates
{% if pillar.get('templates', none) is not none %}
{% for id in pillar['templates'] %}
{{ id }}:
    cmd.script:
        - name: /usr/local/bin/set_templates.sh
        - stateful: True
        - require:
            - file: proxmox_scripts
            - pkg: proxmox_packages
        - args: {{ pillar['templates'][id] }}
{% endfor %}
{% endif %}

# handle VM and LXC creations and updates
{% if pillar.get('map', none) is not none %}
{% for id in pillar['map'] %}
{% if id.startswith('lxc')      %}
{%   set password='password='   %}
{%   set name='set_lxc.sh'      %}
{% elif id.startswith('vm')     %}
{%   set password='cipassword=' %}
{%   set name='set_vm.sh'       %}
{% endif %}
{% set password=password~pillar['map'][id]['password'] %}
{{ id }}:
    cmd.script:
        - name: /usr/local/bin/{{ name }}
        - stateful: True
        - require:
            - file: proxmox_scripts
            - pkg: proxmox_packages
        - args: >
            {{ password }}
            {{ pillar['map'][id]['settings'] }}
{% endfor %}
{% endif %}

User management

My user management needs are pretty basic: system accounts and Samba shares. A simpler alternative to LDAP for my home lab is using states and minion values to handle all operations (create, modify, delete) and synchronize them across all hosts.

Example of user definitions in the pillar:

Click to expand code
#!yaml|gpg

# if defined AND set to true, this setting is used to trigger
# samba password handling. Else, only system password are set
pdbedit: True

revokedusers:
  - debian

users:
  test:
    fullname: full test
    uid: 1000
    shell: /bin/bash
    ssh-keys:
      - ssh-ed25519 [...] test@my_computer
    groups:
      - adm
      - plugdev
      - sudo
      - staff
      - my_group
    password: |
      -----BEGIN PGP MESSAGE-----

      [...]
      -----END PGP MESSAGE-----

groups:
  my_group:
    gid: 1003

States to handle users on all nodes:

Click to expand code
##############################################################
# Handle users on all managed hosts
# - Remove users present in 'revokedusers' pillar (including ssh key)
# - Add groups present in 'groups' pillar
# - Add users present in 'users' pillar (including ssh key)
# - Handle samba passwords if needed ('pdbedit' pillar)
##############################################################

# delete users and groups that have been revoked
# unsure user is no more referenced
{% if pillar.get('revokedusers', none) is not none %}
{% for user in pillar['revokedusers'] %}
{{user}}:
  user.absent: []
  group.absent: []

# unsure user ssh key is no more used by root
{% if salt['pillar.get']('revokedusers:ssh-keys', none) is not none %}
{{user}}_root_key:
  ssh_auth.absent:
    - user: root
    - names:
      {% for key in pillar['revokedusers']['ssh-keys'] %}
      - {{ key }}
      {% endfor %}

# unsure user ssh key is no more used for this user
{{user}}_key:
  ssh_auth.absent:
    - user: {{user}}
    - names:
      {% for key in pillar['revokedusers']['ssh-keys'] %}
      - {{ key }}
      {% endfor %}
{% endif %}
{% endfor %}
{% endif %}

# set groups that must be defined
{% if pillar.get('groups', none) is not none %}
{% for group in pillar['groups'] %}
{{ group }}:
  group.present:
    - gid: {{ pillar['groups'][group]['gid'] }}
{% endfor %}
{% endif %}

# Set users that must be defined
{% if pillar.get('users', none) is not none %}
{% for user in pillar['users'] %}
{{ user }}:
  group.present:
      - gid: {{ pillar['users'][user]['uid'] }}

  user.present:
    - fullname: {{ pillar['users'][user]['fullname'] }}
    - uid: {{ pillar['users'][user]['uid'] }}
    - gid: {{ pillar['users'][user]['uid'] }}
    - shell: {{ pillar['users'][user]['shell'] }}
    - password: {{ pillar['users'][user]['password'] }}
    - hash_password: True
    {% if salt['pillar.get']('users:' + user + ':groups', none) is not none -%}
    - groups:
      {% for group in pillar['users'][user]['groups'] -%}
      - {{ group }}
      {% endfor %}
    {% endif %}

{% if pillar.get('pdbedit', none) is not none %}
  pdbedit.managed:
    - password: {{ pillar['users'][user]['password'] }}
{% endif %}

{% if salt['pillar.get']('users:' + user + ':ssh-keys', none) is not none -%}
{{user}}_root_key:
  ssh_auth.present:
    - user: root
    - names:
      {% for key in pillar['users'][user]['ssh-keys'] -%}
      - {{ key }}
      {% endfor %}

{{user}}_key:
  ssh_auth.present:
    - user: {{user}}
    - names:
      {% for key in pillar['users'][user]['ssh-keys'] -%}
      - {{ key }}
      {% endfor %}
{% endif %}

{% endfor %}
{% endif %}

Zabbix as monitoring solution

I chose Zabbix to monitor all components of my home cloud. I found it easy to set up, yet very powerful, with many useful monitors by default.

I created a single state for the installation process, both for the server and all agents, depending on pillar values. I also had to write a small script to handle PostgreSQL database creation.

Example of pillar values:

Click to expand code
#!yaml|gpg

zabbix-common:
  server: 'monitor.local.lan'
  repo: ' https://repo.zabbix.com/zabbix/7.0/debian/pool/main/z/zabbix-release/zabbix-release_latest+debian12_all.deb'

zabbix-server:
  db_name: 'zabbix'
  db_user: 'zabbix'
  db_script: '/usr/share/zabbix-sql-scripts/postgresql/server.sql.gz'
  db_password: |
    -----BEGIN PGP MESSAGE-----

    [...]
    -----END PGP MESSAGE-----

States that install Zabbix:

Click to expand code
##############################################################
# Deploy Zabbix server or agent regarding zabbix-* pillars.
# ALL:
# - install the repo
# - ensure all required services are running
#
# SERVER:
# - Init PostgreSQL database
# - install all needed packages
# - customize configuration files with db, user and password values
# - set nginx to use servername defined in 'server' pillar and to listen to port 80
#
# AGENT:
# - Deploy customized configuration files with server fqdn (or 127.0.0.1 on zabbix server)
#
##############################################################

# zabbix repository
zabbix-repo-install:
  cmd.run:
    - name: "wget -O /tmp/zabbix-release.deb {{ pillar['zabbix-common']['repo'] }} && dpkg -i /tmp/zabbix-release.deb && apt-get update"
    - unless: 'dpkg -l | grep zabbix-release'

# zabbix server
{% if pillar.get('zabbix-server', none) is not none %}

zabbix-set-locale:
  file.line:
    - name: /etc/locale.gen
    - match: 'en_US.UTF-8 UTF-8'
    - mode: replace
    - content: 'en_US.UTF-8 UTF-8'
  cmd.run:
    - name: locale-gen

zabbix-set-default-locale:
  file.line:
    - name: /etc/default/locale
    - match: 'LANG='
    - mode: replace
    - content: 'LANG=en_US.UTF-8'

zabbix-set_pgsql:
  file.managed:
    - name: /usr/local/bin/set_pgsql.sh
    - source: salt://services/files/set_pgsql.sh
    - user: root
    - group: root
    - mode: 750

zabbix-server-install:
  pkg.installed:
    - pkgs:
      - zabbix-server-pgsql
      - zabbix-frontend-php
      - php8.2-pgsql
      - zabbix-nginx-conf
      - zabbix-sql-scripts
      - postgresql-all
    - require:
      - cmd: zabbix-repo-install

zabbix-postgresql:
  service.running:
    - name: postgresql
    - enable: True
    - require:
      - pkg: zabbix-server-install

zabbix-db-user-creation:
  cmd.script:
    - name: /usr/local/bin/set_pgsql.sh
    - stateful: True
    - require:
        - file: zabbix-set_pgsql
        - pkg: zabbix-server-install
    - args: {{ pillar['zabbix-server']['db_name'] }} {{ pillar['zabbix-server']['db_user'] }} {{ pillar['zabbix-server']['db_password'] }} {{ pillar['zabbix-server']['db_script'] }}

/etc/zabbix/zabbix_server.conf:
  file.managed:
    - source: salt://services/files/zabbix_server.conf
    - template: jinja
    - context:
      DBPassword: {{ pillar['zabbix-server']['db_password'] }}
      DBName: {{ pillar['zabbix-server']['db_name'] }}
      DBUser: {{ pillar['zabbix-server']['db_user'] }}
    - require:
        - pkg: zabbix-server-install

/etc/zabbix/web/zabbix.conf.php:
  file.managed:
    - source: salt://services/files/zabbix.conf.php
    - template: jinja
    - context:
      DBPassword: {{ pillar['zabbix-server']['db_password'] }}
      DBName: {{ pillar['zabbix-server']['db_name'] }}
      DBUser: {{ pillar['zabbix-server']['db_user'] }}
    - user: www-data
    - group: www-data
    - mode: 600
    - require:
        - pkg: zabbix-server-install

zabbix-nginx.conf-port:
  file.line:
    - name: /etc/zabbix/nginx.conf
    - match: '8080'
    - mode: replace
    - content: 'listen 80;'
    - require:
        - pkg: zabbix-server-install

zabbix-nginx.conf-host:
  file.line:
    - name: /etc/zabbix/nginx.conf
    - match: 'example.com'
    - mode: replace
    - content: 'server_name {{ pillar['zabbix-common']['server'] }};'
    - require:
        - pkg: zabbix-server-install

zabbix-nginx-default-config:
  file.absent:
    - name: /etc/nginx/sites-enabled/default
    - require:
      - pkg: zabbix-server-install

zabbix-server:
  service.running:
    - enable: True
    - require:
      - pkg: zabbix-server-install

zabbix-server-nginx:
  service.running:
    - name: nginx
    - enable: True
    - require:
      - pkg: zabbix-server-install

zabbix-server-php:
  service.running:
    - name: php8.2-fpm
    - enable: True
    - require:
      - pkg: zabbix-server-install
{% endif %}

# zabbix agent
{% if pillar.get('zabbix-common', none) is not none %}
zabbix-agent-install:
  pkg.installed:
    - pkgs:
      - zabbix-agent2
    - require:
      - cmd: zabbix-repo-install

/etc/zabbix/zabbix_agent2.conf:
  file.managed:
    - source: salt://services/files/zabbix_agent2.conf
    - template: jinja
    - context:
    {% if pillar.get('zabbix-server', none) is none %}
      server: {{ pillar['zabbix-common']['server'] }}
    {% else %}
      server: 127.0.0.1
    {% endif %}
    - require:
        - pkg: zabbix-agent-install

zabbix-agent2:
  service.running:
    - enable: True
    - require:
      - file: /etc/zabbix/zabbix_agent2.conf
    - watch:
      - file: /etc/zabbix/zabbix_agent2.conf
{% endif %}

As you can see, these state definitions rely on some external files.

zabbix.conf.php : jinja template for zabbix configuration

Click to expand code
<?php
// Zabbix GUI configuration file.

$DB['TYPE']                     = 'POSTGRESQL';
$DB['SERVER']                   = 'localhost';
$DB['PORT']                     = '0';
$DB['DATABASE']                 = '{{ DBName }}';
$DB['USER']                     = '{{ DBUser }}';
$DB['PASSWORD']                 = '{{ DBPassword }}';

// Schema name. Used for PostgreSQL.
$DB['SCHEMA']                   = '';

// Used for TLS connection.
$DB['ENCRYPTION']               = true;
$DB['KEY_FILE']                 = '';
$DB['CERT_FILE']                = '';
$DB['CA_FILE']                  = '';
$DB['VERIFY_HOST']              = false;
$DB['CIPHER_LIST']              = '';

// Vault configuration. Used if database credentials are stored in Vault secrets manager.
$DB['VAULT']                    = '';
$DB['VAULT_URL']                = '';
$DB['VAULT_PREFIX']             = '';
$DB['VAULT_DB_PATH']            = '';
$DB['VAULT_TOKEN']              = '';
$DB['VAULT_CERT_FILE']          = '';
$DB['VAULT_KEY_FILE']           = '';
// Uncomment to bypass local caching of credentials.
// $DB['VAULT_CACHE']           = true;

// Uncomment and set to desired values to override Zabbix hostname/IP and port.
// $ZBX_SERVER                  = '';
// $ZBX_SERVER_PORT             = '';

$ZBX_SERVER_NAME                = '{{ grains['host'] }}';

zabbix_server.conf: jinja template for Zabbix server configuration

Click to expand code
LogFile=/var/log/zabbix/zabbix_server.log
PidFile=/run/zabbix/zabbix_server.pid
SocketDir=/run/zabbix
DBName={{ DBName }}
DBUser={{ DBUser }}
DBPassword={{ DBPassword }}
SNMPTrapperFile=/var/log/snmptrap/snmptrap.log
Timeout=20
FpingLocation=/usr/bin/fping
Fping6Location=/usr/bin/fping6
LogSlowQueries=3000
StatsAllowedIP=127.0.0.1
EnableGlobalScripts=0

zabbix_agent2.conf: jinja template for all Zabbix agents configuration. Note that there is a subtle settings here. If the host use kubernetes persistent volumes (detected with ‘k3s-labels:pv’), all vfs triggers are disabled. I did that because else, Zabbix agent would have mounted the persistent volumes on all nodes at the same same, which would creates corruption on the filesystem (more on persistent volumes later).

Click to expand code
Server={{ server }}
ServerActive={{ server }}
Hostname={{ grains['host'] }}
LogFile=/var/log/zabbix/zabbix_agent2.log
PidFile=/run/zabbix/zabbix_agent2.pid
PluginSocket=/run/zabbix/zabbix_agent2.sock
{% if salt.pillar.get('k3s-labels:pv', none) == 'enabled' -%}
DenyKey=vfs.*[*]
{% endif %}

Here is the custom script that handle PostgreSQL database. It it compliant with saltstack’s stateful script requirements:

Click to expand code
#!/bin/bash

### GLOBAL VARIABLES ###########################
CHANGED="no"
COMMENT=""
DBNAME=""
USER=""
PASSWD=""

### FUNCTIONS ##################################
Help()
{
   # Display Help
   echo
   echo "Unsure a PostgreSQL DB and user exists"
   echo "Either create it, modify it or do nothing"
   echo
   echo "Syntax: $0 <dbname> <user> <password> [sql_script.gz]"
   echo
   echo "IMPORTANTS NOTES:"
   echo "   Mind that any givn password can be shown in process list for a short time and can also be storedin bash history"
   echo
}

# print a message that can be interpreted by saltstack cmd.script state
# $1=exit code
ExitMessage()
{
    echo
    echo "changed=$CHANGED comment='$COMMENT'"
    exit $1
}

### SCRIPT LOGIC ################################

# check if a record exists in given table return 1 if found
# $1: table
# $2: field to use for earching (where)
# $3: record to search
Pg_Record_exists()
{
    table="$1"
    field="$2"
    record="$3"

    sudo -u postgres psql -tc "SELECT 1 FROM $table WHERE $field = '$record'"
}

# Display help if needed
if [[ ($# -lt 3) || $1 == "help" ]] ; then
    Help
    exit 2
fi

DBNAME="$1"
USER="$2"
PASSWD="$3"

if [[ "$(Pg_Record_exists pg_roles rolname $USER | awk '{print $1}')" -eq 1 ]]; then
    COMMENT="User $USER already exists."
else
    sudo -u postgres psql -c "CREATE ROLE $USER LOGIN PASSWORD '$PASSWD';"
    CHANGED="yes"
    COMMENT="User $USER created."
fi

if [[ "$(Pg_Record_exists pg_database datname $DBNAME | awk '{print $1}')" -eq 1 ]]; then
    COMMENT="$COMMENT Database $DBNAME already exists."
else
    sudo -u postgres psql -c "CREATE DATABASE $DBNAME OWNER $USER LOCALE 'en_US.UTF-8' ENCODING UTF8;"
    CHANGED="yes"
    COMMENT="$COMMENT Database $DBNAME created."

    # if an sql script path has been given, try to execute it on the created db
    if [[ $# -eq 4 ]]; then
        zcat "$4" | sudo -u $USER psql -q $DBNAME
        if [[ $? -eq 0 ]]; then
            COMMENT="$COMMENT given SQL script executed"
        else
            COMMENT="$COMMENT failed to execute given SQL script"
        fi
    fi
fi

ExitMessage 0

Some tips I noted about Zabbix:

  • The default system locale must be set for the Zabbix configuration process to work.
  • On the Zabbix server, the agent config file must use the local IP address (server=127.0.0.1). The FQDN does not work (but it works perfectly fine on all other agents…).
  • Don’t forget to open the following ports: 10050 (server to agents) and 10051 (agents to server).

I wanted to automatically add all managed hosts to the server right after agent installations using saltext-zabbix, but at the time of writing, it wasn’t working with the current SaltStack version.

I had to add all hosts manually… All in all, Zabbix is probably the least automated part of my homelab. Even the database restore and configuration deployment are manual. I probably need to work on that, but I found Zabbix not to be at a “cloud-ready” level yet (no “as code” configuration).

Ntfy as notification solution

A good monitoring solution is of limited use without a reliable notification mechanism. This is where ntfy comes into play.

Ntfy is a simple, free way to send and receive notifications on your smartphone. The usage is straightforward: just push a string via HTTP to a dedicated channel, and you can instantly receive the string on your smartphone.

Of course, the default free and cloud-based approach has limited security protection, but it meets the requirements for a home lab.

To pair it with Zabbix, a specific “mediatype” must be imported. You can find this in the following GitHub repository: torgrimt/zabbix-ntfy: Mediatype to add support for ntfy.sh services.

However, I found that the configuration of Ntfy’s mediatype in Zabbix is not well explained:

Field Comment
URL Must contain the URL to the Ntfy server without “http://” or “https://”. For example, “ntfy.sh” is valid, but “https://ntfy.sh” is not valid.
Password, Token and Username These fields must be empty if not needed. By “empty,” I mean nothing—remove even the macro reference if not used (even if the macro is empty).
Topic Must contain the topic you want to use on the Ntfy server without any “/” character.

Backup strategy

Because most things are defined “as code” and based on a desired state approach, my backup needs are lightweight and very targeted: “live” data.

I actually have three types of “live” data to back up:

  • User files, hosted on a NAS (more on that later).
  • Zabbix database: I haven’t yet found an elegant way to back up and restore automatically via states. For now, I do a simple “pg_dump” from time to time (I’m mostly interested in backing up the configuration more than data history).
  • Kubernetes persistent volumes: This is what I will detail below.

My goal was to follow a KISS approach to make it easy to recover data in any circumstance. I mean “agnostic”: even if I had to change the infrastructure logic, hardware, containers, hypervisor, operating system, etc.

To achieve this, I based my implementation on ZFS volumes (prefixed by “pv_” for “Persistent Volumes”):

  • Any “pv” is a ZFS volume created on all Proxmox nodes and mounted on all Kubernetes nodes.
  • “pv” volumes are synchronized using “pvesync” at the hypervisor level.
  • All “pv” volumes are backed up from node-1 (which has NL disks) by simply mounting them in read-only mode and creating a tar.gz archive.
  • I keep 7 rolling backups for each volume.

More details on how these persistent volumes are handled are in the K3s chapter below.

Regarding the synchronization of ZFS volumes, “pvesync” works with a master/slave logic. In other words, it doesn’t handle bi-directional synchronization. This can be a problem because Kubernetes pods can freely move from one node to another. So, it’s necessary to determine which host holds the “master” ZFS volume in order to initiate synchronization accordingly.

After some research, I found that the best way to determine the “master” volume from the hypervisor was by checking the “bytes written” property of the ZFS volume. I based the selection of the “master” volume on the node where the ZFS volume has the highest “bytes written” value.

Here is the script. It determines the master node and then launches the synchronization if needed:

Click to expand code
#!/bin/bash

source /usr/local/bin/set_common.sh

### GLOBAL VARIABLES ###########################
VOLUME_PREFIX="pv_"
declare -A PV

### FUNCTIONS ##################################
Help()
{
   # Display Help
   echo
   echo "Syncronize all zfs volume that begin with '$VOLUME_PREFIX' with all given hosts"
   echo "All hosts are scanned and the source of each volume is determined by checking bytes written."
   echo
   echo "The host on which a volume has the highest number of written bytes is considered as the source."
   echo "All others hosts will be considered as destinations on which data must be updated"
   echo "If not change has been made on the volume, no operation is done"
   echo
   echo "This command depends on pve-zsync, it must be present and no check is done for that"
   echo
   echo "for each found volumes, the output will be:"
   echo "datetime  volume  written  source_host  status".
   echo ""
   echo "examples".
   echo "2024-11-09T19:01:02,506680931+01:00   zfs-storage/pv_myvolume  10.0.0.1=674537 / 10.0.0.1=7656 / => 10.0.0.1    OK".
   echo "2024-11-09T19:01:02,506680931+01:00   zfs-storage/pv_myvolume  10.0.0.1=0 / 10.0.0.2=0   no_change   OK".
   echo
   echo "status can be: OK, ERROR or SKIPPED (if source is not localhost)"
   echo
   echo "Syntax: $0 <host1> <host2> [host3 [...]]"
   echo
}

# $1 = zfs volume
# $2 = prefix
GetZFSSnapshots()
{
    prefix="$2"
    $prefix zfs list -H -t snapshot -o name -s creation | \grep "$1"
}

### SCRIPT LOGIC ################################

# Display help if needed
if [[ ($# -lt 2) || $1 == "help" ]] ; then
    Help
    exit 2
fi

# scan all zfs volume used as pv
for pv_path in $(zfs list -H | \grep pv_ | awk '{print $1}')
do
    echo -n "$(date -Ins)   $pv_path    "
    lastWrittenValue=0
    sourceHost=""

    # get amount of written bytes on all servers and set source host regarding the highest bytes written value
    for current_host in "$@"
    do
        # if not localhost, call command throught ssh
        prefix=""
        if [[ $(hostname --all-ip-addresses || hostname -I) != *"$current_host"* ]]; then
            prefix="ssh $current_host"
        fi

        # if this current scanned host has more written bytes for this volume, consider it as source
        written=$($prefix zfs get -H -p written $pv_path | awk '{print $3}')
        if [[ $written -gt $lastWrittenValue ]]; then
            sourceHost=$current_host
        fi

        echo -n "$current_host=$written / "

        lastWrittenValue=$written
        PV[$current_host]=$written
    done

    # if source host has not been determined, it means that no bytes have been written since the previous sync
    if [[ -z "${sourceHost}" ]]; then
        echo "=> no_change    OK"
        continue
    else
        #echo -n "${PV[$sourceHost]}    $sourceHost"
        echo -n "=> $sourceHost "
    fi

    # sync to all sources if sourceHost is localhost
    if [[ $(hostname --all-ip-addresses || hostname -I) == *"$sourceHost"* ]]; then
        for current_host in "$@"
        do
            if [[ $current_host != $sourceHost ]]; then
                # Synchronise from source to destination
                pve-zsync sync --source $pv_path --dest $current_host:$(dirname $pv_path) --maxsnap 5
                if [[ $? -eq 0 ]]; then
                    echo "  OK"
                else
                    echo "  ERROR"
                fi
            fi
        done
    else
        echo "  SKIPPED"
    fi
done

exit 0

This script runs every 5 minutes on each defined “pvhost” pillars (see bellow), which is acceptable for me as I have very few write operations on my containers. The probability of losing data because a pod might have been moved just after a write operation but before the next synchronization is very low. Through, I still have the opportunity to go as low as a sync every minute.

Even though it works very well, such a mechanism must be properly monitored to quickly detect any errors (e.g., network failure). Each call of the script logs the results in a file (/var/log/zfs_sync.log).

I created a dedicated Zabbix item and trigger to be sure I’m alerted by Ntfy in case of error:

After several months in production, I’ve never lost anything and haven’t experienced any filesystem corruption (I use ext4 on top of ZFS volumes). However, I did encounter some errors—not due to the mechanism itself, but because of the USB network adapter dedicated to the replication network on one of the nodes. This adapter is sometimes reset by the kernel.

Last but not least, persistent volumes are automatically created by states and defined in pillar values. I created another script to handle their creation. This script also takes care of automatically restoring any backup during the creation process. So, again, if I deleted everything, SaltStack will recreate all volumes with their latest available data.

Here is how I define a persistent volume in pillar:

Click to expand code
##############################################################
# Define zfs volumes to be used as persistents volumes in k3s
# size is in Gb
# scsiid must be unique for each volume
##############################################################
pvdef:
  mariadb:
    zfsmountpoint: zfs-storage
    size: 4
    fstype: ext4
    scsiid: 1

##############################################################
# Define zfs volumes to be used as persistents volumes in k3s
# size is in Gb
# scsiid must be unique for each volume
##############################################################
pvdef:
  mariadb:
    zfsmountpoint: zfs-storage
    size: 4
    fstype: ext4
    scsiid: 1

  mariadbbackup:
    zfsmountpoint: zfs-storage
    size: 1
    fstype: ext4
    scsiid: 5

##############################################################
# list of hosts ip addresses on which all persistents volumes
# must be synchronized through pve-zsync using zfs_sync.sh
# ipaddr: ip address used for zfs to send / receive sync data
# cronsync: cronexpression at which the host execute zfs_sync.sh
#
# WARNING: be sure that no host launch the sync at the same time !
#
##############################################################
pvhosts:
    hulk-1:
        ipaddr: 10.0.0.1
        cronsync: '0-59/10 * * * *'
    hulk-2:
        ipaddr: 10.0.0.2
        cronsync: '5-59/10 * * * *'

##############################################################
# list of persistants volumes and their Virtual Machine
# attachement (ID)
##############################################################
pvmap:
  mariadb: 1200
  mariadbbackup: 1200

How states handle the creation:

Click to expand code
# handle k3s persistent volumes creation
{% if pillar.get('pvmap', none) is not none %}
{% for app in pillar['pvmap'] %}
pv_{{ app }}_{{ grains['host'] }}:
    cmd.script:
        - name: /usr/local/bin/set_zfspv.sh
        - stateful: True
        - args: >
            zfsmountpoint={{ pillar['pvdef'][app]['zfsmountpoint'] }}
            appname={{ app }}
            size={{ pillar['pvdef'][app]['size'] }}
            fstype={{ pillar['pvdef'][app]['fstype'] }}
            vmid={{  pillar['pvmap'][app] }}
            scsiid={{ pillar['pvdef'][app]['scsiid'] }}

# try to restore a backup if persistent volume has just been created
{% if pillar.get('has_nas_storage', false) is true %}
pv_{{ app }}_{{ grains['host'] }}_restore:
    cmd.run:
        - names:
            - /usr/local/bin/nas.sh restore {{ pillar['pvdef'][app]['zfsmountpoint'] }} pv_{{ app }}
            - /usr/local/bin/zfs_sync.sh {% for pvhost in pillar['pvhosts'] %}{{ pvhost }} {% endfor %} >> /var/log/zfs_sync.log
        - onchanges:
            - cmd: pv_{{ app }}_{{ grains['host'] }}
{% endif %}

{% endfor %}
{% endif %}

How the states ensure ZFS persistent volumes are synchronized:

Click to expand code
{% if pillar.get('pvhosts', none) is not none %}
{% set currentlocalhost = grains['localhost'] %}

iac_zfssync_packages:
  pkg.installed:
    - pkgs:
      - pve-zsync

iac_zfssync_cmd:
  file.managed:
    - names:
      - /usr/local/bin/zfs_sync.sh:
        - source: salt://iac_backend/files/zfs_sync.sh
        - mode: 750
      - /etc/logrotate.d/zfs_sync:
        - source: salt://iac_backend/files/zfs_sync.logrotate
        - mode: 644

iac_zfssync_cronjob:
  schedule.present:
    - function: cmd.run
    - job_args:
      - '/usr/local/bin/zfs_sync.sh {% for pvhost in pillar['pvhosts'] %}{{ pillar['pvhosts'][pvhost]['ipaddr'] }} {% endfor %} >> /var/log/zfs_sync.log'
    - maxrunning: 1
    - cron: '{{ pillar['pvhosts'][currentlocalhost]['cronsync'] }}'

{% endif %}

The script beneath the states (/usr/local/bin/set_zfspv.sh, which is statefull compliant):

Click to expand code
#!/bin/bash

source /usr/local/bin/set_common.sh

### GLOBAL VARIABLES ###########################
VOLUME_PREFIX="pv_"
MANDATORY="zfsmountpoint appname size fstype vmid scsiid"

### FUNCTIONS ##################################
Help()
{
   # Display Help
   echo
   echo "Unsure a ZFS volume for kubernetes PV exists and is provided to guest VM"
   echo "Either create and provide it, or do nothing"
   echo
   echo "Syntax: $0 parameter1=value1 paramter2=value2 ..."
   echo
   echo "where valid parameters are (all are mandatory):"
   echo "  zfsmountpoint:  root zfs mountpoint on which to create the volume"
   echo "  appname:        name of the application in kubernetes (eg. mariadb)"
   echo "  size:           size of the volume (eg. 8 for 8 Gigabytes)"
   echo "  fstype:         filesystem to use to format the partition (eg. ext4)"
   echo "  vmid:           VMID of the VM to which the volume must be added"
   echo "  scsiid:         SCSI disk number to use on guest VM (eg. 1 for scsi1) "
   echo
}

### SCRIPT LOGIC ################################

# Display help if needed
if [[ ($# -lt 6) || $1 == "help" ]] ; then
    Help
    exit 2
fi

ParseParameters "$@"
CheckMandatory

ZFSVolumePath="${arguments['zfsmountpoint']}/${VOLUME_PREFIX}${arguments['appname']}"
ZFSBlockDevice="/dev/zvol/$ZFSVolumePath"
ZFSBlockDevicePart1="${ZFSBlockDevice}-part1"
MkfsCMD="mkfs.${arguments['fstype']}"
ProductName="${VOLUME_PREFIX}${arguments['appname']}"

# create volume, partition and format it if not exists
if [[ ! -b "$ZFSBlockDevice" ]]; then
    # Create ZFS volume
    zfs create -V ${arguments['size']}G $ZFSVolumePath
    if [[ ! $? -eq 0 ]]; then
        CHANGED="no"
        COMMENT="Error creating zfs volume $ZFSVolumePath"
        ExitMessage 1
    fi

    # create one partition the volume
    while [ ! -b "$ZFSBlockDevice" ]; do
        sleep 1
    done
    parted "$ZFSBlockDevice" -- mklabel msdos
    parted -a minimal "$ZFSBlockDevice" -s -- mkpart primary 0 -1
    if [[ ! $? -eq 0 ]]; then
        CHANGED="no"
        COMMENT="Error creating partition on zfs volume ${VOLUME_PREFIX}${arguments['appname']}"
        ExitMessage 1
    fi

    # format the new partition when it appears
    while [ ! -b "$ZFSBlockDevicePart1" ]; do
        sleep 1
    done
    sleep 2 # still need to wait a bit for the part to settle
    $MkfsCMD "$ZFSBlockDevicePart1"
    if [[ ! $? -eq 0 ]]; then
        CHANGED="no"
        COMMENT="Error with $MkfsCMD $ZFSBlockDevicePart1"
        ExitMessage 1
    fi

    CHANGED="yes"
    COMMENT="$ZFSVolumePath created."
else
    CHANGED="no"
    COMMENT="$ZFSVolumePath already exists."
fi

# add volume as disk to the guest VM
if [[ "$(qm config ${arguments['vmid']} | grep ${arguments['appname']})" != *"${arguments['appname']},"* ]]; then
    qm set ${arguments['vmid']} --scsi${arguments['scsiid']} $ZFSBlockDevice,ssd=1,product="$ProductName"
    if [[ ! $? -eq 0 ]]; then
        CHANGED="no"
        COMMENT="Error adding $ZFSBlockDevice as scsi${arguments['scsiid']} on VM ${arguments['vmid']}"
        ExitMessage 1
    fi

    CHANGED="yes"
    COMMENT="$COMMENT Disk scsi${arguments['scsiid']} added to VM ${arguments['vmid']}"
else
    CHANGED="no"
    COMMENT="$COMMENT Guest ${arguments['vmid']} already use it"
fi

# print saltstack readble message then exit with success
ExitMessage 0

You can also see a call to a script named “/usr/local/bin/nas.sh”, it will be detailled later.

Automatic updates

As of writing, all my LXC and virtual machines are based on Debian 12. Thus I deployed unnattended upgrade on all of hosts via a simple state, based on the official documentation:

autoupdate_packages_debian:
  pkg.installed:
    - pkgs:
      - unattended-upgrades
      - apt-listchanges

/etc/apt/apt.conf.d/52unattended-upgrades-local:
  file.managed:
    - source: salt://sysadmin/files/52unattended-upgrades-local
    - mode: 640

/etc/apt/apt.conf.d/02periodic:
  file.managed:
    - source: salt://sysadmin/files/apt_conf_02periodic
    - mode: 640

However, I choosed not to apply automated upgrade on Proxmox nodes as it may cause serious issues due to breaking changes (eg. change in network cards names).

Real life use cases

NAS

As explained previously, one of the nodes (node-1) has two 8-terabyte hard drives. The main purpose is to build a NAS, primarily to offer Samba shares that host music, movies, office files, backups, etc.

One disk is “live,” and the other is used solely as a mirror/backup. The mirror disk is switched off most of the time and is even removed when I leave home for more than two days.

The mirroring is handled on the hypervisor side with the help of a custom script. This script also manages ZFS persistent volume backups and restores, and it can generate email reports.

Click to expand code
#!/bin/bash

### GLOBAL VARIABLES ###########################
LOG_TMP_FILE="/tmp/nas_last_sync.log"
SYNC_SOURCE="/nas-storage"
SYNC_DEST="/nas-mirror"
BACKUP_DIR="$SYNC_SOURCE/backup"
DATE_FORMAT="/bin/date +%d.%m.%Y-%H:%M:%S"
SUBJECT_SYNC="NAS Sync result"
SUBJECT_REPORT="NAS storage report"
SUBJECT_BACKUP="NAS zfs pv_* backup"
SUBJECT_RESTORE="NAS zfs pv_* restore"
MIRROR_DISK_LABEL="NAS-MIRROR"
VOL_TO_RESTORE=""
ZFS_POOL=""
BACKUP_NB=4
NL=$'\n'

### FUNCTIONS ##################################
Help()
{
   # Display Help
   echo
   echo "Perform NAS operations on NL disks"
   echo
   echo "Syntax: $0 <command>"
   echo "where command can be"
   echo "- sync:    launch disks synchdonisation"
   echo "- report:  build usage report and send it"
   echo "- backup:  backup all zfs pv_* volumes to $BACKUP_DIR"
   echo "- restore <zfs pool> <vol>: restore content of vol from $BACKUP_DIR"
   echo "- help:    display this message"
   echo
}

# send a email to sysadmin
# $1 = subject
# $2 = content (string or file)
mailadmin()
{
  if [ -f "$2" ]; then
    mail -s "$1" root < "$2"
  else
    echo "$2" | mail -s "$1" root
  fi
}

# umount, send mail then exit with given code
# $1 = exit code
# $2 = path to umount
# $3 = subject
# $4 = content (string or file)
mailandexit()
{
    if [[ ! -z "$2" ]];then
        umount -q $2
    fi
    mailadmin "$3" "$4"
    exit $1
}

# snychornize nas-storage to nas-mirror if disk is present
Sync()
{
    if [ -e /dev/disk/by-label/$MIRROR_DISK_LABEL ] ; then
        mount -v $SYNC_DEST > "$LOG_TMP_FILE" 2>&1

        echo "---------------------------------------------------------" >> "$LOG_TMP_FILE"
        rsync -a -v --delete-after $SYNC_SOURCE/ $SYNC_DEST/ | awk '{ print "['$($DATE_FORMAT)'] " $0 }' >> "$LOG_TMP_FILE" 2>&1
        echo "---------------------------------------------------------" >> "$LOG_TMP_FILE"

        umount -v $SYNC_DEST >> "$LOG_TMP_FILE" 2>&1

        mailadmin "$SUBJECT_SYNC" "$LOG_TMP_FILE"
    fi
}

# generate a simple usage report of nas storage
Report()
{

    message="$(df -h $SYNC_SOURCE)"
    message="$message$NL --------------------------------------------------------------$NL"
    for dir in $(ls $SYNC_SOURCE)
    do
        message="$message $(du -h -d 1 $SYNC_SOURCE/$dir)$NL$NL"
    done

    mailadmin "$SUBJECT_REPORT" "$message"
}

# backup all zfs pv_* vol
backup_zfspv()
{
    message="ZFS pv_* backup:$NL"
    message="$message --------------------------------------------------------------$NL"

    for pv_path in $(zfs list -H | \grep pv_ | awk '{print $1}')
    do
        mountpath="/dev/zvol/${pv_path}-part1"
        base="$(basename $pv_path)"
        filename="$base-$(date -I).tar.gz"
        message="$message  $filename  "

        # do nothing if backup destination already exists
        if [[ -f "$BACKUP_DIR/$filename" ]]; then
            message="$message INFO: file already exists $NL"
            continue
        fi

        # mount the source to backup
        mkdir -p /media/$pv_path
        mount -o ro $mountpath /media/$pv_path
        if [[ ! $? -eq 0 ]]; then
            message="$message ERROR: could not mount $mountpath $NL"
            continue
        fi

        # if volume is empty, do nothing
        if [[ $(ls /media/$pv_path | wc -l) -lt 2 ]] then
            message="$message INFO: empty source, nothing done $NL"
            umount /media/$pv_path
            continue
        fi

        # do the backup
        cd /media/$pv_path
        tar -czf "$BACKUP_DIR/$filename" *
        if [[ ! $? -eq 0 ]]; then
            message="$message ERROR: creating tar.gz archive on $BACKUP_DIR $NL"
            umount /media/$pv_path
            continue
        fi

        # report size
        message="$message$(du -h "$BACKUP_DIR/$filename" | awk '{print $1}') $NL"
        cd ..
        umount /media/$pv_path
        if [[ $? -eq 0 ]]; then
            rmdir /media/$pv_path
        fi

        # if there are more backup files than BACKUP_NB, delete the oldest
        if [[ $(ls $BACKUP_DIR/$base* | wc -l) -gt $BACKUP_NB ]] then
            file_to_delete=$(ls $BACKUP_DIR/$base* | sort | head -1)
            rm $file_to_delete
        fi
    done

    mailadmin "$SUBJECT_BACKUP" "$message"
}

# restore a given zfs volume
restore_zfspv()
{
    if [[ -z "$VOL_TO_RESTORE" || -z "$ZFS_POOL" ]]; then
        Help
        exit 0
    fi

    message="Restore $VOL_TO_RESTORE:$NL"
    message="$message --------------------------------------------------------------$NL"

    # get lastest backup if any
    file_to_restore=$(ls $BACKUP_DIR/$VOL_TO_RESTORE* 2>/dev/null | sort -r | head -1)
    if [[ ! -f $file_to_restore ]]; then
        message="$message INFO: no backup found in $BACKUP_DIR"
        mailandexit 0 "" "$SUBJECT_RESTORE" "$message"
    fi

    # mount vol to restore
    mountpath="/dev/zvol/${ZFS_POOL}/${VOL_TO_RESTORE}-part1"
    mkdir -p /media/$VOL_TO_RESTORE
    mount $mountpath /media/$VOL_TO_RESTORE
    if [[ ! $? -eq 0 ]]; then
        message="$message ERROR: could not mount $mountpath"
        mailandexit 1 /media/$VOL_TO_RESTORE "$SUBJECT_RESTORE" "$message"
    fi

    # if volume not empty, do nothing
    if [[ $(ls /media/$VOL_TO_RESTORE | wc -l) -gt 2 ]] then
        message="$message WARN: target not empty, nothing done $NL"
        mailandexit 0 /media/$VOL_TO_RESTORE "$SUBJECT_RESTORE" "$message"
    fi

    # restore
    message="$message $(tar -zxvf $file_to_restore -C /media/$VOL_TO_RESTORE/)$NL$NL"
    if [[ ! $? -eq 0 ]]; then
        message="$message ERROR: could not restore $file_to_restore"
        mailandexit 1 /media/$VOL_TO_RESTORE "$SUBJECT_RESTORE" "$message"
    fi

    # umount
    umount /media/$VOL_TO_RESTORE
    if [[ $? -eq 0 ]]; then
        rmdir /media/$VOL_TO_RESTORE
    fi

    message="$message restored $file_to_restore into $mountpath $NL"
    mailadmin "$SUBJECT_RESTORE" "$message"
}

### SCRIPT LOGIC ################################
case "$1" in
    sync)
        Sync
        ;;
    report)
        Report
        ;;
    backup)
        backup_zfspv
        ;;
    restore)
        ZFS_POOL="$2"
        VOL_TO_RESTORE="$3"
        restore_zfspv
        ;;
    *)
        Help
        ;;
esac

In additions, most important files are also synchronized on Onedrive which is monitored via a custom log item plus a trigger in Zabbix for the file “/var/log/onedrive”

The NAS service itself is provided by a dedicated LXC on node-1. This LXC uses the “online” hard disk to provides SMB shares on a VLAN dedicated to users. Users confguration has been covered in the previous chapter “Users management”.

The state that configure the LXC is pretty simple:

# source : https://wiki.archlinux.fr/Samba + https://wiki.debian.org/Avahi
# install samba
filesrv_packages:
  pkg.installed:
    - pkgs:
      - samba
      - avahi-daemon
      - wsdd

# custom configuration file
/etc/samba/smb.conf:
  file.managed:
    - source: salt://services/files/smb.conf
    - user: root
    - group: root
    - mode: 644

# ensure service is active and running
smbd:
  service.running:
    - enable: True
    - watch:
      - file: /etc/samba/smb.conf

nmbd:
  service.running:
    - enable: True
    - watch:
      - file: /etc/samba/smb.conf

wsdd:
  service.running:
    - enable: True

avahi-daemon:
  service.running:
    - enable: True

Reverse proxy and WAF

As explained later, I have a Kubernetes cluster on which some exposed services are running. These services are behind an Nginx server that serves as a reverse proxy, SSL offloading, and a web application firewall thanks to NAXSI.

Nginx is fully deployed on a dedicated LXC running on node-2 via a couple of states.

Below is an example of pillar values to set up Nginx. Crypto keys are encrypted with GPG:

Click to expand code
#!jinja|yaml|gpg

##############################################################
# reverse proxy configuration with in-memory cache
# secrets are encrypted with GPG
#
# rproxy_conf :
# - ssl_certificate and ssl_certificate_key are GPG encrypted data for let's encrypt. Wildcard certificate is assumed (same for all sites)
#
# rproxy_sites:
# - type: used for specific settings and naxsi rules file name naxsi_<type>.rules
#
##############################################################

rproxy_conf:
  naxsi_package: https://github.com/wargio/naxsi/releases/download/1.6/debian-bookworm-libnginx-mod-http-naxsi_1.6_amd64.deb
  worker_connections: 512   # maximum number of simultaneous connections that can be opened by a worker process
  resolver: aaa.bbb.ccc.ddd  # ip address of dns server to use to resolve backend fqdn
  cache_max_age: 14d        # delete file older than x days
  cache_max_size: 400m      # maximum size the cache can take ()
  dh4096: |
    -----BEGIN PGP MESSAGE-----

    [...]
    -----END PGP MESSAGE-----

  ssl_certificate: |
    -----BEGIN PGP MESSAGE-----

    [...]
    -----END PGP MESSAGE-----

  ssl_certificate_key: |
    -----BEGIN PGP MESSAGE-----

    [...]
    -----END PGP MESSAGE-----

rproxy_certbot:
  rsa-key-size: 4096
  cert-name: all-sites
  email: registered@email.com
  domains: www.mydomain.com,www.myother-domain.org
  deploy-hook: systemctl restart nginx

rproxy_sites:
  mydomain.com:
    type: wordpress
    cache_static_files: enabled
    cache_dynamic_files: enabled
    backends:
      - k3s-master-1.local.lan:8001
      - k3s-master-2.local.lan:8001

  myother-domain.org:
    type: wordpress
    cache_static_files: enabled
    cache_dynamic_files: enabled
    backends:
      - k3s-master-1.local.lan:8002
      - k3s-master-2.local.lan:8002

And here it the state file:

Click to expand code
##############################################################
# Deploy everything that is required to run nginx as a reverseproxy + waf + cache:
# - needed packages
# - external deb package for naxsi extention
# - custom config for nginx service in order to create /run/nginx directory for tmpfs caching
# - install naxsi rules files and cache configurations
# - make custom nginx config file regarding pillar values
# - set let's encrypt required certiitates file based on pillar GPG encrypted values
# - create site configuration file for each site declared in pillar
# - set a cronjob that runs certbot everyday to try to renew the wildcards certificate
##############################################################

# install packages
rproxy_packages:
  pkg.installed:
    - pkgs:
      - nginx
      - certbot
      - python3-certbot-nginx

# install naxsi extention, assume debian host
rproxy_sources_packages:
  cmd.run:
    - name: wget {{ pillar['rproxy_conf']['naxsi_package'] }} -O /root/naxsi.deb && dpkg -i /root/naxsi.deb && rm /root/naxsi.deb
    - unless: test -f /usr/lib/nginx/modules/ngx_http_naxsi_module.so

# tune systemd nginx service config
rproxy_systemd_config:
  file.managed:
    - name: /etc/systemd/system/nginx.service.d/custom-service.conf
    - source: salt://services/files/rproxy/custom-service.conf
    - makedirs: True

# install available naxsi rules
rproxy_naxsi_rules:
  file.recurse:
    - name: /etc/nginx/naxsi-rules
    - source: salt://services/files/rproxy/naxsi-rules

# install available reverse proxy cache configurations
rproxy_cache_conf:
  file.recurse:
    - name: /etc/nginx/reverse-conf
    - source: salt://services/files/rproxy/reverse-conf

# deploy nginx configuration
rproxy_conf:
  file.managed:
    - name: /etc/nginx/nginx.conf
    - source: salt://services/files/rproxy/nginx.conf
    - template: jinja
    - context:
      worker_connections: {{ pillar['rproxy_conf']['worker_connections'] }}
      resolver: {{ pillar['rproxy_conf']['resolver'] }}
      cache_max_age: {{ pillar['rproxy_conf']['cache_max_age'] }}
      cache_max_size: {{ pillar['rproxy_conf']['cache_max_size'] }}

# let's encrypte cert files
rproxy_dh4096:
  file.managed:
    - name: /etc/nginx/dh4096.pem
    - contents: |-
        {{ pillar['rproxy_conf']['dh4096'] | indent(8) }}

rproxy_fullchain:
  file.managed:
    - name: /etc/letsencrypt/live/all-sites/fullchain.pem
    - makedirs: True
    - contents: |-
        {{ pillar['rproxy_conf']['ssl_certificate'] | indent(8) }}

rproxy_privkey:
  file.managed:
    - name: /etc/letsencrypt/live/all-sites/privkey.pem
    - makedirs: True
    - contents: |-
        {{ pillar['rproxy_conf']['ssl_certificate_key'] | indent(8) }}

# generate config files for each declared site
{% for site in pillar['rproxy_sites'] %}
rproxy_site_{{ site }}:
  file.managed:
    - name: /etc/nginx/sites-enabled/{{ site }}.conf
    - source: salt://services/files/rproxy/sites-enabled.conf
    - makedirs: True
    - template: jinja
    - context:
      site: {{ site }}
{% endfor %}

# ensure nginx is running
rproxy_service:
  service.running:
    - name: nginx
    - enable: True
    - restart: True
    - watch:
      - file: /etc/nginx/nginx.conf
      - file: /etc/nginx/sites-enabled/*

# auto renew let's encrypt certificate
rproxy_certbot:
  schedule.present:
    - function: cmd.run
    - job_args:
      - 'certbot --nginx --agree-tos --hsts --staple-ocsp --rsa-key-size {{ pillar['rproxy_certbot']['rsa-key-size'] }} --cert-name {{ pillar['rproxy_certbot']['cert-name'] }} --email {{ pillar['rproxy_certbot']['email'] }} --domains {{ pillar['rproxy_certbot']['domains'] }}'
    - maxrunning: 1
    - cron: '0 5 * * *'

I’m not going to share all my nginx configuration files, but as you can see I use a generic “site-enabled.conf” which is a jinja template.

k3s cluster

Create the cluster

If you’ve managed to read the previous chapters and reached this line, you probably already know how my K3S cluster is deployed and configured: via states and pillar values.

Node-1 and node-2 are primarily used to host services that require high performance or persistence. Node-3 can handle smaller workloads but, more importantly, serves as a third Etcd instance. While it’s not mandatory with K3s, it provides additional security.

Below is a compilation of all the pillar values needed to configure the cluster. Of course, they must be distributed across several files to assign values to specific hosts (e.g., first master, simple node, etc.).

Pillar values :

Click to expand code
##############################################################
# k3s common settings for all nodes ofthe cluster
# url: base url where to get k3s binaries from
# test: file to check to choose weither k3s install should be done
# tocken: gpg encrypted needed token to join the cluster
# echo -n 'value to encrypt' | gpg --homedir /etc/salt/gpgkeys --trust-model always -ear unique_ear_key
##############################################################
k3s-common:
  url: https://get.k3s.io
  test: test -x /usr/local/bin/k3s
  token: |
    -----BEGIN PGP MESSAGE-----

    [...]
    -----END PGP MESSAGE-----

##############################################################
# k3s first master of the cluster.
# A server (not lxc) must exists on local.lan
#
# Any other node of the cluster will first have to connect to this one
# Install: command to run in order to deploy k3s on this master node
##############################################################
k3s-master:
  install: sh -s - server --cluster-init --write-kubeconfig-mode=644 --tls-san k3s-master-1.local.lan

##############################################################
# k3s master definition for all nodes.
#
# master: fqdn of the first k3s master
##############################################################
k3s-node:
  master: k3s-master-1.local.lan

The following states take care of installing either a primary master or a node, depending on available pillar values:

Click to expand code
##############################################################
# Deploy k3s cluster regarding k3s-* pillars.
# - The role of the node is defined by its name (k3s-agent-*, k3s-master-*, k3s-server-*)
# - The 1st master will be used to add all other nodes
# - A custom udev rule is added to all nodes that can handle persistent volumes:
#   Any scsi disk with a product name that starts with "pv_" is
#   mounted as /media/pv_<name> on all PV enabled hosts
#
##############################################################
{% if grains['host'].startswith('k3s-agent')        %}
{%   set role='agent'                               %}
{% elif pillar.get('k3s-master', none) is not none  %}
{%   set role=''                                    %}
{% else                                             %}
{%   set role='server'                              %}
{% endif                                            %}

k3s_dependencies:
  pkg.installed:
    - pkgs:
      - ca-certificates

{% if pillar.get('k3s-master', none) is not none %}
k3s_install_{{ grains['host'] }}:
  cmd.run:
    - name: wget -q -O - {{ pillar['k3s-common']['url'] }} | {{ pillar['k3s-master']['install'] }} {% for label in pillar['k3s-labels'] %}{{ ' --node-label ' + label + '=' ~ pillar['k3s-labels'][label] }} {% endfor %} --token {{ pillar['k3s-common']['token'] }}
    - unless: {{ pillar['k3s-common']['test'] }}
    - require:
      - pkg: k3s_dependencies
{% endif %}

{% if pillar.get('k3s-node', none) is not none %}
k3s_install_{{ grains['host'] }}:
  cmd.run:
    - name: wget -q -O - {{ pillar['k3s-common']['url'] }} | K3S_URL="https://{{ pillar['k3s-node']['master'] }}:6443" K3S_TOKEN="{{ pillar['k3s-common']['token'] }}" sh -s - {{ role }} {% for label in pillar['k3s-labels'] %}{{ ' --node-label ' + label + '=' ~ pillar['k3s-labels'][label] }} {% endfor %}
    - unless: {{ pillar['k3s-common']['test'] }}
    - require:
      - pkg: k3s_dependencies
{% endif %}

{% if pillar['k3s-labels']['pv'] == 'enabled' %}
k3s_install_udev:
  file.managed:
    - names:
      - /etc/udev/rules.d/80-pv-disks.rules:
        - source: salt://services/files/80-pv-disks.rules
        - mode: 640
  cmd.run:
    - name: udevadm control --reload-rules && udevadm trigger
    - onchanges:
      - file: /etc/udev/rules.d/80-pv-disks.rules

{% endif %}

k3s_running:
  service.running:
    {% if role == 'agent' -%}
    - name: k3s-agent
    {% else -%}
    - name: k3s
    {% endif -%}
    - enable: True
    - require:
      - cmd: k3s_install_{{ grains['host'] }}

As you can see, a special udev rule file (80-pv-disks.rules) is installed on all K3S nodes with the pillar value “k3s-label:pv”. More on than on the next chapter.

Manage persistence

I’ve already touched on the subject of persistent volumes in the “Backup and Restore” chapter. The concept is to use ZFS volumes that are mirrored between node-1 and node-2.

These ZFS volumes are mounted as SCSI devices on K3s hosts. They are then automatically mounted and unmounted via udev rules (80-pv-disks.rules). The purpose of “automount” is to prevent the same volume from being mounted simultaneously on two different nodes, thereby avoiding filesystem corruption. The auto-unmount timeout is set to a relatively short value (5 seconds).

Now, you might ask: where/how should these SCSI volumes be mounted so that K3s pods can recognize their names? The trick is to use the SCSI property “product,” which you may have already seen in the script set_zfspv.sh.

For example, I have a ZFS volume named pv_mariadb on the hypervisor side. This volume is presented as a SCSI device on all nodes with a command like:

qm set 1200 --scsi0 /dev/zvol/zfs-storage/pv_mariadb-part1,ssd=1,product="pv_mariadb"

Then, on all hosts with the pillar value k3s-label:pv , there is a set of udev rules that automatically mount all SCSI devices and create mount points according to the SCSI property “product” (exposed as “ID_MODEL” by udev). For example, /media/pv_mariadb. This way, I can define a pod that references a local storage path, and no matter where the pod is deployed, the data will be accessible.

Below are the udev rules :

# create mount dir
ACTION=="add", KERNEL=="sd[a-z]?", ENV{ID_MODEL}=="pv_*", RUN+="/bin/mkdir -p /media/%E{ID_MODEL}"
KERNEL=="sd[a-z]?", ENV{ID_MODEL}=="pv_*", RUN+="/bin/mkdir -p /media/%E{ID_MODEL}"
# set automount: all kube pv with reside in /media/pv_xxx
ACTION=="add", KERNEL=="sd[a-z]?", ENV{ID_MODEL}=="pv_*", RUN+="/usr/bin/systemd-mount --no-block --automount=yes --timeout-idle-sec=5s --collect $devnode /media/%E{ID_MODEL}"
KERNEL=="sd[a-z]?", ENV{ID_MODEL}=="pv_*", RUN+="/usr/bin/systemd-mount --no-block --automount=yes --timeout-idle-sec=5s --collect $devnode /media/%E{ID_MODEL}"

# if device is removed, umount it...
ACTION=="remove", KERNEL=="sd[a-z]?", ENV{ID_MODEL}=="pv_*", RUN+="/usr/bin/systemd-mount -u /media/%E{ID_MODEL}"
# ... then remove mount dir
ACTION=="remove", KERNEL=="sd[a-z]?", ENV{ID_MODEL}=="pv_*", RUN+="/bin/rmdir /media/%E{ID_MODEL}"

And here is a example of a persistent volume definition for K3S (additional nodeAffinity may be required):

apiVersion: v1
kind: PersistentVolume
metadata:
  name: mariadb-data-pv
  namespace: prod
spec:
  capacity:
    storage: 3500M
  volumeMode: Filesystem
  accessModes:
  - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-storage
  claimRef:
    name: mariadb-data-pv-claim
    namespace: prod
  local:
    path: /media/pv_mariadb

By combining this mechanism with the backup and desired-state approach I explained earlier, I can delete any SCSI volume (e.g., qm set 1200 --delete scsi0) or even destroy the ZFS volume (e.g., zfs destroy zfs-storage/pv_mariadb). Everything will be automatically reconstructed and restored, either by manually running salt '*' state.apply  or by waiting a bit, as the same command is automatically executed on a regular basis.

Application deployment

Deploying applications in Kubernetes “as code” is done with YAML files that include various definitions (volumes, services, containers, deployments, etc.). To maintain a GitOps approach, all applications deployed in my K3s cluster are based on pillar values and states.

The concept is that all Kubernetes definition YAML files are managed by SaltStack, which is also responsible for running kubectl apply. By “definition files,” I mean not only application deployments but also namespaces, secrets, and custom images. As mentioned earlier, any push of a new or modified file to the Git repository triggers a “highstate apply.” Thus, any newly added YAML file is automatically applied to the K3s cluster.

However, removing a pod is not (yet?) handled automatically. I haven’t implemented that for now, but it shouldn’t be complicated (pillar value + dedicated state that could trigger a kubectl delete).

Here is an exemple of pillar values I can use:

Click to expand code
#!yaml|gpg

##############################################################
# List of applications that are deployed on kubernetes cluster
# The name must correspond to a kube yaml definitions file on
# salt/kubeapps/files.
#
# yaml files are managed on all k3s server nodes in /root
#
# e.g: "mariadb" will trigger kubectl apply -f /root/mariadb.yaml
#
# kubesecrets part is used to create secrets in k3s cluster
# secrets are base64 encoded then encrypted with gpgp
# echo -n 'value to encrypt' | base64 | gpg --homedir /etc/salt/gpgkeys --trust-model always -ear unique_ear_key
##############################################################
kubeapps:
  - mariadb
  - mariadbbackup

kubesecrets:
  prod:
    mariadbpwd: |
      -----BEGIN PGP MESSAGE-----

      [...]
      -----END PGP MESSAGE-----

##############################################################
# k3s docker images to install on all nodes
#
# expected format of docker image name: <name>-<version>
#
# install: list of image to install
# remove: list of image to delete (the '-' is replaced by ':' when trying to remove)
# prefix is used to remove images, adding this value to the image name:version
##############################################################
k3s-images:
  prefix: docker.io/library/
  install:
    wordpress-v2024.11.17: https://github.com/jit06/docker-images/releases/download/wordpress/wordpress-v2024.11.17.tar.gz
  remove:
    - wordpress-v2024.11.16
    - wordpress-latest

And below the states that manage namespaces, custom images installation, secrets and applications deployments:

Click to expand code
##############################################################
# installation and removall of docker images to local repository
# image to install is expected to be a gziped tar archive
##############################################################
# install
{% for image in pillar['k3s-images']['install'] %}
{{ image }}_install:
  cmd.run:
    - name: wget -q -O - {{ pillar['k3s-images']['install'][image] }} | gzip -d - | ctr image import -
    - unless: test -n "$(ctr images ls | grep {{ image.replace('-',':') }})"
{% endfor %}

# remove
{% for image in pillar['k3s-images']['remove'] %}
{{ image }}_remove:
  cmd.run:
    - name: ctr images del {{ pillar['k3s-images']['prefix'] }}{{ image.replace('-',':') }}
    - unless: test -z "$(ctr images ls | grep {{ image.replace('-',':') }})"
{% endfor %}

##############################################################
# Handle installation and deployment of apps in k3s cluster
# This state should only be applied on a k3s master.
#
# The list of apps to deploy are checked in 'kubeapps' pillar
# for each apps:
# - the yaml definition from files/<appname>.yaml is copied to /root
# - the yaml file is applied via kubectl unless app is already installed
##############################################################
# create / refresh namespaces
kubeapps_namespaces:
  file.managed:
    - name: /root/namespaces.yaml
    - source: salt://kubeapps/files/namespaces.yaml
  cmd.run:
    - name: kubectl apply -f /root/namespaces.yaml

# create / refresh secrets than remove the file
kubeapps_secrets:
  file.managed:
    - name: /root/secrets.yaml
    - source: salt://kubeapps/files/secrets.yaml
    - template: jinja
  cmd.run:
    - name: kubectl apply -f /root/secrets.yaml

# deploy kubapps if not running
{% for app in pillar['kubeapps'] %}
{{ app }}_{{ grains['host'] }}:
  file.managed:
    - name: /root/{{ app }}.yaml
    - source: salt://kubeapps/files/{{ app }}.yaml
    - template: jinja
    - mode: 600

apply_{{ app }}_{{ grains['host'] }}:
  cmd.run:
    - name: kubectl apply -f /root/{{ app }}.yaml
    - unless: test -n "$(kubectl get pods | grep {{ app }})"
{% endfor %}

Finally, here is an example of my MariaDB definition to be deployed on the cluster, which also embed phpMyAdmin (yes, they shoud be on separates pods…):

Click to expand code
#######################################
# Services
#######################################
---
apiVersion: v1
kind: Service
metadata:
  name: mariadb-internal-service
  namespace: prod
spec:
  selector:
    app: mariadb
  ports:
    - protocol: TCP
      port: 3306
      targetPort: 3306
---
apiVersion: v1
kind: Service
metadata:
  name: phpmyadmin-service
  namespace: prod
spec:
  selector:
    app: mariadb
  type: LoadBalancer
  ports:
    - protocol: TCP
      port: 80 # exposed port for
      targetPort: 8080 # targeting port on the container
#      nodePort: 30100 # external IP port from 30000 till 32767 range

#######################################
# routes
#######################################
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: phpmyadmin
  namespace: prod
spec:
  rules:
    - host: phpmyadmin.local.lan
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: phpmyadmin-service
                port:
                  number: 80

#######################################
# Maps
#######################################
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: mariadb-configmap
  namespace: prod
data:
  mariadb.cnf: |
    [mariadb]
    key_buffer_size=4M
    max_allowed_packet=16M
    table_open_cache=64
    sort_buffer_size=512K
    net_buffer_length=8K
    read_buffer_size=256K
    read_rnd_buffer_size=512K
    myisam_sort_buffer_size=8M
    innodb_buffer_pool_size=64M
    query_cache_limit=1M
    query_cache_size=16M
    tmp_table_size=64M
    max_heap_table_size=64M
    max_connections=50
    log-warnings
    slow-query-log
    general-log
    disable-log-bin

#######################################
# volumes
#######################################
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: mariadb-data-pv-claim
  namespace: prod
  labels:
    app: mariadb
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 3500M
  storageClassName: local-storage
  volumeName: mariadb-data-pv
---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: mariadb-data-pv
  namespace: prod
spec:
  capacity:
    storage: 3500M
  volumeMode: Filesystem
  accessModes:
  - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-storage
  claimRef:
    name: mariadb-data-pv-claim
    namespace: prod
  local:
    path: /media/pv_mariadb
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: kubernetes.io/hostname
          operator: In
          values:
          - k3s-master-1
          - k3s-master-2

#######################################
# containers
#######################################
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mariadb-deployment
  namespace: prod
spec: # specification for deployment resource
  replicas: 1 # how many replicas of pods we want to create
  selector:
    matchLabels:
      app: mariadb
  template:
    metadata:
      namespace: prod
      labels:
        app: mariadb # service will look for this label
    spec:
      containers:
      - name: mariadb-internal-service
        image: mariadb
        ports:
        - containerPort: 3306
        env:
        - name: MARIADB_ROOT_PASSWORD
          valueFrom:
            secretKeyRef:
              name: kubesecrets
              key: mariadbpwd
        volumeMounts:
        - name: mariadb-config
          mountPath: /etc/mysql/conf.d/ # directory will be cleaned at the beginning
        - name: mariadb-data-pv
          mountPath: /var/lib/mysql
      - name: phpmyadmin
        image: bitnami/phpmyadmin:latest
        ports:
        - containerPort: 8080
        env:
        - name: DATABASE_HOST
          value: mariadb-internal-service
      volumes:
      - name: mariadb-config
        configMap:
          name: mariadb-configmap
          defaultMode: 0644
          items:
          - key: mariadb.cnf
            path: plugin-configuration.cnf
      - name: mariadb-data-pv
        persistentVolumeClaim:
          claimName: mariadb-data-pv-claim
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                - key: pv
                  operator: In
                  values:
                  - enabled
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                - key: speed
                  operator: In
                  values:
                  - fast

A Last Word on Secrets Management. You may have seen how I manage secrets:

  • Secrets are encoded in base64, as required by K3s.
  • Base64-encoded secrets are then encrypted with GPG and stored in pillar values (which reside in my private Git repository).
  • Secrets are injected into K3s via a temporary YAML file located in /root. This file is always removed after usage, as it contains decrypted secrets.

Kubernetes applications

If you’ve read this far, you probably understand the concept of my homelab. However, I’ll provide some application deployment examples in this chapter to better illustrate real-life use cases.

Mariadb + phpMyAdmin

This deployment was discussed in the previous chapter. However, I use a specific deployment to back up MariaDB. Even though all persistent volumes are replicated and backed up automatically, I find it safer to keep basic, text-based SQL exports of all databases.

To do this, I use a Kubernetes cronjob to execute the mariadb-dump command and store SQL files on a dedicated persistent volume.

Click to expand code
#######################################
# volumes
#######################################
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: mariadb-backup-pv-claim
  namespace: prod
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 900M
  storageClassName: local-storage
  volumeName: mariadb-backup-pv
---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: mariadb-backup-pv
  namespace: prod
spec:
  capacity:
    storage: 900M
  volumeMode: Filesystem
  accessModes:
  - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-storage
  claimRef:
    name: mariadb-backup-pv-claim
    namespace: prod
  local:
    path: /media/pv_mariadbbackup
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: kubernetes.io/hostname
          operator: In
          values:
          - k3s-master-1
          - k3s-master-2

#######################################
# cron job
#######################################
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: mariadb-backup-cronjob
  namespace: prod
spec:
  schedule: "0 4 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: mariadb-backup
              image: mariadb
              command:
                - "/bin/sh"
                - "-c"
                - 'mariadb -h mariadb-internal-service -u root -p$(cat /secrets/mariadbpwd) -N -e "show databases" | while read dbname; do mariadb-dump -h mariadb-internal-service -u root -p$(cat /secrets/mariadbpwd) --add-drop-database --single-transaction "$dbname" > "/backup/$dbname-$(date -I)".sql; done; find /backup -type f -mtime +1 -exec rm {} \;'
              volumeMounts:
                - name: mariadb-backup-pv
                  mountPath: /backup
                - name: secrets
                  mountPath: /secrets
                  readOnly: true
          restartPolicy: OnFailure
          volumes:
            - name: mariadb-backup-pv
              persistentVolumeClaim:
                claimName: mariadb-backup-pv-claim
            - name: secrets
              secret:
                secretName: kubesecrets

Wordpress

For my Wordpress sites, I need php extensions not provided by the official image. The Dockerfile and image I use are available in a public GitHub repository:

 Below is an example of a Wordpress YAML definition I use:

Click to expand code
#######################################
# Services
#######################################
---
apiVersion: v1
kind: Service
metadata:
  name: my_site
  namespace: prod
spec:
  selector:
    app: my_site
  type: LoadBalancer
  ports:
    - protocol: TCP
      port: 8001 # exposed port for
      targetPort: 80 # targeting port on the container
      nodePort: 30200 # external IP port from 30000 till 32767 range

#######################################
# volumes
#######################################
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: my_site-data-pv-claim
  namespace: prod
  labels:
    app: my_site
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 6500M
  storageClassName: local-storage
  volumeName: my_site-data-pv
---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: my_site-data-pv
  namespace: prod
spec:
  capacity:
    storage: 6500M
  volumeMode: Filesystem
  accessModes:
  - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-storage
  claimRef:
    name: my_site-data-pv-claim
    namespace: prod
  local:
    path: /media/pv_my_site
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: kubernetes.io/hostname
          operator: In
          values:
          - k3s-master-1
          - k3s-master-2

#######################################
# containers
#######################################
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my_site
  namespace: prod
  labels:
    app: my_site
spec:
  replicas: 1 # how many replicas of pods we want to create
  selector:
    matchLabels:
      app: my_site
  template:
    metadata:
      namespace: prod
      labels:
        app: my_site # service will look for this label
    spec:
      containers:
      - name: my_site
        image: wordpress:v2024.11.17
        ports:
        - containerPort: 80
        env:
        - name: WORDPRESS_DB_HOST
          value: mariadb-internal-service
        - name: WORDPRESS_DB_NAME
          value: www.my_site.org
        - name: WORDPRESS_DB_PASS
          valueFrom:
            secretKeyRef:
              name: kubesecrets
              key: my_sitepwd
        - name: WORDPRESS_DB_USER
          value: my_site
        - name: NGINX_SERVER_NAME
          value: my_site
        - name: SITEMAP_DOMAIN
          value: www.my_site.org
        - name: WORDPRESS_DEBUG
          value: 'false'
        - name: WORDPRESS_AUTH_KEY
          value: xxx
        - name: WORDPRESS_SECURE_AUTH_KEY
          value: xxx
        - name: WORDPRESS_LOGGED_IN_KEY
          value: xxx
        - name: WORDPRESS_NONCE_KEY
          value: xxx
        - name: WORDPRESS_AUTH_SALT
          value: xxx
        - name: WORDPRESS_SECURE_AUTH_SALT
          value: xxx
        - name: WORDPRESS_LOGGED_IN_SALT
          value: xxx
        - name: WORDPRESS_NONCE_SALT
          value: xxx
        volumeMounts:
        - name: my_site-data-pv
          mountPath: /srv/http/wordpress/wp-content
      volumes:
      - name: my_site-data-pv
        persistentVolumeClaim:
          claimName: my_site-data-pv-claim
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                - key: pv
                  operator: In
                  values:
                  - enabled
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                - key: speed
                  operator: In
                  values:
                  - fast

Issues encountered so far

As of now, after a few months of operation, I’ve encountered some issues, and all of them are hardware- or low-level related (e.g., driver issues). The table below lists these issues and the actions I took to resolve them:

Issue Action
USB nic (r8153) stopped working due to “Hardware Unit Hang” (reported by dmesg) Disabled TCP fragmentation offload. For now I added a line on rc.local file on all node (ethtool -K enx00e04c6800a0 sg off)

I may set this on /etc/network/interfaces in the future

One USB NIC get reset by the kernel from time to time on node-1, which makes the vmbr1 to stop working even if it is up This triggers an alert from Zabbix. For now, I manually reset the interface and add it back into bond0:
ifdown enx00e04c6800a0 && ifup enx00e04c6800a0
ip link set enx00e04c680a0d master bond0

I may add some udev rules to handle this automatically, but since only one USB NIC has this problem, it might be best to “keep an eye” on the alerts. The NIC might be defective and could stop working soon.

JMB582 controller (SATA) reported some failure (eg. ” failed command: READ FPDMA QUEUED”) This error is pretty common and usually caused by a bad SATA cable. I replaced it, and I haven’t seen the error occur again.

Questions or comments?

Join the discussion on Discord, ask a question or share your experience.

Discuss on Discord
  1. a81b8cac-b6c5-4d6b-b0fd-c47bb4749d68-wmGitOps driven Openhab in k3s
  2. ChatGPT Image Jan 30, 2026, 07_13_27 PMXMG Pro 16 m25 under Linux: Maximum GPU Power and Gaming Performance
  3. zabbix_statsLinux : Monitoring And supervision with Zabbix