﻿<feed xmlns="http://www.w3.org/2005/Atom">
  <title type="text" xml:lang="en">OpenTofu</title>
  <link type="application/atom+xml" href="https://d.moonfire.us/tags/opentofu/atom.xml" rel="self" />
  <link type="text/html" href="https://d.moonfire.us/tags/opentofu/" rel="alternate" />
  <updated>2026-03-09T17:42:47Z</updated>
  <id>https://d.moonfire.us/tags/opentofu/</id>
  <author>
    <name>D. Moonfire</name>
  </author>
  <rights>Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International</rights>
  <entry>
    <title>Teaching NixOS about OpenTofu</title>
    <link rel="alternate" href="https://d.moonfire.us/blog/2024/01/05/teaching-nixos-about-opentofu/" />
    <updated>2024-01-05T06:00:00Z</updated>
    <id>https://d.moonfire.us/blog/2024/01/05/teaching-nixos-about-opentofu/</id>
    <category term="development" scheme="https://d.moonfire.us/categories/" label="Development" />
    <category term="opentofu" scheme="https://d.moonfire.us/tags/" label="OpenTofu" />
    <category term="pulumi" scheme="https://d.moonfire.us/tags/" label="Pulumi" />
    <category term="dreamhost" scheme="https://d.moonfire.us/tags/" label="DreamHost" />
    <category term="nixos" scheme="https://d.moonfire.us/tags/" label="NixOS" />
    <category term="just" scheme="https://d.moonfire.us/tags/" label="Just" />
    <category term="deltachat" scheme="https://d.moonfire.us/tags/" label="DeltaChat" />
    <summary type="html">In my endless quest to come up with a completely data-driven and reproducible environment, I decided to take a stab at a new instance automation tool: OpenTofu.
</summary>
    <content type="html">&lt;p&gt;In my endless quest to come up with a completely data-driven and reproducible environment, I decided to take a stab at a new automation tool: &lt;a href="https://opentofu.org/"&gt;OpenTofu&lt;/a&gt;. I've already gotten a good &lt;a href="/tags/nixos/"&gt;NixOS&lt;/a&gt; setup, but I wanted to also be able to check in the setup for my instances (and to a smaller degree, my bare metal servers in my home lab) to expand on the functionality. It didn't hurt that work had settled on Terraform.&lt;/p&gt;
