Felipe Martín

Building a Reusable Alpine LXC Template on Proxmox

I run most of my homelab services as LXC containers on Proxmox, and lately I’ve been reaching for Alpine Linux 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:

1
2
3
4
5
6
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 <service> 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:

1
pct enter 9000

You’re now inside the container as root. Refresh the package index first:

1
apk update

Install OpenSSH plus a Python interpreter:

1
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:

1
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:

1
install -d /etc/ssh/sshd_config.d

Write the template’s SSH policy:

1
2
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:

1
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:

1
: > /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:

1
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:

1
rm -rf /var/cache/apk/*

Leave the container shell:

1
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:

1
2
pct stop 9000
pct set 9000 --delete net0

Then dump it straight into the template cache directory, compressed with zstd:

1
2
vzdump 9000 --mode stop --compress zstd \
  --dumpdir /var/lib/vz/template/cache/

vzdump writes a file named like vzdump-lxc-9000-<timestamp>.tar.zst. Rename it to something descriptive that follows the same convention as the stock templates:

1
2
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:

1
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:

1
2
3
4
5
6
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:

1
pct enter 9001
1
rc-service sshd status

Confirms sshd came up on boot without any manual intervention.

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