<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ayush Verma</title>
    <description>The latest articles on DEV Community by Ayush Verma (@ayushverma8).</description>
    <link>https://dev.to/ayushverma8</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3838987%2F96e1794a-3969-4dd7-84c0-98a1d0b9715f.jpeg</url>
      <title>DEV Community: Ayush Verma</title>
      <link>https://dev.to/ayushverma8</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ayushverma8"/>
    <language>en</language>
    <item>
      <title>I automated my homelab so hard it started naming servers after Tarantino characters</title>
      <dc:creator>Ayush Verma</dc:creator>
      <pubDate>Sun, 22 Mar 2026 22:53:40 +0000</pubDate>
      <link>https://dev.to/ayushverma8/i-automated-my-proxmox-homelab-and-accidentally-gave-my-servers-tarantino-names-4993</link>
      <guid>https://dev.to/ayushverma8/i-automated-my-proxmox-homelab-and-accidentally-gave-my-servers-tarantino-names-4993</guid>
      <description>&lt;p&gt;This started because I was lazy.&lt;/p&gt;

&lt;p&gt;Every time I needed a new container on my Proxmox server, I'd open the web UI, click Create CT, pick a template, type a hostname, set the specs, configure networking, hit create, wait, then SSH in and install the same packages I always install. Fifteen minutes of clicking for something I do regularly.&lt;/p&gt;

&lt;p&gt;So one evening I wrote a &lt;code&gt;main.tf&lt;/code&gt; to automate it. That should have been the end of it. It wasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first version was embarrassing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"proxmox"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;pm_api_url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://192.168.2.250:8006/api2/json"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"proxmox_lxc"&lt;/span&gt; &lt;span class="s2"&gt;"test_container"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;target_node&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"proxmox"&lt;/span&gt;
  &lt;span class="nx"&gt;hostname&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"test-lxc"&lt;/span&gt;
  &lt;span class="nx"&gt;cores&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="nx"&gt;memory&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;API URL hardcoded in source. Specs as magic numbers. Want a second container? Copy-paste the whole block. It worked, but anyone looking at this in a code review would have things to say.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I kept going
&lt;/h2&gt;

&lt;p&gt;I split it into proper files. Moved every value into variables with defaults. Added validation so &lt;code&gt;cores = 0&lt;/code&gt; or &lt;code&gt;disk_size = "big"&lt;/code&gt; would fail at plan time instead of blowing up on the Proxmox API with some cryptic error.&lt;/p&gt;

&lt;p&gt;Then I switched to a map with &lt;code&gt;for_each&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;lxc_containers&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;testbox&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="nx"&gt;docker&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cores&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;memory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;disk_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"64G"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;testbox = {}&lt;/code&gt; gives you a container with sane defaults. Override only what's different. Adding a container is one line, not a resource block.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tarantino thing
&lt;/h2&gt;

&lt;p&gt;I needed hostnames. Didn't want &lt;code&gt;container-1&lt;/code&gt;, &lt;code&gt;container-2&lt;/code&gt;. That's boring and you can never remember which is which.&lt;/p&gt;

&lt;p&gt;So I threw 30 Tarantino characters into a list and used &lt;code&gt;random_shuffle&lt;/code&gt; to pick one per container, plus a &lt;code&gt;random_id&lt;/code&gt; hex suffix to avoid collisions. Now when I &lt;code&gt;tofu apply&lt;/code&gt;, my container might come back as &lt;code&gt;beatrix-a3f1b2&lt;/code&gt; or &lt;code&gt;django-7c9e04&lt;/code&gt;. My Proxmox dashboard looks ridiculous and I love it.&lt;/p&gt;

&lt;h2&gt;
  
  
  SSH keys without managing SSH keys
&lt;/h2&gt;

&lt;p&gt;Instead of copying keys around, the config pulls them from GitHub:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"http"&lt;/span&gt; &lt;span class="s2"&gt;"github_keys"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/${var.github_username}.keys"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every apply fetches the latest keys. Add a new key to GitHub, run the pipeline, done. No &lt;code&gt;ssh-copy-id&lt;/code&gt;, no Ansible vault for public keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it got hard: Ansible + DHCP
&lt;/h2&gt;

&lt;p&gt;After creating containers, I wanted Ansible to configure them. Install packages, set timezone, run updates. But the containers use DHCP, so at plan time there's no IP. The output just says &lt;code&gt;ip = "dhcp"&lt;/code&gt;. Not helpful.&lt;/p&gt;

&lt;p&gt;I wrote a script that calls the Proxmox API after boot to get the actual IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sk&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PM_API_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/nodes/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;node&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/lxc/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;vmid&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/interfaces"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: PVEAPIToken=..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.data[] | select(.name=="eth0") | .["ip-addresses"][]
    | select(.["ip-address-type"]=="inet") | .["ip-address"]'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generates an Ansible inventory on the fly. No hardcoded IPs.&lt;/p&gt;

