Charlotte, NC
BlogAugust 1, 2026

Terraforming My Homelab: Rebuild the Shells, Protect the Data

Blake McCarn
Terraforming My Homelab: Rebuild the Shells, Protect the Data
I want my homelab infrastructure in Terraform. Not because I enjoy turning every weekend project into HCL. I want it because the homelab crossed the line from "a few boxes I remember how to rebuild" into real infrastructure. There are Proxmox virtual machines, TrueNAS shares, Cloudflare DNS records, AWS bootstrap resources, Kubernetes nodes, and enough service scaffolding that rebuilding it from memory would be risky. But my Terraform homelab has a second problem. It contains data I care about. That is where a lot of infrastructure-as-code advice gets too clean. Recreate the node. Replace the volume. Destroy and apply again. That works until the thing Terraform wants to replace is a dataset with family documents, a database volume, a backup target, a media library, a game world, or a persistent volume an application actually depends on. The rule I settled on is simple: Rebuild the shells. Protect the data. Terraform is very good at declaring desired state. It is also literal. If an object is in state but no longer in configuration, Terraform normally plans to remove it. If a provider marks an argument as requiring replacement, Terraform plans a destroy and create. That is the contract. The mistake is pretending every object has the same blast radius. A Proxmox VM shell before it runs a workload is cheap. It has a CPU count, memory allocation, firmware settings, network adapters, and an operating-system disk I can recreate. A TrueNAS dataset with years of files is not cheap. Neither is a database volume or a backup repository. In cloud consulting, I see the same distinction in client environments. Nobody serious reviews an RDS database, an S3 bucket full of production data, and a disposable task definition as if they were the same kind of resource. My homelab is smaller, but the operating principle is identical. The dollar value may be lower. The recovery cost is still real. The first category is infrastructure I am willing to recreate. Proxmox VM shells. The community-maintained bpg/proxmox provider manages Proxmox VE resources through its API. It is a good fit for the shape of a virtual machine: CPU, memory, boot order, clone source, empty disks, network attachments, and tags. For example, this is the kind of configuration I use for a Kubernetes worker shell. Every identifier here is illustrative: That VM is the chassis. Terraform can rebuild it. The boundary changes once a disk holds real application state. A Kubernetes worker may still be replaceable because the cluster and storage layer replicate the workload, but that does not mean Terraform should replace a stateful disk as a side effect of changing an unrelated VM argument. I described the storage side of that decision in A Boring Kubernetes Homelab. DNS records. Terraform is excellent at DNS. A Cloudflare record for app.example.com is declarative metadata with a small, visible diff. It belongs in code. Bootstrap infrastructure. The AWS resources that support Terraform itself belong in code after the initial bootstrap: the state bucket, versioning, encryption settings, public access block, and IAM policies. Small Kubernetes bootstrap resources and service scaffolding fit here too when the artifact is configuration rather than irreplaceable data. Storage metadata, with guardrails. Dataset properties and NFS share definitions can be reviewed as code. The contents behind them are a different lifecycle. There are things Terraform can describe but should not be trusted to destroy in my environment:
  • TrueNAS dataset contents
  • Physical disks, partitions, and ZFS pools after creation
  • NFS shares containing real files
  • VM disks with application data
  • Databases and database volumes
  • Media libraries
  • Backups and backup targets
  • Game worlds
  • Kubernetes persistent volume contents
