Skip to main content

Base Template Preparation (tmplt-ub-26-min-base)

1. 🧭 Scope Legend

Use these markers to distinguish fixed build instructions from values that identify this specific template.

1.COMMON · NO CHANGE NEEDEDRun exactly as documented for this reusable Ubuntu and Docker base-template build.
2.CHANGE PER TEMPLATE BUILDReview or change values that identify the Proxmox VM, storage, network, and installation account.

2. 🧱 Template Build Inputs

CHANGE PER TEMPLATE BUILDReview the Proxmox VM identity, ISO, storage, network, and resource values before starting. These inputs are stored locally in this browser.
MAC-address safety: 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

COMMON · NO CHANGE NEEDEDCHANGE PER TEMPLATE BUILDThis page prepares only the reusable minimized Ubuntu base with essential utilities, QEMU Guest Agent, Docker Engine, Buildx, Docker Compose, and a fail-closed UFW baseline.
Template Name:
tmplt-ub-26-min-base
Proxmox VM ID:
9000
Ubuntu Version:
Ubuntu Server 26.04 LTS — minimized installation
Temporary MAC / DHCP Reservation:
AA:BB:CC:FF:FF:FF → 192.168.8.254
Post-Bootstrap Internal Resolver:
192.168.8.4 — applied to clones only after prod-dns-01 is healthy
Installed Platform Components:
QEMU Guest Agent, OpenSSH Server, UFW, Docker Engine, Docker CLI, containerd, Buildx, Docker Compose plugin
Firewall Baseline:
UFW enabled · deny incoming · deny routed · allow outgoing · SSH/22 allowed only from 192.168.8.0/24
Intentionally excluded: Cloud-Init, static guest networking, role-specific firewall openings, monitoring, application files, reverse proxies, Docker daemon tuning, product-specific configuration, and a permanently pinned internal DNS resolver in the base template. The template includes only the mandatory fail-closed UFW baseline and SSH access from the configured management subnet. Ansible public-key access and passwordless sudo are intentionally included so clones are ready for automation.
Status and consistency update — June 16, 2026 · revision 2026-06-16.1: this revision keeps the existing page flow, retains the mandatory UFW fail-closed baseline, adds the post-bootstrap internal-DNS handoff and its rebuild-order safety rule, and changes the right anchor panel to the same 260px Docusaurus table-of-contents presentation used by the K8S CI/CD Infrastructure Overview page.
Updating an existing converted template: clone the existing Proxmox template to a temporary full VM, assign a temporary unique MAC and DHCP reservation, then begin at Section 10. Complete the UFW baseline in Section 10, run Sections 16 and 17 for verification and maintenance consistency, then continue with Sections 18 through 21 to clean, stop, check, and convert the replacement template. Never power on the original template and the temporary update VM with the same MAC address.

4. 💿 Download and Upload the Ubuntu Server ISO

CHANGE PER TEMPLATE BUILDDownload the Ubuntu 26.04 LTS live-server AMD64 ISO and upload it to Proxmox storage that supports ISO content.

Download the Ubuntu Server 26.04 LTS AMD64 live-server ISO from the official Ubuntu Server download page.

Expected ISO Filename:
ubuntu-26.04-live-server-amd64.iso

In the Proxmox web interface:

  1. Select node pve.
  2. Select ISO-capable storage synology_iso.
  3. Open ISO Images.
  4. Click Upload.
  5. Select ubuntu-26.04-live-server-amd64.iso and wait for the upload to finish.

5. 🖥️ Create the Proxmox Source VM

CHANGE PER TEMPLATE BUILDCreate the temporary source VM that will be installed, cleaned, shut down, and converted into the final read-only template.

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
Do not add a Cloud-Init drive. This is a conventional Ubuntu installation and template-cleanup workflow.

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