&lt;p&gt;Sounds clean, right? It wasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything that went wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Wrong field name.&lt;/strong&gt; The script was producing &lt;code&gt;ansible_host: null&lt;/code&gt; for every container. Ansible tried to resolve the hostname, which obviously didn't exist in DNS, and died. I spent 15 minutes curling the Proxmox API manually before I noticed: the field is &lt;code&gt;ip-address&lt;/code&gt;, not &lt;code&gt;inet-addr&lt;/code&gt;. Every example I'd seen online used &lt;code&gt;inet-addr&lt;/code&gt;. One jq filter change and it worked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Race condition I didn't think about.&lt;/strong&gt; The Makefile ran &lt;code&gt;tofu apply&lt;/code&gt; then immediately generated inventory. But the container had literally just booted. DHCP hadn't assigned an IP yet. The inventory came back empty, Ansible ran against zero hosts, and the whole thing "succeeded" with nothing actually provisioned. No errors. No warnings. Just silence.&lt;/p&gt;

&lt;p&gt;I added retry logic. The script now polls the API every 5 seconds, up to a minute, waiting for all containers to have IPs before generating inventory. Not elegant, but it works every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lifecycle rules that created a deadlock.&lt;/strong&gt; I wanted to protect important containers from accidental &lt;code&gt;tofu destroy&lt;/code&gt;. OpenTofu has &lt;code&gt;prevent_destroy&lt;/code&gt; for this, but it has to be a literal &lt;code&gt;true&lt;/code&gt; or &lt;code&gt;false&lt;/code&gt; in the code. You can't pass a variable. So I ended up with two resource blocks: &lt;code&gt;proxmox_lxc.protected&lt;/code&gt; and &lt;code&gt;proxmox_lxc.unprotected&lt;/code&gt;, and a flag in tfvars to control which pool a container landed in.&lt;/p&gt;

&lt;p&gt;This worked until I tried to flip the flag. Changing a container from protected to unprotected meant OpenTofu wanted to destroy the protected one and recreate it in the unprotected block. But &lt;code&gt;prevent_destroy&lt;/code&gt; blocked the destroy. Complete deadlock. The only fix was &lt;code&gt;tofu state mv&lt;/code&gt; to manually move resources between blocks.&lt;/p&gt;

&lt;p&gt;I spent an hour on this before deciding it was absurd for a homelab. Deleted the whole thing. One resource block, no flags, no state gymnastics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Cloudflare Tunnel rabbit hole.&lt;/strong&gt; I thought it'd be cool to SSH into my containers from anywhere. Set up Cloudflare Tunnel, created DNS records, installed &lt;code&gt;cloudflared&lt;/code&gt; on a container. Everything looked right. Then: &lt;code&gt;remote error: tls: handshake failure&lt;/code&gt;. Spent another hour debugging tunnel configs, ingress rules, Access policies.&lt;/p&gt;

&lt;p&gt;Then I asked myself when I last needed to SSH into my homelab from outside my house. The answer was never. Deleted the entire Cloudflare integration. Over-engineering is a real disease.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CI that passed locally and failed in Actions.&lt;/strong&gt; &lt;code&gt;ansible-lint&lt;/code&gt; was green on my machine because I had &lt;code&gt;community.general&lt;/code&gt; installed from an old project. The GitHub Actions runner didn't have it. Pipeline went red. Then lint complained my variables didn't have a role prefix. Then it complained about a missing newline at the end of a file. Three separate commits to fix linting issues. Should have run it locally first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it ended up
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. Creates containers, waits for IPs, provisions with Ansible, prints this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;============================================
Infrastructure up and provisioned
============================================

SSH into your containers:
  ssh root@192.168.2.85
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;make down&lt;/code&gt; tears it all down. &lt;code&gt;make up&lt;/code&gt; again and you get a fresh set. Different Tarantino name, same packages, same config. Ansible is idempotent: first run &lt;code&gt;changed=4&lt;/code&gt;, second run &lt;code&gt;changed=0&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd skip next time
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;prevent_destroy&lt;/code&gt; experiment. Not worth the state complexity for a homelab.&lt;/p&gt;

&lt;p&gt;Cloudflare Tunnel. Cool idea, but I didn't need it. Building for a problem you don't have is how features become baggage.&lt;/p&gt;

&lt;p&gt;And I'd run the linter locally before pushing. Not after CI tells me what I already should have caught.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/ayushverma8/homelab" rel="noopener noreferrer"&gt;github.com/ayushverma8/homelab&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Clone it, fill in your Proxmox credentials, &lt;code&gt;make up&lt;/code&gt;. Let me know what Tarantino character you get.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>opentofu</category>
      <category>ansible</category>
      <category>homelab</category>
    </item>
  </channel>
</rss>
