Homelab as Code: Packer + Terraform + Ansible

#automation#homelab

Automate your homelab from scratch — VM templating with Packer, provisioning with Terraform, and service deployment with Ansible on Proxmox.

Warning

Archived. The GitHub repo is archived and no longer maintained. My infrastructure has since moved to Talos + FluxCD GitOps. This stays up as a reference for the Packer + Terraform + Ansible approach on Proxmox, which is still a reasonable way to do it.

One command, and a Proxmox host goes from empty to a running VM with Docker, Portainer, Traefik and a media stack on it. Packer builds the template, Terraform provisions from it, Ansible configures what’s inside.

The interesting part isn’t any one of those tools. It’s that the handoffs between them are also code — Terraform writes the Ansible inventory itself, so there’s no step where you copy an IP address from one window into another.

Homelab Architecture Diagram

Source: meroxdotdev/homelab-as-code

What you’ll need

  • Proxmox VE, with an API token
  • A fresh LXC to run the deploy script from — it installs packages, so don’t use one you care about
  • A DNS server for the service hostnames (Pi-hole, Unbound, Technitium — whatever you already run)
Directory What it does
packer/ Builds an Ubuntu template on Proxmox
terraform/ Provisions the VM from that template, networking and storage included
ansible/ Docker, Portainer, Traefik, optional NFS mounts
configs/docker/ The service stacks themselves — Homepage, Traefik, media

Running it

On a clean LXC:

Terminal window
bash -c "$(curl -fsSL https://raw.githubusercontent.com/meroxdotdev/homelab-as-code/refs/heads/master/deploy_homelab.sh)"

It asks two questions before doing anything. First, whether your repo is public or private — for a private one it generates an SSH key and waits while you add it as a deploy key. For the public tutorial repo, https://github.com/meroxdotdev/homelab-as-code.git works as-is.

Second, whether you’ve already edited the config files. Answer no and it exits so you can do that — everything below is what it’s asking about. Config files land in /home/homelab/.


Packer

credentials.pkr.hcl holds the Proxmox API token. It does not go in a public repository:

proxmox_api_url = "https://your-proxmox-ip-or-fqdn/api2/json"
proxmox_api_token_id = "terraform_user@pam!homelab"
proxmox_api_token_secret = "your-proxmox-api-token-secret"

Generating the token is this part of Christian Lempa’s video; his boilerplates repo has more Packer examples than mine does.

The plugin block:

packer {
required_plugins {
proxmox-iso = {
version = ">= 1.0.0"
source = "github.com/hashicorp/proxmox"
}
}
}

Then two placeholders in the template definition — YOUR_PROXMOX_NODE_NAME, and YOUR_IP_DEPLOYMENT_MACHINE for the machine running the script — and YOUR_SSH_KEY in the cloud-init user data, which is the key homelab_deploy.sh generated for you.


Terraform

Same credentials, plus host-level access for the provider:

proxmox_api_url = "https://your-proxmox-ip-or-fqdn/api2/json"
proxmox_api_token_id = "terraform_user@pam!homelab"
proxmox_api_token_secret = "your-proxmox-api-token-secret"
proxmox_host = "proxmox-host-ip"
proxmox_user = "proxmox-user"
proxmox_password = "proxmox-password"

In the VM module, replace YOUR_PROXMOX_NODE_NAME, IP_MACHINE (the VM’s address), GateWay_IP, IP_DEPLOYMENT (same as IP_MACHINE) and YOUR_SSH_KEY. The same file also sets locales, root SSH login and key auth, and holds the trigger that hands off to Ansible.

Not using NFS? Comment this block out, or the run fails at the last step on a mount that can’t happen:

resource "null_resource" "run_ansible_docker" {
depends_on = [local_file.ansible_inventory]
provisioner "local-exec" {
command = "LC_ALL=C.UTF-8 LANG=C.UTF-8 ansible-playbook -i ../ansible/inventory/inventory.ini ../ansible/roles/nfs_mount/main.yml"
}
}

Ansible

There is no inventory to write. Terraform generates it after the VM answers, which is why nothing in the pipeline needs to know the IP in advance:

resource "local_file" "ansible_inventory" {
depends_on = [time_sleep.wait_1_minute]
content = <<EOT
[docker]
docker-01 ansible_host=IP_DEPLOYMENT ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_rsa
EOT
filename = "../ansible/inventory/inventory.ini"
}

roles/docker installs Docker, Compose, Portainer, Homepage and Traefik, one .yml per service. If you are mounting an NFS share, roles/nfs_mount needs two substitutions — REPLACE_this_with_your_nfs_server_ip_address and REPLACE_this_with_your_nfs_path — leaving a line shaped like this:

Terminal window
172.20.0.254:/volume1/Server/Data/media_nas/ /media nfs rw,hard,intr 0 0

The service configs

Homepage needs nothing. If you’re bringing an existing config, extract the archive, drop your files in, re-archive. It ends up at /home/homepage/ on the VM.

Media stack — every Host rule in the labels section has to match your DNS. The repo ships .local names:

- "traefik.http.routers.jellyseer-media.rule=Host(`jellyseer.local`)"
- "traefik.http.routers.jellyseer-media.rule=Host(`jellyseer.merox.cloud`)"

Every service in the stack, not just this one.

Traefik — four files, and you can edit them locally before archiving or on the VM at /home/traefik/ afterwards:

File Change
docker-compose.yaml All four occurrences of yourdomain.com. Provider is Cloudflare
.env Dashboard credentials. They default to user / password
data/traefik.yml your-cloudflare@email.com → your account email
data/config.yml Optional — for putting non-Docker services behind SSL, like the Proxmox UI
Caution

Traefik will not start until /home/traefik/cf_api_token.txt exists on the VM with your Cloudflare API token in it. The file can’t be in the archive, so this is a manual step after deployment, and the container restart-loops until you do it:

Terminal window
/usr/local/bin/docker-compose up &

TechnoTim’s Traefik guide goes deeper on the certificate side than this post does.


What a finished run looks like

Portainer is on port 9000 of the VM’s address:

http://172.20.0.252:9000

Create the admin user there and you should be looking at the dashboard above.

The thing to be honest about with a pipeline like this: it is excellent at building, and it does nothing about drift afterwards. Nothing here notices when a container has been down for a week. Check on it monthly, or put the state in Git and let something reconcile it — which is exactly the reasoning that eventually moved me to Flux.