+++ title = "Building a Reusable Alpine LXC Template on Proxmox" date = 2026-07-22 tags = ["proxmox", "homelab", "alpine", "lxc"] +++ I run most of my homelab services as LXC containers on Proxmox, and lately I've been reaching for [Alpine Linux](https://alpinelinux.org/) because the base image is tiny. The stock `alpine-3.23-default` template Proxmox ships is great for that reason, but it comes with a catch: it has **no SSH server installed**. A fresh Alpine container boots with no `sshd`, so any automation that expects to connect over SSH (in my case Terraform provisioning the container and Ansible configuring it) has nothing to talk to on first boot. You can fix this by hand every time you create a container, but that gets old fast. A better option is to bake your own template once, with SSH already enabled, and reuse it for every new container. Proxmox makes this straightforward because a container backup (`vzdump`) can be dropped into the template cache and used as an OS template directly. The plan is: 1. Create a throwaway container from the stock Alpine template. 2. Install and enable `sshd` (plus anything else you want baked in). 3. Wipe the machine-specific bits so every clone comes up unique. 4. Dump it to the template cache with `vzdump` and rename it. Everything below runs on the Proxmox node as `root`. I'll use VMID `9000` for the scratch container — pick any free ID on your cluster. ## Step 1: Create a scratch container (Proxmox Host) Create a container from the stock template with a temporary network interface so we can reach the package repositories: ```sh pct create 9000 local:vztmpl/alpine-3.23-default_20260116_amd64.tar.xz \ --hostname alpine-tmpl --arch amd64 --ostype alpine \ --cores 1 --memory 512 --rootfs local-zfs:2 \ --net0 name=eth0,bridge=vmbr0,ip=dhcp --unprivileged 1 pct start 9000 ``` Adjust `--rootfs` and `--net0` to match your storage and bridge. Give it a moment to pick up a DHCP lease before continuing. ## Step 2: Install and enable SSH (LXC) Alpine uses OpenRC rather than systemd, so enabling a service at boot is `rc-update add default`. Rather than stringing everything into one `pct exec` one-liner, drop into an interactive shell with `pct enter` and run each command on its own so it's easier to follow along: ```sh pct enter 9000 ``` You're now inside the container as `root`. Refresh the package index first: ```sh apk update ``` Install OpenSSH plus a Python interpreter: ```sh apk add --no-cache openssh openssh-server python3 ``` > `python3` is optional. I install it because my Ansible playbooks need a Python interpreter on the target, and baking it in saves a slow package install on the first run. Leave it out if you want the smallest possible template. Enable `sshd` at boot: ```sh rc-update add sshd default ``` Without this, `sshd` is installed but never starts on the next boot — Alpine doesn't start services just because they're installed. Create the sshd drop-in directory: ```sh install -d /etc/ssh/sshd_config.d ``` Write the template's SSH policy: ```sh printf "MaxAuthTries 30\nPermitRootLogin prohibit-password\n" \ > /etc/ssh/sshd_config.d/10-template.conf ``` - `MaxAuthTries 30` saves me a headache: my SSH agent offers several keys on connect, and the default limit of 6 trips a *"too many authentication failures"* error before it gets to the right one. Bumping the limit avoids that during automated provisioning. - `PermitRootLogin prohibit-password` keeps root login key-only, which is how my provisioning tooling bootstraps the container before it creates a regular admin user. ## Step 3: Wipe the machine-specific state (LXC) This is the step that's easy to forget and important to get right. If you dump the container as-is, every clone made from it will share the same SSH host keys and machine ID which is both a security problem and a source of very confusing bugs. Remove the SSH host keys: ```sh rm -f /etc/ssh/ssh_host_* ``` This is safe: Alpine's `sshd` init script runs `ssh-keygen -A` on start if the host keys are missing, and Proxmox regenerates them when it creates a container from the template. Clear the machine ID: ```sh : > /etc/machine-id ``` Each clone needs its own machine ID; leaving the scratch container's ID in place would make every container created from this template report the same one. Remove any authorized keys that leaked in during testing: ```sh rm -f /root/.ssh/authorized_keys ``` This keeps the template clean the real keys get injected fresh when you create the actual container. Clear the package cache to keep the template small: ```sh rm -rf /var/cache/apk/* ``` Leave the container shell: ```sh exit ``` ## Step 4: Dump it to the template cache (Proxmox Host) Stop the container and strip its network interface so the scratch MAC address doesn't leak into the template: ```sh pct stop 9000 pct set 9000 --delete net0 ``` Then dump it straight into the template cache directory, compressed with zstd: ```sh vzdump 9000 --mode stop --compress zstd \ --dumpdir /var/lib/vz/template/cache/ ``` `vzdump` writes a file named like `vzdump-lxc-9000-.tar.zst`. Rename it to something descriptive that follows the same convention as the stock templates: ```sh mv /var/lib/vz/template/cache/vzdump-lxc-9000-*.tar.zst \ /var/lib/vz/template/cache/alpine-3.23-sshd-python_20260722_amd64.tar.zst ``` That's it. The tarball now shows up under the `local` storage as a usable CT template, both in the web UI and for `pct create`. Once the template exists you can delete the scratch container: ```sh pct destroy 9000 ``` ## Why the backup works as a template The trick that makes this whole thing painless is that Proxmox's `pct create` treats a `vzdump` backup and a "real" OS template the same way: it extracts the root filesystem and ignores any config baked into the archive. That's why stripping `net0` in step 4 is only cosmetic since the network, hostname, and MAC of your new containers come from the `pct create` command (or from Terraform, in my case), not from the template. The template only contributes the filesystem. ## Testing it Worth a quick sanity check before you rely on it. Create a container from the new template and confirm `sshd` is actually up: ```sh pct create 9001 local:vztmpl/alpine-3.23-sshd-python_20260722_amd64.tar.zst \ --hostname sshd-test --arch amd64 --ostype alpine \ --cores 1 --memory 512 --rootfs local-zfs:2 \ --net0 name=eth0,bridge=vmbr0,ip=dhcp --unprivileged 1 pct start 9001 ``` Drop in and check the service: ```sh pct enter 9001 ``` ```sh rc-service sshd status ``` Confirms `sshd` came up on boot without any manual intervention. ```sh ss -tln | grep :22 ``` Confirms it's actually listening. You should also see a freshly generated set of host keys under `/etc/ssh/`, proof that `ssh-keygen -A` regenerated them rather than reusing the ones baked into the template. Once you're happy, `pct destroy 9001` and point your tooling at the new template. Now every Alpine container I create comes up with SSH ready to go, and my provisioning runs end to end without a manual step in the middle. When Alpine ships a new point release I just run through these four steps again against the newer base template.