That list is intentionally boring. The important distinction is not whether a provider exposes a resource type. It is whether I want Terraform to have authority over that resource's complete lifecycle. My first instinct was to put Proxmox, TrueNAS, Cloudflare, and AWS bootstrap resources in one root module. One command. One plan. One state file. That is neat until the blast radii stop matching. I now use separate roots with separate state keys. The names reflect the order the homelab grew rather than a perfect taxonomy: This is more than repository organization. Each root has its own review context, state key, lock, and failure domain. A plan that changes DNS does not need to refresh every protected dataset. A provider bug in a compute plan does not get a chance to propose changes to storage metadata. Separate roots also make credential scoping possible, but the directory split does not create that isolation by itself. Modules are a code boundary. State files are an operational boundary. I care about both. Terraform state maps code to real infrastructure. If that map is fragile, the whole setup is fragile. I use the Terraform S3 backend with bucket versioning, server-side encryption, and native S3 lockfiles: HashiCorp now documents use_lockfile as the S3 locking path and marks DynamoDB-based locking as deprecated. The runner needs s3:ListBucket on the bucket for the relevant prefix, plus s3:GetObject and s3:PutObject on the state object. The .tflock object also needs s3:GetObject, s3:PutObject, and s3:DeleteObject. The state object itself does not need delete permission for normal backend operation. Versioning matters. HashiCorp explicitly recommends it for recovery from accidental deletion and human error. It gives me a path back if a state write goes wrong. State is still sensitive. HashiCorp's sensitive data guidance is clear that plan and state files can contain passwords, API tokens, and other resource attributes. Marking a value sensitive redacts normal CLI output. It does not remove the value from state. Remote state needs narrow IAM, encryption, and versioned recovery because it is valuable data in its own right. I do not fetch provider credentials through Terraform data sources. Doing that makes the secret part of Terraform's evaluation. Depending on how the result is used, it can also put sensitive values into plan or state. Terraform now supports ephemeral values and write-only arguments in some cases, but provider support varies. Provider-specific environment variables are a simpler boundary for credentials that only authenticate the provider. A root-specific wrapper can fetch credentials from AWS Secrets Manager, export only the values that root needs, and then replace the wrapper process with Terraform. This condensed example shows the environment-variable pattern for three providers: The bpg Proxmox provider documents PROXMOX_VE_API_TOKEN, the TrueNAS provider documents TRUENAS_API_KEY, and the Cloudflare provider accepts its token through CLOUDFLARE_API_TOKEN. The HCL describes endpoints and resource intent. Credentials arrive from the process environment. Terraform and the provider plugin processes it starts can read that environment, so I still treat the entire process tree as privileged. This also does not make every Terraform secret ephemeral. If I pass a password into a normal resource argument, the provider may still store it in state. The rule is narrower: provider authentication should not become managed infrastructure data. My TrueNAS datasets existed before the Terraform code did. Pretending Terraform created them would be dishonest and dangerous. The community barodeur/truenas provider uses the TrueNAS WebSocket API and supports dataset and NFS share resources. Both can be imported. Terraform's current import workflow supports declarative import blocks, which means the adoption step can go through the same pull request review as the resource configuration. I treat this provider as transitional rather than a forever dependency. Its schema matches the resources I already imported, but its upstream maintenance signals are weak. Keeping it isolated in the TrueNAS root limits the blast radius while I evaluate better-supported replacements. Every dataset name, path, share ID, and network in this example is a documentation-only placeholder. 192.0.2.0/24 is an IETF TEST-NET range, not a private network from my homelab. The import is not the safety check. The first plan is. I write the resource block to match the live object, add the import block, and inspect the plan before applying the adoption. I stop if the plan proposes anything beyond the intended import. This matters with ZFS inheritance. The provider documents that only properties with a local ZFS source are stored in state, while inherited or default properties appear as null. I do not fill every attribute just because the schema exposes it. I also test provider upgrades against noncritical resources before letting a new version refresh protected storage. A provider schema change can be just as operationally meaningful as an HCL change. prevent_destroy is useful because Terraform rejects a plan that would destroy a protected resource while that lifecycle rule is present. It turns a dangerous plan into a hard error. It is not magic. HashiCorp's lifecycle documentation calls out the important limitation: removing the resource configuration also removes the place where prevent_destroy is declared. The resource can still be destroyed as Terraform reconciles the configuration. When I want Terraform to stop managing a protected object without deleting it, I use a reviewed removed block: Terraform recommends this declarative path over an imperative terraform state rm for normal workflows. The intent is visible in code: forget the object, leave the real dataset alone. Most importantly, prevent_destroy does not protect against application corruption, operator error inside TrueNAS, disk failure, or a compromised credential. Snapshots, off-system backups, and tested restores still do that job. A lifecycle rule is one guardrail in front of one tool. Local Terraform works when one person is careful. I wanted the plan to be visible, repeatable, and attached to code review. Atlantis runs Terraform plans from pull requests and posts the result back to the PR. Its locking model locks a directory and Terraform workspace to that PR until the PR is merged, closed, or the plan is discarded. Terraform's backend lock still protects state during operations. Atlantis adds a higher-level lock that prevents a second pull request from planning the same directory and workspace while the first lock is held. That gives me the review shape I want:
  • Every infrastructure change starts as a pull request.
  • The plan is visible before an apply.
  • Destructive actions appear beside the code that caused them.
  • Protected storage plans are reviewed separately from rebuildable compute.
  • Concurrent changes to the same root do not race each other.
There is a security catch. Atlantis plans untrusted HCL on a server that has provider credentials. Its own security documentation warns that a malicious provider or external data source can execute during plan, before an apply approval matters. I treat Atlantis as privileged infrastructure. My server is allowlisted to a private repository, fork pull requests are disabled, webhooks are authenticated, and plan access is limited to trusted contributors. The Atlantis process still receives the credentials needed by its configured projects, so pull-request isolation is not credential isolation. Pull-request visibility is useful, but it is not a sandbox. This is the boundary I use now: The boundary is not perfect. Provider behavior changes. Imports need care. Some resources mix rebuildable configuration with stateful disks in one schema. Writing the boundary down still changes how I read a plan. If Terraform wants to create or update a shell, I ask whether the proposed shape matches the architecture. If it wants to destroy or replace something stateful, I stop and ask why Terraform believes it owns that lifecycle at all.
  • Terraform for infrastructure state, plans, imports, and lifecycle guardrails
  • Terraform S3 backend with versioning, encryption, and native lockfiles
  • Atlantis for pull-request plans and per-project locking
  • bpg/proxmox for Proxmox VM shells and infrastructure metadata
  • barodeur/truenas for imported TrueNAS dataset and NFS share metadata
  • Cloudflare Terraform provider for DNS records and public routing metadata
  • AWS Secrets Manager for provider credentials injected into each root's Terraform process environment
  • Talos and Kubernetes for the cluster layer after the VM shells exist
The honest lesson is that infrastructure as code is not just, "Can I create this?" It is, "Can I safely declare what must never be destroyed?" That question makes my Terraform plans slower to write and much easier to trust. I am fine with that trade.
Share this post: