← Back to list

Building a reusable OpenTofu module library, and why I stopped copy-pasting infrastructure

I’m setting up a Kubernetes labs cluster on Civo. On day one I had a choice: copy-paste Terraform from old projects, tweak the bits that…

Igor Silva · 2026-04-29 20:06 · 0 claps · 4.1 min read
#terraform #opentofu #kubernetes #devops #infrastructure-as-code
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud 📚 · Books & Reading

Building a reusable OpenTofu module library, and why I stopped copy-pasting infrastructure

I’m setting up a Kubernetes labs cluster on Civo. On day one I had a choice: copy-paste Terraform from old projects, tweak the bits that don’t apply, run it and hope. Or build the modules properly from the start, with versioning, CI, and the kind of structure that makes the next cluster trivial to spin up.

I went with modules. The result is tf-modules, and it just hit v0.1.0. This is what's in it and why I made the bets I made.

Why modules from day one

Copy-paste Terraform is the default mode for solo projects. There’s nothing inherently wrong with it for a single cluster. The problem starts the moment you spin up the second one.

When you copy-paste, the fix to a bug lives in one repo and slowly rots in the others. There’s no version. There’s no CI. There’s no list of inputs, because what you have isn’t a module. It’s a directory you trust out of muscle memory.

Building this as the first cluster meant I could choose differently. Modules with explicit input contracts. Tags as version markers. CI that catches the kind of bug I’d otherwise discover three weeks in.

The cost of doing it this way was about one extra day of setup. The cost of not doing it is invisible until you try to reproduce a setup three months later. Then it’s all you can see.

What v0.1.0 actually contains

Four modules, one library, all OpenTofu compatible:

tf-modules/
├── civo/
│   ├── network/           # Civo network + firewall + optional DNS
│   └── kubernetes/        # K3s cluster on Civo with configurable pools
├── helm/                  # Generic helm_release wrapper
├── kubernetes/
│   └── namespaces-rbac/   # Namespaces + service accounts + read-only RBAC
└── examples/              # One runnable example per module

The relationship between them looks like this:

  ┌──────────────┐   ┌──────────────────┐   ┌────────────────┐
  │ civo/network │──▶│ civo/kubernetes  │   │     helm       │
  └──────────────┘   └────────┬─────────┘   └────────────────┘
                              │
                              ▼
                  ┌─────────────────────────┐
                  │ kubernetes/             │
                  │   namespaces-rbac       │
                  └─────────────────────────┘

Network feeds the cluster. The cluster gets a kubeconfig. Namespaces and RBAC layer on top. Helm deploys workloads later. Each module is small on purpose. Most of them are under 50 lines of HCL.

Consuming any of them from another repo is the part I care about most:

module "network" {
  source = "git::https://github.com/igorsilva-dev/tf-modules.git//civo/network?ref=v0.1.0"
  network_label        = "main"
  firewall_name        = "main"
  create_default_rules = true
}
module "kubernetes" {
  source = "git::https://github.com/igorsilva-dev/tf-modules.git//civo/kubernetes?ref=v0.1.0"
  cluster_name       = "platform"
  kubernetes_version = "1.34.2-k3s1"
  network_id         = module.network.network_id
  firewall_id        = module.network.firewall_id
  pools = [
    { label = "workers", size = "g4s.kube.xsmall", node_count = 2 },
  ]
}

That ?ref=v0.1.0 matters. It's the difference between "infrastructure that drifts" and "infrastructure pinned to a known-good revision".

Why OpenTofu, why Civo

Two opinionated bets, both small ones.

OpenTofu, because the fork happened, the foundation backing it is real, and the migration cost from Terraform was effectively zero. My civo-infrastructure repo runs tofu everywhere. The CLI surface is identical. State files are interchangeable. If the day comes when the two diverge meaningfully, I'd rather be on the open-source side.

Civo, because it’s cheap, it’s K3s, and it’s not “yet another EKS demo”. A 3-node cluster costs me less than a coffee per day. The K3s control plane behaves differently from a full kube-apiserver in interesting ways, which is itself a learning surface. And the API is simple enough that I built modules around it in an evening, not a week.

Neither of these bets is permanent. The whole point of the module library is that swapping a backend later is a matter of rewriting one module, not every project.

Remote state, the Terragrunt detail

The modules are stateless on their own. State lives with the consumer. For the reference consumer (civo-infrastructure), I configured a Civo Object Store bucket as an S3-compatible backend, generated once at the Terragrunt root:

generate "backend" {
  path      = "backend.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
terraform {
  backend "s3" {
    endpoint                    = "https://objectstore.lon1.civo.com"
    bucket                      = "tf-backend"
    key                         = "${path_relative_to_include()}/tofu.tfstate"
    region                      = "LON1"
    skip_region_validation      = true
    skip_credentials_validation = true
    skip_metadata_api_check     = true
    force_path_style            = true
    access_key                  = "${local.access_keys.access_key}"
    secret_key                  = "${local.access_keys.secret_key}"
  }
}
EOF
}

Civo Object Store speaks S3, which means the standard s3 backend works with a few skip flags and force_path_style = true. State lives in the same provider as the cluster. One bill, one credential surface, one less moving part.

I had Terragrunt and Terramate both wired up at one point. Terramate was orchestrating Terragrunt. After a few days of using it, I removed Terramate entirely. The thing it added (running across changed stacks) wasn’t carrying its weight at this scale. Terragrunt alone is enough until I actually have more than two stacks worth managing. Less tooling, fewer surprises.

What CI actually runs

Every PR runs four jobs:

Job What it does Format Check tofu fmt -check -recursive -diff Validate Modules Matrix tofu init -backend=false && tofu validate per module Lint tflint per module with the recommended preset Security Scan tfsec at minimum severity MEDIUM

A red pipeline blocks merge. The first time I added the lint job, three modules failed because they were missing version constraints on providers. That was useful. The whole point of CI is to catch the things you forgot, not the things you remembered.

Where it lives, and what’s next

Repo: github.com/igorsilva-dev/tf-modules. Release: v0.1.0. Changelog: CHANGELOG.md.

This was Phase 0 of a longer build. Phase 1 is Argo CD on the Civo cluster, bootstrapped from civo-infrastructure via the same modules, with the app-of-apps pattern pulling everything else from a gitops/ directory. Then Istio, Prometheus, Grafana. Then an AI agent running on the cluster, with RBAC scoped tighter than cluster-admin.

The point of all of this is not the modules. The modules are scaffolding. The point is to have a real platform to run real things on, where every layer is documented, versioned, and reproducible. Phase 0 was the boring part. The boring part is the part that lets the rest exist.


메타데이터
post_id
70c0b290af18
slug
building-a-reusable-opentofu-module-library-and-why-i-stopped-copy-pasting-infrastructure-70c0b290af18
url
https://medium.com/@dev_47507/building-a-reusable-opentofu-module-library-and-why-i-stopped-copy-pasting-infrastructure-70c0b290af18
canonical_url
https://medium.com/@dev_47507/building-a-reusable-opentofu-module-library-and-why-i-stopped-copy-pasting-infrastructure-70c0b290af18
author_url
https://medium.com/@dev_47507
status
ok
fetched_at
2026-06-09 15:37:30