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…

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