COMMON · NO CHANGE NEEDEDCHANGE PER TEMPLATE BUILDBoot the VM from the uploaded ISO and perform a minimized Ubuntu Server installation using DHCP.
  1. Start tmplt-ub-26-min-base and open the Proxmox console.
  2. Select Try or Install Ubuntu Server.
  3. Select the required language and keyboard layout.
  4. Select Ubuntu Server (minimized).
  5. Allow the network interface to use DHCP.
  6. Confirm that the expected address is 192.168.8.254.
  7. Leave the proxy blank.
  8. Accept the default Ubuntu archive mirror after its connectivity check succeeds.
  9. Select Use an entire disk; the default LVM layout is acceptable.
  10. Do not enable disk encryption.
  11. Set the server name to tmplt-ub-26-min-base.
  12. Create administrative user acllc with a temporary installation password.
  13. Skip Ubuntu Pro for now.
  14. Select Install OpenSSH server.
  15. Do not import SSH identities.
  16. Do not select any featured server snaps.
  17. 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 media

7. 🔎 Verify the Fresh Ubuntu Installation

COMMON · NO CHANGE NEEDEDConfirm the installed operating system, kernel, hostname, identity, and DHCP address before installing packages.

Log in through the Proxmox console as acllc and run:

cat /etc/os-release
uname -r
hostnamectl
ip -brief address

Confirm:

  • 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

COMMON · NO CHANGE NEEDEDRemove the installer-generated MAC match before cloning. Ubuntu remains on DHCP, while the router assigns each VM its reserved address from that VM's unique Proxmox MAC.
Required template rule: the final Netplan configuration must not contain 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.cfg

8.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.yaml
The interface name remains in Netplan, which is expected. The MAC address does not. Each full clone keeps the predictable interface name but receives its own permanent Proxmox MAC.

8.4 Validate and apply the replacement configuration

sudo netplan generate
sudo netplan apply

ip -brief address
ip route
sudo netplan get

8.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."
fi

8.6 Reboot and prove DHCP survives without the MAC match

sudo reboot

After 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 || true

Confirm 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 get shows dhcp4: true.
  • No macaddress entry or AA:BB:CC:FF:FF:FF appears under /etc/netplan.

8.7 Apply the internal DNS resolver after the DNS server is healthy

Do not run this block on the base-template source VM during a from-scratch rebuild. The same template creates 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:

  1. Keep the base template on normal DHCP-provided DNS during template creation and initial bootstrap.
  2. Create prod-dns-01 first and configure BIND.
  3. Verify that 192.168.8.4 resolves internal records and forwards public queries.
  4. Apply the following Netplan override through the common Ansible baseline to prod-dns-01 and every later clone.
  5. 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}."
Why this is not baked into the template: the required setting is correct for operational clones, but unsafe as a bootstrap dependency when the DNS server itself is being recreated from this same template. The common Ansible role is the authoritative place to apply it after a mandatory prod-dns-01 health check.

9. ⬆️ Update Ubuntu Completely

COMMON · NO CHANGE NEEDEDInstall all available package and kernel updates before building the reusable software baseline.
sudo apt update
sudo apt full-upgrade -y
sudo apt autoremove --purge -y
sudo apt clean
sudo reboot

Log back in after the reboot.

10. 🧰 Install Essential Utilities

COMMON · NO CHANGE NEEDEDInstall only the essential server administration, troubleshooting, transfer, archive, editor, SSH, and Proxmox guest-agent packages.
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   ufw
sudo 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 ssh

Each service should report enabled and active.

10.1 Configure the mandatory fail-closed UFW baseline

Lockout prevention: the SSH allow rule is created before UFW is enabled. Keep the Proxmox console open, enable UFW, then prove that a second SSH/PuTTY session can connect before continuing. The template must fail closed: every inbound port other than approved SSH remains blocked until an Ansible role explicitly opens it.
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 verbose

Expected 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

COMMON · NO CHANGE NEEDEDRemove distribution or legacy packages that can conflict with Docker Engine packages from Docker's official Ubuntu repository.
sudo apt remove -y   docker.io   docker-compose   docker-compose-v2   docker-doc   podman-docker   containerd   runc 2>/dev/null || true

12. 🐳 Install Docker Engine and Docker Compose

COMMON · NO CHANGE NEEDEDConfigure Docker's official Ubuntu repository and install the current stable Docker Engine, CLI, containerd, Buildx, and Compose plugin.

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

