190 lines
7.1 KiB
Markdown
190 lines
7.1 KiB
Markdown
+++
|
|
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.
|
|
|
|
<!--more-->
|
|
|
|
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 <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:
|
|
|
|
```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-<timestamp>.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.
|