# Automating the Homelab: Packer, Terraform, and Handing Off to Flux

## Context

A few weeks back I had to redeploy my GitOps k3s homelab because of a network migration; new router, new subnet, and (long story, see the last post) no clean way to avoid it. Rather than fight k3s over its IP-bound TLS certs I just reprovisioned the VMs by hand, pointed Flux at them, and let it reapply everything from the repo. It worked pretty well for all intents and purposes.

But it also left me wanting more. To quote Kelsey Hightower I had "left room for greatness," and after that redeploy, the "room" became obvious. Every part of that recovery except the VM provisioning itself was already automated. Flux owned the cluster state. Git owned the source of truth. The only manual part left was me clicking through the Proxmox UI to spin up VMs.

You can see where this is headed. I set out to automate the provisioning of the VMs and k3s, and the hand-off to Flux, using Packer, Terraform, and cloud-init. In theory; this gets my entire GitOps homelab on any hardware running Proxmox. We'll see how theory holds up.

* * *

## The Mental Model

I work from Larry, my small headless Debian ssh server running on a re-purposed windows surface laptop with Terraform, Packer, `kubectl`, `kubectx/kubens`, Flux, my `age` key, and a `GITHUB_TOKEN` in the environment. Nothing sensitive ever touches the VMs themselves:

```plaintext
jumpbox (10.10.10.x, Debian 13 headless)
 ├─ terraform, packer, kubectl, kubectx, flux, age key, GITHUB_TOKEN env
 │
 ├─ [1] packer build  ──SSH──> temp build-VM on Proxmox ──> becomes template
 │
 ├─ [2] terraform apply ──API──> Proxmox clones template 
 │        cloud-init on each new VM: hostname, ssh key, swapoff, install k3s ONLY
 │
 └─ [3] terraform's local-exec (still running ON the jumpbox, as me):
          scp kubeconfig off the new node → point it at node's real IP
          flux bootstrap github  (uses my jumpbox's GITHUB_TOKEN, never the VM's)
          kubectl create secret sops-age  (uses my jumpbox's age key, never the VM's)
```

VMs get just enough to run k3s. Anything that needs a real credential (the GitHub token, the age key for SOPS) runs from the jumpbox via `local-exec`, never gets baked into an image or handed to a VM(however the k8s secret does live on the cluster - encrypted of course).

![](https://cdn.hashnode.com/uploads/covers/66a8d2d066ab7d9a89c8a733/6ad6144f-1a31-459d-92ea-2d9401bba1ae.jpg align="center")

* * *

## How It Fits Together

The repo is split the same way the workflow is:

```plaintext
homelab-automation/
├── packer/
│   ├── debian13-k3s.pkr.hcl      # builds the template
│   ├── http/preseed.cfg          # unattended Debian installer answers
│   └── scripts/provision.sh      # cloud-init/qemu-guest-agent + cleanup
└── terraform/
    ├── versions.tf
    ├── variables.tf
    ├── main.tf                   # clones the template per cluster
    ├── cloud-init.tf             # uploads rendered cloud-init as a Proxmox snippet
    ├── outputs.tf
    ├── terraform.tfvars.example
    └── templates/
        └── user-data.yaml.tftpl  # k3s install + flux bootstrap, per node
```

1.  **Packer** boots the Debian 13 netinst ISO on Proxmox, feeds it `http/preseed.cfg` for a fully unattended install (no swap partition, OpenSSH + sudo + curl from the start), runs `scripts/provision.sh` to install `qemu-guest-agent` and `cloud-init`, then generalizes the image (clearing machine-id, SSH host keys, and the cloud-init cache) before converting it into a Proxmox template. This means the template can not be used to manually provision, I created a seperate template for that.
    
2.  **Terraform** clones that template once per entry in `var.clusters` (`staging` and `production` by default), applying the VM hardware settings from my notes, and attaches a per-node cloud-init config rendered from `user-data.yaml.tftpl`.
    
3.  **cloud-init** on first boot of each clone, sets hostname/user/SSH keys, disables swap, installs k3s (`INSTALL_K3S_EXEC=--disable=helm-controller`, matching my notes). From that point, Flux owns the cluster.
    

One-time setup, from the jumpbox:

```bash
# Packer
cd packer
packer init .
packer build -var-file=variables.pkrvars.hcl debian13-k3s.pkr.hcl

# Terraform
cd ../terraform
cp terraform.tfvars.example terraform.tfvars   # fill in your values
export TF_VAR_proxmox_api_token="root@pam!terraform=xxxxxxxx-xxxx-..."
export TF_VAR_github_token="ghp_xxx"           # never put this in tfvars/git
terraform init
terraform plan
terraform apply
```

* * *

## Oldies But Still Goodies; Why Not Ansible

A lot of fellow homelabbers redeploy and manage infra/config with Ansible and custom shell scripts. Nothing wrong with that, but it's not GitOps and it's exactly what I've been trying to move away from. Between Flux and Kubernetes-native tooling, I don't see a need for it in the lab itself.

That said, I thought I'd keep Ansible around for "Day 2" ops. Adding a user to sudo, installing docker-compose, one-off custom builds on a node that already exists. That's a reasonable division of labor, and it's not a bad way to stay grounded in the "traditional" DevOps fundamentals while still leaning GitOps-first for anything that touches cluster state.

* * *

## Packer: The Golden Image

From the `packer/` directory:

```shell
packer init .

packer validate -var-file=variables.pkrvars.hcl debian13-k3s.pkr.hcl

packer build -on-error=ask -var-file=variables.pkrvars.hcl debian13-k3s.pkr.hcl
```

The commands above build a template meant for Terraform only, because the image is generalized and locked down, and it can't be cloned and booted for manual testing as previously mentioned.

To get a version I can poke at by hand, I had to:

*   keep the default user credentials in a separate provisioning script
    
*   override the template name and provisioning script via extra Packer variables:
    

```bash
packer build -on-error=ask -var-file=variables.pkrvars.hcl \
  -var 'vm_id=9001' \
  -var 'vm_name=debian13-manual-template' \
  -var 'provision_script=scripts/provision-manual.sh' \
  debian13-k3s.pkr.hcl
```

## Lessons From my Zettel Notes

A few things that came up while getting this far:

*   `packer validate` threw a handful of deprecation warnings and a missing-checksum warning. It still validated, but ignoring those would've just meant hitting the same bugs later so it was worth fixing up front rather than deferring.
    
*   `provision.sh` had a couple of package names wrong for Debian 13 specifically, which needed a quick correction.
    
*   The Terraform-only template is successful, but by design can't be manually cloned, which is exactly what the separate manual-template build args above are for.
    
    * * *
    

## Wrap Up

Packer's side of this is where I want it. A clean, generalized, golden Debian 13 image that Terraform can clone on demand. Next up is finishing the Terraform side of things, confirming the cloud-init → k3s → Flux bootstrap chain works end to end on a fresh clone, and, if it does, getting to the point where standing up this entire GitOps homelab really is one command away. Checkout my lab repo where I do my testing/experiments: [Github](https://github.com/angel-n-chavez/lab)

Not to be confused w/my actual homelab gitops repo: [Homelab](https://github.com/angel-n-chavez/homelab)

More to come.