12.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-ce

Confirm 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-plugin

12.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 containerd
Docker firewall rule: UFW remains the host baseline, but Docker can create packet-filtering and NAT rules for published container ports. Role-specific automation must avoid unnecessary 0.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 docker

Log out and back in once before testing Docker without sudo.

13. Verify Docker and Docker Compose

COMMON · NO CHANGE NEEDEDVerify the installed components and run one temporary Compose workload before cleaning the VM for template conversion.
docker version
docker compose version
docker buildx version
containerd --version
Use the current command docker 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-exit

Confirm 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 images

14. 🔑 Prepare Ansible SSH Access and Passwordless Sudo

COMMON · NO CHANGE NEEDEDInstall the Ansible controller public key for the administrative user and allow that user to run sudo without an interactive password. This makes Terraform-created clones ready for post-provisioning automation.
Security rule: paste only the Ansible public key into this template. Never copy the private key into the template, into cloned VMs, or into this documentation. The private key must remain only on the automation/controller VM that runs Terraform, GitHub Actions, and Ansible.

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

Copy 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_key

14.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 acllc

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

COMMON · NO CHANGE NEEDEDInstall a one-shot systemd service that generates SSH host keys when they are missing. The existing template keys are removed only during final cleanup.
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.service

Do not delete the current SSH host keys until the final cleanup step.

16. 🧪 Run Final Service Verification

COMMON · NO CHANGE NEEDEDConfirm that the reusable base services are enabled and operating before cleaning the source VM.
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 version

Docker, 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

COMMON · NO CHANGE NEEDEDInstall Ubuntu security updates daily without automatic reboot, run complete package maintenance weekly, reboot only when Ubuntu reports that it is required, and verify critical services after maintenance.
Recommended frequency: install Ubuntu security updates daily, but run the complete 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.
Schedule used below: Sunday at 3:00 AM in the VM's local timezone, with a random delay of up to 15 minutes. Verify the timezone with 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 --all

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

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

The 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
EOF

Persistent=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.timer

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

17.7 Perform one end-to-end maintenance test before cleanup

This performs the real update operation. If Ubuntu reports that a reboot is required, the VM reboots automatically. Run it from the Proxmox console and allow it to finish before continuing to final template cleanup.
sudo systemctl start ac-weekly-maintenance.service

After 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

COMMON · NO CHANGE NEEDEDRemove test artifacts, cached packages, transient files, logs, clone-sensitive SSH keys, and the current machine identity. Do not install or configure anything after this step.

18.1 Remove Docker test artifacts

docker system prune --all --force --volumes

18.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=1s

18.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-seed

18.6 Clear shell history

history -c
rm -f ~/.bash_history
sudo rm -f /root/.bash_history

19. ⏹️ Shut Down the Source VM

COMMON · NO CHANGE NEEDEDFlush pending writes and power off the source VM. Do not boot it again before conversion.
sync
sudo poweroff

Wait until Proxmox reports that VM 9000 is stopped.

20. 🔍 Perform Final Proxmox Checks

CHANGE PER TEMPLATE BUILDInspect the stopped VM before conversion and confirm that it contains only the expected virtual hardware.

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

CHANGE PER TEMPLATE BUILDConvert the cleaned, powered-off source VM into the final Proxmox read-only template.
  1. In the Proxmox web interface, right-click tmplt-ub-26-min-base.
  2. Select Convert to template.
  3. Confirm the conversion.
  4. 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:FF

22. 🏁 Finished State

COMMON · NO CHANGE NEEDEDCHANGE PER TEMPLATE BUILDStop here. Role-specific networking, firewall openings, credentials, automation, monitoring, and application configuration belongs to later clone-specific phases.
tmplt-ub-26-min-base now contains only:
  • 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-01 becomes healthy
Call it a day. Do not add private SSH keys, static IP configuration, a pre-bootstrap internal-DNS dependency, role-specific UFW openings, Docker daemon settings, monitoring, reverse proxies, or application workloads to this base template. Those changes belong to clone-specific Ansible roles after their dependencies are healthy.

Official References