&lt;p&gt;Previously, I had taken a stab at &lt;a href="https://www.pulumi.com/"&gt;Pulumi&lt;/a&gt; (during my wedding anniversary trip in 2022). It was fun and I liked the code but I ended up gutting it later. For some reason, it quickly ended up feeling like a chore to play with. At that point, I figured I would just do things manually. But then I saw the announcement that OpenTofu had forked from Terraform because of enshittification of licenses (develop with an open license, then switch to a more limited one once profits became important). That little thing set me off and I decided to try it out.&lt;/p&gt;
&lt;h2&gt;Installing&lt;/h2&gt;
&lt;p&gt;Since all my infrastructure code is in a Nix flake, to get started just required me to add to the shell's packages.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-nix"&gt;devShell.${system} = pkgs.mkShell {
  buildInputs = [
    pkgs.just
    pkgs.opentofu
    pkgs.openstackclient
  ];
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I also grabbed the OpenStack client because it made easier to find some of the nasty little identifiers I needed to import.&lt;/p&gt;
&lt;h2&gt;Configuration Files&lt;/h2&gt;
&lt;p&gt;The way tofu works, it grabs all the &lt;code&gt;*.tf&lt;/code&gt; files in the same directory. So inside my infrastructure flake, I have a &lt;code&gt;src/tofu&lt;/code&gt; directory with configuration files that make sense to me:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;000-providers.tf&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;050-secrets.tf&lt;/code&gt; (.gitignored)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;050-secrets.tf.enc&lt;/code&gt; (SOPS encrypted based on my user key)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;200-networks.tf&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;500-instance0.tf&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;500-instance1.tf&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All of them are picked up, merged together, and made into a single set of settings. I also use &lt;code&gt;tofu fmt&lt;/code&gt; a lot since I like to normalize my files on every commit.&lt;/p&gt;
&lt;h2&gt;OpenStack and DreamHost&lt;/h2&gt;
&lt;p&gt;Fortunately, my hosting provider of choice is &lt;a href="https://dreamhost.com/"&gt;DreamHost&lt;/a&gt;. They aren't the cheapest or the best, but they appear to be ethetical. Mostly, I stick with them because they went to the court to &lt;a href="https://techfreedom.org/victory-online-political-free-speech-dreamhost-case/"&gt;fight some overreaching gag orders&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;(I also tried DigitalOcean at the same time as Pulumi but dropped that also.)&lt;/p&gt;
&lt;p&gt;OpenTofu (via Terraform plugins) does a wonderful job of supporting both OpenStack and DreamHost DNS to tie everything together.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;terraform {
  required_version = &amp;quot;&amp;gt;= 0.14.0&amp;quot;

  required_providers {
    dreamhost = {
      source  = &amp;quot;adamantal/dreamhost&amp;quot;
      version = &amp;quot;0.3.2&amp;quot;
    }

    openstack = {
      source  = &amp;quot;terraform-provider-openstack/openstack&amp;quot;
      version = &amp;quot;~&amp;gt; 1.53.0&amp;quot;
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I added the &lt;code&gt;adamantal/dreamhost&lt;/code&gt; plugin so I could also assign the DNS record directly from my script and have everything working.&lt;/p&gt;
&lt;h2&gt;Secrets&lt;/h2&gt;
&lt;p&gt;Even though my infrastructure flake is in a private repository, I still encrypt all my secrets. I use SOPS for this, which means setting up the &lt;code&gt;.sops.yaml&lt;/code&gt; file to encrypt and then decrypting/encrypting files using `just``:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;decrypt: decrypt-clouds decrypt-secrets

decrypt-clouds:
    if [ ! -f clouds.yaml ];then sops -d clouds.yaml.enc &amp;gt; clouds.yaml;fi

encrypt-clouds:
    cp clouds.yaml clouds.yaml.enc
    sops -i -e clouds.yaml.enc

decrypt-secrets:
    if [ ! -f 050-secrets.tf ];then sops -d 050-secrets.tf.enc &amp;gt; 050-secrets.tf;fi

encrypt-secrets:
    cp 050-secrets.tf 050-secrets.tf.enc
    sops -i -e 050-secrets.tf.enc
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I do the same with my &lt;code&gt;.env&lt;/code&gt; file so I can get the information I need set up properly.&lt;/p&gt;
&lt;h2&gt;Creating an Instance&lt;/h2&gt;
&lt;p&gt;Here is a short segment for creating an instance on DreamHost (or most OpenStack providers).&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;resource &amp;quot;openstack_compute_instance_v2&amp;quot; &amp;quot;instance1&amp;quot; {
  provider    = openstack.dreamhost
  name        = &amp;quot;instance1&amp;quot; // It really isn't instance1, but just pretend
  key_pair    = &amp;quot;keypair1&amp;quot; // This is my SSH key set up somewhere else
  flavor_name = &amp;quot;gp1.supersonic&amp;quot; // gp1.supersonic means I don't need a swap disk

  user_data = &amp;lt;&amp;lt;-EOT
    #cloud-config
    runcmd:
      - curl https://raw.githubusercontent.com/elitak/nixos-infect/master/nixos-infect | NIX_CHANNEL=nixos-unstable bash 2&amp;gt;&amp;amp;1 | tee /tmp/infect.log
    EOT

  # This sets up the boot device as /
  block_device {
    source_type           = &amp;quot;image&amp;quot;
    uuid                  = &amp;quot;2b2c61c6-324c-47f4-88c1-9ae8a978ddfd&amp;quot; # Ubuntu
    boot_index            = 0
    delete_on_termination = true
    destination_type      = &amp;quot;volume&amp;quot;
    multiattach           = false
    volume_size           = 80
  }

  network { // Also configured somewhere else
    name = openstack_networking_network_v2.public.name
  }
}

resource &amp;quot;dreamhost_dns_record&amp;quot; &amp;quot;instance1&amp;quot; {
  record = &amp;quot;instance1.mfgames.com&amp;quot;
  value  = openstack_compute_instance_v2.instance1.network[0].fixed_ip_v4
  type   = &amp;quot;A&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For me, the part is really cool is that I can bake in the &lt;a href="https://github.com/elitak/nixos-infect"&gt;NixOS infect&lt;/a&gt; script right in. To my surprise, it just ran the first time without errors (thought I had to wait about ten minutes after OpenTofu said it was done).&lt;/p&gt;
&lt;p&gt;All I had to do was either show the results:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-shell"&gt;tofu plan
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or apply the changes:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-shell"&gt;tofu apply
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Importing an Instance&lt;/h2&gt;
&lt;p&gt;Actually, the first thing I did was import my existing instances into the system. This involves creating a &lt;code&gt;.tf&lt;/code&gt; file with the same basic setup at the other instance (some fields can be skipped but I was still learning), then import with the ID from OpenStack. Of course, getting the IDs was the hard part. Fortunately, this is where the OpenStack client comes into play. I can use that to get the list of servers, figure out the ID, then import it into Tofu.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-shell"&gt;$ openstack --os-cloud dreamhost server list
+--------------------------------------+-----------+--------+------------+--------------------------+----------------+
| ID                                   | Name      | Status | Networks   | Image                    | Flavor         |
+--------------------------------------+-----------+--------+------------+--------------------------+----------------+
| 55f8ee35-31b2-4137-af1d-b7597d348271 | instance0 | ACTIVE | public=*** | N/A (booted from volume) | gp1.supersonic |
| 1a38092b-bbc5-46bd-9092-0df979ca8fe4 | instance1 | ACTIVE | public=*** | N/A (booted from volume) | gp1.supersonic |
$ tofu import openstack_compute_instance_v2.instance0 55f8ee35-31b2-4137-af1d-b7597d348271
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;NixOS&lt;/h2&gt;
&lt;p&gt;Now, while this was great for setting up things, I also wanted to pull that data into my Nix infrastructure flake. Fortunately, OpenTofu has a way of exporting the data pulled from the cloud. To do that, I need to add an &lt;code&gt;output&lt;/code&gt; stanza at the bottom of my &lt;code&gt;500-instance1.tf&lt;/code&gt; file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;output &amp;quot;instance1_ipv4&amp;quot; {
  value = openstack_compute_instance_v2.instance1.network[0].fixed_ip_v4
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Since I'm (recently) fond of using &lt;a href="/tags/just/"&gt;Just&lt;/a&gt; for automation, I banged up a little stanza that automatically creates a &lt;code&gt;default.nix&lt;/code&gt; file inside that directory every time I apply.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apply: decrypt format &amp;amp;&amp;amp; export
    tofu apply

format:
    tofu fmt

plan: decrypt format
    tofu plan

export:
    echo &amp;quot;inputs: {&amp;quot; &amp;gt; default.nix
    tofu output | sort | perl -ne 'chomp;s@_@.@g;print &amp;quot;  $_;\n&amp;quot;' &amp;gt;&amp;gt; default.nix
    echo &amp;quot;}&amp;quot; &amp;gt;&amp;gt; default.nix
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I had to use &lt;code&gt;_&lt;/code&gt; in the output since dotted notation isn't accepted, but I use &lt;code&gt;perl&lt;/code&gt; to convert those underscores into Nix-happy format.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-nix"&gt;inputs: {
  instance0.ipv4 = &amp;quot;1.2.3.4&amp;quot;;
  instance1.ipv4 = &amp;quot;2.3.4.5&amp;quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I use this to pull into my &lt;code&gt;networking.nix&lt;/code&gt; which is used to drive things like configuring AdGuard, services like Maddy (for DeltaChat) and other services.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-nix"&gt;inputs:
let
  tofu = import ./tofu/default.nix { };
in
{
  instance0 = tofu.instance0;
  instance1 = tofu.instance1;
  instance2.ipv4 = &amp;quot;192.168.0.2&amp;quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From there, I have a single place to get all my IP addresses:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-nix"&gt;inputs:
let
  ip = (import ../../../../networks.nix { }).instance0.ipv4;
in
{
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;It isn't the best or most graceful way of doing things, but I'm pretty happy how everything turned out. I made a few mistakes along the way of setting up Gitea Actions and had to drop and rebuild my instance0. That was just a matter of renaming &lt;code&gt;500-instance0.tf&lt;/code&gt;, applying to drop, and then name the file back. Then I had nice clean slate to push out a new closure.&lt;/p&gt;
&lt;p&gt;OpenTofu is much nicer than Pulmui. It didn't insist on having a cloud to maintain state, the file is checked into Git instead. It has a declarative language instead of code, and since I really don't need a lot of that logical flow, it just works for me. Plus I was able to inject into my &lt;code&gt;just deploy&lt;/code&gt; top-level script that pushes out changes to my home lab and all my instances in a single call.&lt;/p&gt;
</content>
  </entry>
</feed>
