1. 🧭 Scope Legend
Use these markers to distinguish fixed build instructions from values that identify this specific template.
2. 🧱 Template Build Inputs
AA:BB:CC:FF:FF:FF is the temporary MAC assigned to this template-build VM. Never power on another VM using the same MAC address at the same time. After the VM is converted to a template, clones must receive their own permanent MAC addresses.3. 📋 Final Template Profile
tmplt-ub-26-min-base9000Ubuntu Server 26.04 LTS — minimized installationAA:BB:CC:FF:FF:FF → 192.168.8.254192.168.8.4 — applied to clones only after prod-dns-01 is healthyQEMU Guest Agent, OpenSSH Server, UFW, Docker Engine, Docker CLI, containerd, Buildx, Docker Compose pluginUFW enabled · deny incoming · deny routed · allow outgoing · SSH/22 allowed only from 192.168.8.0/244. 💿 Download and Upload the Ubuntu Server ISO
Download the Ubuntu Server 26.04 LTS AMD64 live-server ISO from the official Ubuntu Server download page.
ubuntu-26.04-live-server-amd64.isoIn the Proxmox web interface:
- Select node pve.
- Select ISO-capable storage synology_iso.
- Open ISO Images.
- Click Upload.
- Select ubuntu-26.04-live-server-amd64.iso and wait for the upload to finish.
5. 🖥️ Create the Proxmox Source VM
5.1 General
- Node: pve
- VM ID: 9000
- Name: tmplt-ub-26-min-base
- Start at boot: Unchecked
5.2 OS
- Use CD/DVD disc image file: Selected
- Storage: synology_iso
- ISO image: ubuntu-26.04-live-server-amd64.iso
- Guest OS: Linux
- Version: 6.x - 2.6 Kernel
5.3 System
- Machine: q35
- BIOS: SeaBIOS
- SCSI Controller: VirtIO SCSI single
- QEMU Agent: Checked
- TPM: None
5.4 Disk
- Bus/Device: SCSI
- Storage: local-lvm
- Disk size: 40 GiB
- Cache: Default
- Discard: Checked
- IO thread: Checked
5.5 CPU and Memory
- Sockets: 1
- Cores: 2
- CPU type: host
- Memory: 4096 MiB
- Ballooning Device: Unchecked
5.6 Network
- Bridge: vmbr0
- Model: VirtIO (paravirtualized)
- VLAN tag: Blank
- MAC address: AA:BB:CC:FF:FF:FF
- Expected DHCP reservation: 192.168.8.254
Review the VM configuration and click Finish.
6. 🐧 Install Ubuntu Server 26.04 Minimal
- Start tmplt-ub-26-min-base and open the Proxmox console.
- Select Try or Install Ubuntu Server.
- Select the required language and keyboard layout.
- Select Ubuntu Server (minimized).
- Allow the network interface to use DHCP.
- Confirm that the expected address is 192.168.8.254.
- Leave the proxy blank.
- Accept the default Ubuntu archive mirror after its connectivity check succeeds.
- Select Use an entire disk; the default LVM layout is acceptable.
- Do not enable disk encryption.
- Set the server name to tmplt-ub-26-min-base.
- Create administrative user acllc with a temporary installation password.
- Skip Ubuntu Pro for now.
- Select Install OpenSSH server.
- Do not import SSH identities.
- Do not select any featured server snaps.
- Complete the installation and reboot.
After installation, detach the ISO:
VM 9000 (tmplt-ub-26-min-base)
→ Hardware
→ CD/DVD Drive
→ Edit
→ Do not use any media7. 🔎 Verify the Fresh Ubuntu Installation
Log in through the Proxmox console as acllc and run:
cat /etc/os-release
uname -r
hostnamectl
ip -brief addressConfirm:
- The OS is Ubuntu 26.04 LTS.
- The hostname is tmplt-ub-26-min-base.
- The guest received the expected DHCP address 192.168.8.254.
8. 🌐 Replace MAC-Bound Netplan with Template-Safe DHCP
match.macaddress, AA:BB:CC:FF:FF:FF, or any other template-specific MAC address. The template uses DHCP; the router controls addresses through reservations for the unique MAC assigned to each VM.Run this from the Proxmox console while the source VM still has working network access.
8.1 Confirm the active interface name
ip -brief link
ip -brief address
ip route
PRIMARY_IFACE="$(ip -o route show default | awk '{print $5; exit}')"
echo "Detected primary interface: ${PRIMARY_IFACE}"
test -n "${PRIMARY_IFACE}" || {
echo "ERROR: No default-route interface was detected."
exit 1
}For this Proxmox VM, the interface is normally ens18. The commands use the interface detected on the source VM rather than assuming its name.
8.2 Prevent Cloud-Init from regenerating a MAC-bound network file
sudo install -d -m 0755 /etc/cloud/cloud.cfg.d
echo 'network: {config: disabled}' | \
sudo tee /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg >/dev/null
sudo cat /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg8.3 Replace the installer-generated Netplan YAML
PRIMARY_IFACE="$(ip -o route show default | awk '{print $5; exit}')"
test -n "${PRIMARY_IFACE}" || {
echo "ERROR: No default-route interface was detected."
exit 1
}
sudo rm -f /etc/netplan/*.yaml
sudo tee /etc/netplan/01-template-dhcp.yaml >/dev/null <<EOF
network:
version: 2
renderer: networkd
ethernets:
${PRIMARY_IFACE}:
dhcp4: true
dhcp6: false
EOF
sudo chmod 600 /etc/netplan/01-template-dhcp.yaml
sudo cat /etc/netplan/01-template-dhcp.yaml8.4 Validate and apply the replacement configuration
sudo netplan generate
sudo netplan apply
ip -brief address
ip route
sudo netplan get8.5 Stop the build if any MAC-specific Netplan rule remains
if sudo grep -RniE 'macaddress|aa:bb:cc:ff:ff:ff' /etc/netplan; then
echo "ERROR: Template-specific MAC configuration still exists in /etc/netplan."
exit 1
else
echo "PASS: Netplan contains no MAC-specific match or template MAC address."
fi
if sudo netplan get | grep -qi 'macaddress'; then
echo "ERROR: Effective Netplan configuration is still MAC-bound."
sudo netplan get
exit 1
else
echo "PASS: Effective Netplan configuration is MAC-independent."
fi8.6 Reboot and prove DHCP survives without the MAC match
sudo rebootAfter the VM returns, log in through the Proxmox console and run:
ip -brief address
ip route
sudo netplan get
sudo grep -RniE 'macaddress|aa:bb:cc:ff:ff:ff' /etc/netplan || trueConfirm all of the following before continuing:
- The primary interface is UP.
- The source VM again receives 192.168.8.254 through DHCP.
- A default route exists through the router.
sudo netplan getshowsdhcp4: true.- No
macaddressentry orAA:BB:CC:FF:FF:FFappears under/etc/netplan.
8.7 Apply the internal DNS resolver after the DNS server is healthy
prod-dns-01. If the template is permanently pinned to 192.168.8.4 before BIND is installed and healthy, the DNS server clone can point to itself too early and every other new clone can lose name resolution while the DNS tier is still unavailable.Use this rebuild order:
- Keep the base template on normal DHCP-provided DNS during template creation and initial bootstrap.
- Create
prod-dns-01first and configure BIND. - Verify that
192.168.8.4resolves internal records and forwards public queries. - Apply the following Netplan override through the common Ansible baseline to
prod-dns-01and every later clone. - Do not configure public resolvers beside
192.168.8.4; public forwarding belongs in BIND.
After prod-dns-01 is healthy, this is the exact clone-side configuration Ansible should enforce:
set -euo pipefail
INTERNAL_DNS_SERVER="192.168.8.4"
PRIMARY_IFACE="$(ip -o route show default | awk '{print $5; exit}')"
test -n "${PRIMARY_IFACE}" || {
echo "ERROR: No default-route interface was detected."
exit 1
}
# Prove the internal resolver works before replacing DHCP-provided resolvers.
dig "@${INTERNAL_DNS_SERVER}" harbor.aspireclan.com A +short | grep -Eq '^[0-9]+(.[0-9]+){3}$'
dig "@${INTERNAL_DNS_SERVER}" ubuntu.com A +short | grep -Eq '^[0-9]+(.[0-9]+){3}$'
sudo tee /etc/netplan/99-internal-dns.yaml >/dev/null <<EOF
network:
version: 2
ethernets:
${PRIMARY_IFACE}:
dhcp4: true
dhcp4-overrides:
use-dns: false
nameservers:
addresses:
- ${INTERNAL_DNS_SERVER}
EOF
sudo chmod 600 /etc/netplan/99-internal-dns.yaml
sudo netplan generate
sudo netplan apply
sudo resolvectl revert "${PRIMARY_IFACE}" || true
sudo resolvectl flush-caches
resolvectl status "${PRIMARY_IFACE}"
resolvectl query harbor.aspireclan.com
getent ahostsv4 harbor.aspireclan.com
CURRENT_DNS_SERVERS="$(resolvectl dns "${PRIMARY_IFACE}" | sed -E 's/^Link [0-9]+ ([^)]*):[[:space:]]*//')"
if [ "${CURRENT_DNS_SERVERS}" != "${INTERNAL_DNS_SERVER}" ]; then
echo "ERROR: Expected only ${INTERNAL_DNS_SERVER}, but found: ${CURRENT_DNS_SERVERS}"
exit 1
fi
echo "PASS: ${PRIMARY_IFACE} uses only ${INTERNAL_DNS_SERVER}."prod-dns-01 health check.9. ⬆️ Update Ubuntu Completely
sudo apt update
sudo apt full-upgrade -y
sudo apt autoremove --purge -y
sudo apt clean
sudo rebootLog back in after the reboot.
10. 🧰 Install Essential Utilities
sudo apt update
sudo apt install -y qemu-guest-agent openssh-server ca-certificates curl wget gnupg git jq unzip zip rsync vim nano htop tree lsof net-tools dnsutils iputils-ping traceroute tcpdump bash-completion ufwsudo systemctl enable --now qemu-guest-agent
sudo systemctl enable --now ssh
systemctl is-enabled qemu-guest-agent
systemctl is-active qemu-guest-agent
systemctl is-enabled ssh
systemctl is-active sshEach service should report enabled and active.
10.1 Configure the mandatory fail-closed UFW baseline
sudo ufw --force reset
sudo ufw default deny incoming
sudo ufw default deny routed
sudo ufw default allow outgoing
sudo ufw allow from 192.168.8.0/24 to any port 22 proto tcp comment 'Allow SSH from management network'
sudo ufw logging low
sudo ufw --force enable
sudo ufw status verboseExpected baseline:
- Status: active
- Incoming: deny
- Routed: deny
- Outgoing: allow
- SSH: TCP 22 allowed only from
192.168.8.0/24 - Application ports: blocked until Ansible opens the ports required by the VM role
10.2 Verify SSH after enabling UFW
From the Ansible controller or Windows/PuTTY machine inside 192.168.8.0/24, open a new connection while keeping the Proxmox console available.
ssh -i ~/.ssh/id_ed25519_ansible -o IdentitiesOnly=yes acllc@192.168.8.254 'hostnamectl --static && sudo -n ufw status verbose'If this second connection fails, use the Proxmox console to correct the source CIDR before proceeding. Do not convert a template until SSH succeeds through the enabled firewall.
11. 🧹 Remove Conflicting Docker Packages
sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc 2>/dev/null || true12. 🐳 Install Docker Engine and Docker Compose
12.1 Add Docker's signing key
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc12.2 Add Docker's Ubuntu repository
sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
apt-cache policy docker-ceConfirm that the Docker packages are available from Docker's repository for Ubuntu Resolute.
12.3 Install Docker
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin12.4 Enable Docker services
sudo systemctl enable --now containerd
sudo systemctl enable --now docker
systemctl is-enabled docker
systemctl is-active docker
systemctl is-enabled containerd
systemctl is-active containerd0.0.0.0:hostPort:containerPort mappings, bind internal services to 127.0.0.1 or the required private interface where possible, and validate exposure from another host. The template contains no persistent published container ports.12.5 Allow the administrative user to run Docker
sudo usermod -aG docker "acllc"
getent group dockerLog out and back in once before testing Docker without sudo.
13. ✅ Verify Docker and Docker Compose
docker version
docker compose version
docker buildx version
containerd --versiondocker compose. Do not install or use the legacy standalone docker-compose command.13.1 Create and run a temporary Compose test
cat >/tmp/compose-test.yaml <<'EOF'
services:
hello:
image: hello-world:latest
EOF
docker compose -f /tmp/compose-test.yaml up --abort-on-container-exitConfirm that the container prints Hello from Docker!.
13.2 Remove the temporary Compose test
docker compose -f /tmp/compose-test.yaml down --rmi all
rm -f /tmp/compose-test.yaml
docker ps -a
docker images14. 🔑 Prepare Ansible SSH Access and Passwordless Sudo
Run this on the template-build VM before final cleanup. Replace the placeholder value with the full single-line public key from the Ansible controller, usually prod-terraform-deploy-02:~/.ssh/id_ed25519_ansible.pub.
14.1 Get the public key from the Ansible controller
Run this on prod-terraform-deploy-02. If the key already exists, only the cat command is needed.
mkdir -p ~/.ssh
chmod 700 ~/.ssh
if [ ! -f ~/.ssh/id_ed25519_ansible ]; then
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_ansible -C "prod-terraform-deploy-02 Ansible automation" -N ""
fi
ls -l ~/.ssh/id_ed25519_ansible*
cat ~/.ssh/id_ed25519_ansible.pubCopy the full output from cat ~/.ssh/id_ed25519_ansible.pub. It must start with ssh-ed25519, ssh-rsa, or ecdsa-sha2-.
14.2 Install the Ansible public key for acllc
Run this on the template-build VM while logged in as acllc. This block validates the key format, installs it into ~/.ssh/authorized_keys, fixes permissions, and avoids duplicate key entries.
ANSIBLE_PUBLIC_KEY='PASTE_FULL_SINGLE_LINE_PUBLIC_KEY_HERE'
install_ansible_public_key() {
if [[ ! "$ANSIBLE_PUBLIC_KEY" =~ ^(ssh-ed25519|ssh-rsa|ecdsa-sha2-[^[:space:]]+)[[:space:]]+[A-Za-z0-9+/=]+([[:space:]].*)?$ ]]; then
echo "ERROR: ANSIBLE_PUBLIC_KEY does not look like a valid SSH public key." >&2
echo "Paste the full single-line public key from:" >&2
echo "cat ~/.ssh/id_ed25519_ansible.pub" >&2
return 1
fi
echo "Ansible public key format looks valid."
install -d -m 700 "$HOME/.ssh"
touch "$HOME/.ssh/authorized_keys"
chmod 600 "$HOME/.ssh/authorized_keys"
KEY_TYPE="$(printf '%s\n' "$ANSIBLE_PUBLIC_KEY" | awk '{print $1}')"
KEY_DATA="$(printf '%s\n' "$ANSIBLE_PUBLIC_KEY" | awk '{print $2}')"
if awk -v key_type="$KEY_TYPE" -v key_data="$KEY_DATA" '$1 == key_type && $2 == key_data { found = 1 } END { exit !found }' "$HOME/.ssh/authorized_keys"
then
echo "Ansible public key is already installed."
else
printf '%s\n' "$ANSIBLE_PUBLIC_KEY" >> "$HOME/.ssh/authorized_keys"
echo "Ansible public key installed."
fi
chmod 600 "$HOME/.ssh/authorized_keys"
chown -R "$USER:$USER" "$HOME/.ssh"
echo
echo "Installed authorized keys:"
awk 'NF {print NR ": " $1 " ... " $NF}' "$HOME/.ssh/authorized_keys"
echo
ls -ld "$HOME/.ssh"
ls -l "$HOME/.ssh/authorized_keys"
}
install_ansible_public_key14.3 Configure passwordless sudo for acllc
sudo tee /etc/sudoers.d/90-acllc-ansible >/dev/null <<EOF
acllc ALL=(ALL) NOPASSWD:ALL
EOF
sudo chmod 440 /etc/sudoers.d/90-acllc-ansible
sudo visudo -cf /etc/sudoers.d/90-acllc-ansible
sudo -l -U acllc14.4 Verify SSH daemon and sudo readiness
sudo systemctl enable --now ssh
systemctl is-enabled ssh
systemctl is-active ssh
sudo -u acllc test -d /home/acllc/.ssh
sudo -u acllc test -f /home/acllc/.ssh/authorized_keys
sudo -u acllc test -r /home/acllc/.ssh/authorized_keys
sudo -n true && echo "PASS: passwordless sudo works for current sudo-capable session."From the Ansible controller, verify this template-build VM before final cleanup. Replace 192.168.8.254 if the template-build VM currently has a different temporary address.
ssh -i ~/.ssh/id_ed25519_ansible -o IdentitiesOnly=yes acllc@192.168.8.254 'hostnamectl --static && sudo -n true && echo "Ansible SSH and sudo ready"'After future clones boot, the Ansible controller should be able to connect with:
ssh -i ~/.ssh/id_ed25519_ansible -o IdentitiesOnly=yes acllc@<CLONE_RESERVED_IP> 'hostnamectl --static && sudo -n true && echo "Ansible SSH and sudo ready"'15. 🔐 Prepare Unique SSH Host Keys for Future Clones
sudo tee /etc/systemd/system/regenerate-ssh-host-keys.service >/dev/null <<'EOF'
[Unit]
Description=Generate SSH host keys when missing
Before=ssh.service
ConditionPathExists=!/etc/ssh/ssh_host_ed25519_key
[Service]
Type=oneshot
ExecStart=/usr/bin/ssh-keygen -A
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable regenerate-ssh-host-keys.service
systemctl is-enabled regenerate-ssh-host-keys.serviceDo not delete the current SSH host keys until the final cleanup step.
16. 🧪 Run Final Service Verification
systemctl --no-pager --full status docker containerd qemu-guest-agent ssh ufw
sudo ufw status verbose
sudo ufw status | grep -q '^Status: active' && echo 'PASS: UFW is active.'
sudo ss -lntup
docker --version
docker compose version
docker buildx versionDocker, containerd, QEMU Guest Agent, and SSH must show active (running). UFW must report Status: active, default-deny inbound/routed policies, and only the approved SSH rule at this base-template stage.
17. 🔄 Configure Automatic Updates, Conditional Reboot, and Health Checks
update → full-upgrade → autoremove → clean sequence only once per week during a maintenance window. Reboot only when /run/reboot-required exists. A daily unconditional full upgrade and reboot is intentionally not configured.timedatectl. Use a window in which Terraform, Ansible, GitHub Actions, and deployments are not expected to be running.The Proxmox template itself does not run while powered off. These services and timers are installed in the template so every future clone inherits the same maintenance policy.
17.1 Enable daily unattended Ubuntu updates
sudo apt update
sudo apt install -y unattended-upgrades
sudo tee /etc/apt/apt.conf.d/20auto-upgrades >/dev/null <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
EOF
sudo tee /etc/apt/apt.conf.d/52-ac-unattended-upgrades >/dev/null <<'EOF'
Unattended-Upgrade::Automatic-Reboot "false";
EOF
sudo systemctl enable --now apt-daily.timer
sudo systemctl enable --now apt-daily-upgrade.timer
apt-config dump | grep -E 'APT::Periodic::(Update-Package-Lists|Unattended-Upgrade|AutocleanInterval)'
systemctl list-timers apt-daily.timer apt-daily-upgrade.timer --allDaily unattended upgrades use Ubuntu's configured unattended-upgrades policy. Automatic reboot is explicitly disabled. Complete upgrades and reboots are controlled by the weekly maintenance job below.
17.2 Create the critical-service health-check script
sudo tee /usr/local/sbin/ac-service-healthcheck.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
LOG_TAG="ac-service-healthcheck"
FAILED=0
log() {
echo "$*"
logger -t "$LOG_TAG" -- "$*"
}
log "Starting post-maintenance health check."
NETWORK_READY=0
for attempt in $(seq 1 18); do
if ip -4 route show default | grep -q '^default ' &&
getent ahostsv4 ubuntu.com >/dev/null 2>&1; then
NETWORK_READY=1
break
fi
log "Waiting for default route and DNS: attempt $attempt of 18."
sleep 10
done
if [ "$NETWORK_READY" -ne 1 ]; then
log "ERROR: Default route or DNS did not become ready."
FAILED=1
else
log "PASS: Default route and DNS are available."
fi
for service in ssh docker containerd qemu-guest-agent; do
if ! systemctl is-active --quiet "$service"; then
log "WARN: $service is not active. Attempting restart."
systemctl restart "$service" || true
sleep 5
fi
if systemctl is-active --quiet "$service"; then
log "PASS: $service is active."
else
log "ERROR: $service is not active."
FAILED=1
fi
done
if ufw status | grep -q '^Status: active'; then
log "PASS: UFW is active."
else
log "ERROR: UFW is not active."
FAILED=1
fi
if docker info >/dev/null 2>&1; then
log "PASS: Docker daemon responds to docker info."
else
log "ERROR: Docker daemon did not respond to docker info."
FAILED=1
fi
if [ "$FAILED" -ne 0 ]; then
log "FAILED: One or more post-maintenance checks failed."
exit 1
fi
rm -f /var/lib/ac-maintenance/reboot-pending
log "SUCCESS: All post-maintenance checks passed."
EOF
sudo chmod 0755 /usr/local/sbin/ac-service-healthcheck.sh
sudo bash -n /usr/local/sbin/ac-service-healthcheck.sh
sudo /usr/local/sbin/ac-service-healthcheck.sh17.3 Create the weekly maintenance script
sudo tee /usr/local/sbin/ac-weekly-maintenance.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
LOG_TAG="ac-weekly-maintenance"
LOCK_FILE="/run/lock/ac-weekly-maintenance.lock"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
logger -t "$LOG_TAG" -- "Another maintenance run is already active. Exiting."
exit 0
fi
log() {
echo "$*"
logger -t "$LOG_TAG" -- "$*"
}
export DEBIAN_FRONTEND=noninteractive
log "Starting weekly package maintenance."
apt-get -o DPkg::Lock::Timeout=1800 update
apt-get -y -o DPkg::Lock::Timeout=1800 -o Dpkg::Options::="--force-confold" dist-upgrade
apt-get -y -o DPkg::Lock::Timeout=1800 autoremove --purge
apt-get clean
log "Package maintenance completed."
if [ -f /run/reboot-required ]; then
install -d -m 0755 /var/lib/ac-maintenance
date --iso-8601=seconds > /var/lib/ac-maintenance/reboot-pending
log "Ubuntu reports that a reboot is required. Rebooting now."
systemctl reboot --no-block
exit 0
fi
log "No reboot is required. Running the health check now."
/usr/local/sbin/ac-service-healthcheck.sh
EOF
sudo chmod 0755 /usr/local/sbin/ac-weekly-maintenance.sh
sudo bash -n /usr/local/sbin/ac-weekly-maintenance.shThe script uses apt-get dist-upgrade, the script-oriented equivalent of apt full-upgrade. It waits up to 30 minutes for another APT or dpkg operation to release its lock.
17.4 Create the weekly systemd service and timer
sudo tee /etc/systemd/system/ac-weekly-maintenance.service >/dev/null <<'EOF'
[Unit]
Description=Weekly Ubuntu package maintenance
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/ac-weekly-maintenance.sh
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
EOF
sudo tee /etc/systemd/system/ac-weekly-maintenance.timer >/dev/null <<'EOF'
[Unit]
Description=Run weekly Ubuntu package maintenance
[Timer]
OnCalendar=Sun *-*-* 03:00:00
RandomizedDelaySec=15m
AccuracySec=1m
Persistent=false
Unit=ac-weekly-maintenance.service
[Install]
WantedBy=timers.target
EOFPersistent=false prevents a missed maintenance window from running immediately when a newly cloned VM boots for the first time. An always-on VM runs at the next scheduled Sunday maintenance window.
17.5 Create the post-reboot health-check service
sudo tee /etc/systemd/system/ac-post-reboot-healthcheck.service >/dev/null <<'EOF'
[Unit]
Description=Verify critical services after maintenance reboot
ConditionPathExists=/var/lib/ac-maintenance/reboot-pending
Wants=network-online.target
After=network-online.target ssh.service docker.service containerd.service qemu-guest-agent.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/ac-service-healthcheck.sh
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable ac-post-reboot-healthcheck.service
sudo systemctl enable --now ac-weekly-maintenance.timer17.6 Verify the maintenance configuration
systemctl is-enabled apt-daily.timer
systemctl is-enabled apt-daily-upgrade.timer
systemctl is-enabled ac-weekly-maintenance.timer
systemctl is-active ac-weekly-maintenance.timer
systemctl is-enabled ac-post-reboot-healthcheck.service
systemctl list-timers apt-daily.timer apt-daily-upgrade.timer ac-weekly-maintenance.timer --all
systemd-analyze calendar 'Sun *-*-* 03:00:00'
sudo bash -n /usr/local/sbin/ac-service-healthcheck.sh
sudo bash -n /usr/local/sbin/ac-weekly-maintenance.sh
sudo /usr/local/sbin/ac-service-healthcheck.sh17.7 Perform one end-to-end maintenance test before cleanup
sudo systemctl start ac-weekly-maintenance.serviceAfter the command completes—or after the VM returns from a required reboot—verify:
systemctl is-active ssh docker containerd qemu-guest-agent
sudo ufw status | grep -q '^Status: active' && echo "UFW health check passed."
docker info >/dev/null && echo "Docker health check passed."
systemctl --no-pager --full status ac-weekly-maintenance.timer
systemctl --no-pager --full status ac-post-reboot-healthcheck.service || true
sudo journalctl -u ac-weekly-maintenance.service -n 100 --no-pager
sudo journalctl -u ac-post-reboot-healthcheck.service -n 100 --no-pager
test ! -e /var/lib/ac-maintenance/reboot-pending &&
echo "PASS: No unresolved post-reboot health-check marker remains."18. 🧼 Perform Final Template Cleanup
18.1 Remove Docker test artifacts
docker system prune --all --force --volumes18.2 Clean APT data and temporary files
sudo apt autoremove --purge -y
sudo apt clean
sudo rm -rf /var/lib/apt/lists/*
sudo rm -rf /tmp/*
sudo rm -rf /var/tmp/*18.3 Rotate and remove accumulated journal entries
sudo journalctl --rotate
sudo journalctl --vacuum-time=1s18.4 Remove clone-sensitive SSH host keys
sudo rm -f /etc/ssh/ssh_host_*18.5 Reset the machine identity
sudo truncate -s 0 /etc/machine-id
sudo rm -f /var/lib/dbus/machine-id
sudo ln -s /etc/machine-id /var/lib/dbus/machine-id
sudo rm -f /var/lib/systemd/random-seed18.6 Clear shell history
history -c
rm -f ~/.bash_history
sudo rm -f /root/.bash_history19. ⏹️ Shut Down the Source VM
sync
sudo poweroffWait until Proxmox reports that VM 9000 is stopped.
20. 🔍 Perform Final Proxmox Checks
With VM 9000 stopped, verify:
- The Ubuntu ISO is detached.
- There is no Cloud-Init drive.
- The primary disk uses SCSI.
- The SCSI controller is VirtIO SCSI single.
- The network adapter uses VirtIO.
- QEMU Guest Agent is enabled in the Proxmox VM options.
- The VM name is tmplt-ub-26-min-base.
- The VM remains powered off.
21. 📦 Convert the VM to a Proxmox Template
- In the Proxmox web interface, right-click tmplt-ub-26-min-base.
- Select Convert to template.
- Confirm the conversion.
- Verify that the VM icon changes to the Proxmox template icon.
Final template name: tmplt-ub-26-min-base
Template VM ID: 9000
Template-build MAC: AA:BB:CC:FF:FF:FF22. 🏁 Finished State
- Ubuntu Server 26.04 LTS minimized installation
- Essential server and troubleshooting utilities
- OpenSSH Server
- UFW enabled with default-deny inbound and routed traffic
- SSH allowed only from 192.168.8.0/24
- QEMU Guest Agent
- Docker Engine and Docker CLI
- containerd
- Docker Buildx plugin
- Docker Compose plugin
- Template-safe machine identity and SSH host-key cleanup
- Ansible public-key access for the administrative user
- Passwordless sudo for the administrative user
- DHCP-provided DNS retained for safe first-boot and DNS-tier bootstrap
- A documented Ansible handoff to internal resolver 192.168.8.4 after
prod-dns-01becomes healthy