Write and Publish a Private Terraform Module

Khimananda Oli 9 min read Virtualization
Write and Publish a Private Terraform Module

By Khimananda Oli | Last reviewed: August 2026

Teams scaling infrastructure often drown in configuration drift and repeated boilerplate code across environments. The solution is to write and publish a private Terraform module that encapsulates your organization’s specific security policies, naming conventions, and architectural standards into a reusable package. This guide walks you through building, versioning, and hosting an internal module registry that enforces compliance automatically while accelerating developer velocity.

How do you structure a private Terraform module for reusability?

A well-structured module is the foundation of any successful internal platform. When I audit infrastructure at Nepali fintechs or global SaaS companies, the most common failure mode isn't bad code—it's unstructured code that cannot be safely reused. Before you write and publish a private Terraform module, you must establish a directory layout that separates concerns and makes the interface explicit.

Your module should follow the standard HashiCorp layout but with stricter enforcement on documentation and validation. Never put environment-specific values inside the module; those belong in the root configuration calling the module. For deeper context on modular design patterns, see our guide on reusable infrastructure with Terraform modules.

<module-root>/
├── main.tf           # Primary resource definitions
├── variables.tf      # Input variables with validation blocks
├── outputs.tf        # Explicit output definitions
├── versions.tf       # Provider and Terraform version constraints
├── README.md         # Auto-generated documentation
├── examples/         # Working usage examples
│   └── basic/
│       ├── main.tf
│       └── terraform.tfvars.example
└── tests/            # Automated testing (terratest/kitchen)
    └── integration_test.go
Module Structure: Separation of ConcernsInterface Layervariables.tfoutputs.tfversions.tfValidation & ConstraintsImplementation Layermain.tflocals.tfdata.tfResource Logic OnlyQuality Layerexamples/tests/README.mdVerification & DocsKey Principles for Private Modules• Variables MUST include description, type, and validation blocks• Outputs MUST expose only what downstream modules need• No hardcoded environment values (region, account ID, CIDR)• Examples must be runnable without modification• Version constraints pinned to minor releases (~> 1.2)
Proper module structure separates interface, implementation, and quality verification layers for safe reuse

The critical differentiator for private modules versus public ones is validation. In a SOC 2 or ISO 27001 environment, you cannot trust consumers to pass compliant parameters. Use variable validation blocks to enforce guardrails at plan time:

variable "instance_type" {
  description = "EC2 instance type. Must be from approved cost-optimized list."
  type        = string
  
  validation {
    condition     = contains(["t3.medium", "t3.large", "m6i.large"], var.instance_type)
    error_message = "Only t3.medium, t3.large, or m6i.large instances are permitted per FinOps policy."
  }
}

variable "tags" {
  description = "Resource tags. Must include CostCenter and Environment."
  type        = map(string)
  
  validation {
    condition     = can(var.tags["CostCenter"]) && can(var.tags["Environment"])
    error_message = "Tags must include both 'CostCenter' and 'Environment' keys for compliance tracking."
  }
}

This shifts compliance left. Instead of failing during an audit three months later, the developer gets immediate feedback when they run terraform plan. Always pair this with terraform fmt -check and terraform validate in your pre-commit hooks.

How do you version and test a Terraform module before publishing?

Publishing broken infrastructure code is far more damaging than publishing broken application code because the blast radius includes entire environments. Before you publish, you need a rigorous testing strategy that mirrors application CI/CD but accounts for stateful resources and cloud costs.

  1. Static Analysis: Run tflint with your custom ruleset to catch deprecated syntax, invalid instance types, and missing required tags. Configure checkov or tfsec to scan for security misconfigurations against CIS benchmarks.
  2. Unit Testing: Use terraform test (native in 1.6+) or Terratest to verify module outputs match expected values without deploying real infrastructure where possible. Mock providers help here.
  3. Integration Testing: Deploy to an ephemeral sandbox account. Verify resources create successfully, pass compliance checks, and destroy cleanly. Tag all test resources with TTLs to prevent orphaned spend.
  4. Semantic Versioning: Follow SemVer strictly. Breaking changes (removing outputs, changing variable types) require major version bumps. New optional inputs are minor versions. Documentation fixes are patches.

In my experience helping teams achieve SOC 2 compliance, the most overlooked step is testing the destroy lifecycle. Modules that create resources but fail to clean up properly cause state lock issues and lingering costs. Always include a destroy test in your CI pipeline.

# .github/workflows/module-ci.yml excerpt
- name: Run Integration Tests
  env:
    AWS_REGION: us-east-1
    TEST_AWS_ACCOUNT_ID: ${{ secrets.SANDBOX_ACCOUNT_ID }}
  run: |
    cd tests/integration
    go test -v -timeout 30m -run TestVPCModule
    terraform destroy -auto-approve  # Mandatory cleanup check

For comprehensive guidance on managing state during these tests, refer to our article on Terraform state management and remote backends. Proper isolation prevents test runs from corrupting production state files.

Where should you host a private Terraform module registry?

Once validated, you need a distribution mechanism. The right choice depends on your team size, compliance requirements, and existing toolchain. Here is a practical comparison of the four most viable options in 2026:

Hosting OptionBest ForCompliance FitComplexityCost
HCP Terraform RegistryEnterprise teams needing full protocol supportSOC 2 / ISO 27001 ready out-of-boxLow (managed service)$$ (per-user pricing)
Git Repository (GitHub/GitLab)Small-to-mid teams already using GitOpsRequires manual access controls & signingMedium (ref-based versioning)$ (included in platform)
Azure Artifacts / AWS CodeArtifactCloud-native shops wanting unified artifact mgmtGood IAM integration, audit logsMedium-High (custom wrapper needed)$ (pay-per-request)
Self-hosted (Terraregistry)Air-gapped / data-residency restricted environmentsFull control, Nepal data residency compliantHigh (you own ops)$$ (infra + maintenance)
Registry Selection Decision FlowStart: Need Private Module?Air-Gapped / Data Residency?YESNOSelf-Hosted Registry(Terraregistry / Custom)Team Size > 20 Engineers?YESNOHCP Terraform Registry(Managed Protocol Support)Git Repo Source(GitHub / GitLab Ref)All options support version pinning. Choose based on compliance burden vs. operational overhead trade-off.
Select your registry based on compliance requirements, team scale, and operational capacity

For most Nepali organizations dealing with local banking regulations or data residency requirements, self-hosting or Git-based approaches avoid cross-border data transfer complexities. Global teams typically benefit from HCP Terraform’s managed protocol, which handles module discovery, version listing, and download URLs natively without custom CLI wrappers.

Configuring Git-Based Module Sources

If choosing Git, use SSH with deploy keys rather than personal tokens for CI/CD. Pin to immutable tags, never branches:

module "vpc" {
  source  = "git::ssh://[email protected]/my-org/terraform-aws-vpc.git?ref=v2.1.0"
  
  cidr_block = "10.0.0.0/16"
  tags       = { CostCenter = "ENG-402", Environment = "prod" }
}

Note the ?ref=v2.1.0 parameter. Without this, Terraform pulls HEAD, making builds non-deterministic and audits impossible. This single mistake causes more production incidents than any other module anti-pattern I've seen.

How do consumers securely consume and update private modules?

Publishing is only half the equation. You must also govern consumption to prevent version sprawl and ensure security patches propagate. Treat module updates like dependency updates in application code: automated, tested, and gated.

  • Version Pinning: Always use exact versions (v2.1.0) or pessimistic constraints (~> 2.1) in consuming configurations. Never use latest or branch refs in production.
  • Automated Updates: Use tools like Renovate or Dependabot configured for Terraform. They open PRs when new module versions release, triggering your CI validation suite automatically.
  • Deprecation Policy: Maintain at least two major versions simultaneously. Announce deprecations via CHANGELOG.md minimum 90 days before removal. Provide migration guides for breaking changes.
  • Access Control: Implement least-privilege read access. Developers get read-only to approved modules; only platform engineers can publish. Audit log all downloads for compliance evidence.

When integrating modules into larger systems, consider how they interact with orchestration tools. Our guide on setting up GitOps with ArgoCD explains how to manage module-driven infrastructure declaratively, ensuring drift detection catches unauthorized modifications to module-sourced resources.

# .renovaterc.json for automated module updates
{
  "terraform": {
    "enabled": true,
    "packageRules": [
      {
        "matchPackagePatterns": ["git::ssh://[email protected]/my-org/*"],
        "allowedVersions": ">=2.0.0 <3.0.0",
        "automerge": false,
        "labels": ["terraform-module-update"]
      }
    ]
  }
}

This configuration ensures updates stay within the v2.x compatibility window while requiring human review before merge—critical for maintaining audit trails in regulated environments.

What are common pitfalls when maintaining private Terraform modules?

Even experienced teams stumble on subtle issues that compound over time. Based on post-mortems from dozens of infrastructure platforms, watch for these failure modes:

State Coupling: Modules that create tightly coupled resources force destructive updates when changed. If modifying a security group rule requires recreating an RDS instance, your module boundary is wrong. Decompose into smaller, composable units connected via outputs/inputs, not implicit dependencies.

Provider Version Drift: Your module declares required_providers, but consumers use different versions. Always specify minimum provider versions in versions.tf and document tested ranges. Provider bugs are the #1 cause of "works locally, fails in CI" module issues.

Documentation Rot: Auto-generate README from code using terraform-docs in CI. Manual docs always lag. Include input/output tables, example snippets, and changelog links directly in the generated output.

Secret Leakage: Never accept secrets as plain string variables. Use sensitive flags, integrate with Vault/AWS Secrets Manager references, or accept secret ARNs/names instead of values. Scan all module repos with gitleaks pre-push.

Private Module Lifecycle: Build → Validate → Publish → Consume → RetireDEVELOPCode + ValidationLocal TestingVALIDATECI PipelineSecurity ScanPUBLISHTag ReleaseRegistry UploadCONSUMEVersion PinAuto-Update PRsRETIREDeprecation NoticeMigration GuideCritical Guardrails at Each Stage✓ Develop: Variable validation blocks enforce compliance at authoring time✓ Validate: Ephemeral sandbox tests verify create AND destroy lifecycles✓ Publish: Signed tags + CHANGELOG required; no unsigned commits accepted✓ Consume: Renovate/Dependabot automates update PRs with CI validation✓ Retire: 90-day deprecation window; parallel major version support⚠ Anti-Pattern: Never allow direct main branch pushes or untagged references
End-to-end module lifecycle with mandatory quality gates prevents compliance drift and technical debt accumulation

Avoid the temptation to make modules "smart." Complex conditional logic inside modules becomes unmaintainable fast. Prefer simple, predictable modules composed together over monolithic do-everything abstractions. If your module has more than 15 variables or nested conditionals spanning 50+ lines, split it.

Next Steps for Your Private Module Strategy

Building a reliable private Terraform module ecosystem requires discipline upfront but pays exponential dividends in reduced incident rates, faster onboarding, and smoother audits. Start small: pick one high-friction resource pattern (VPC, EKS cluster, RDS setup), build it properly with validation and tests, publish to your chosen registry, and migrate three teams to it before expanding scope. Measure success by deployment frequency increase and compliance finding reduction, not module count.

If your team needs hands-on guidance designing a module strategy aligned with SOC 2, ISO 27001, or Nepal-specific data residency requirements, reach out to discuss your infrastructure platform goals. I help organizations build internal developer platforms that actually get adopted—not just documented.

Frequently Asked Questions

Use a private Git repository as the source. Configure SSH keys or personal access tokens in your CI pipeline to authenticate during terraform init, avoiding public exposure entirely.

Yes. Follow standard layout with main.tf, variables.tf, outputs.tf, and README.md at root. Include examples and tests subdirectories to ensure compatibility with registry indexing tools.

No. GitHub Packages does not support the Terraform module registry protocol. Use GitHub Releases with semantic versioning tags or a dedicated private registry like Terrareg instead.

Inject a PAT or SSH key via environment variables. Configure git config url rewriting in your pipeline to replace HTTPS sources with authenticated URLs before running terraform init.

No. S3 works as a module source directly using s3::https:// syntax. Ensure IAM permissions allow GetObject on the bucket path containing your versioned module archives.

Always use Semantic Versioning v2. Tag releases as v1.0.0 format. The registry protocol requires this prefix to correctly resolve version constraints and dependency graphs.

Run terratest or kitchen-terraform against the examples directory. Validate inputs, outputs, and idempotency locally before tagging a release to prevent breaking downstream consumers.

Yes. Use repository-level permissions in GitHub or GitLab. For registries like Terrareg, configure namespace-based access controls to limit visibility per team or project.

Check token scope and expiration. Private registries require read:packages or repo scope. Verify the credential helper is configured correctly in your execution environment.

Self-hosted solutions like Terrareg are free but require infrastructure maintenance. Managed options like Spacelift or env0 charge per user or resource but handle auth and scaling automatically.

Add a deprecation notice in the module README and outputs. Keep the tag available but document migration paths. Never delete tagged versions as this breaks existing state files.

Yes. Use relative paths or full registry addresses with version constraints. Ensure consuming identities have permission to access all nested dependencies during initialization.

Implement the service discovery protocol at /.well-known/terraform.json pointing to your API endpoint. Without this, terraform cannot resolve module sources via shorthand notation.

Use semantic-release or release-please with path filtering. Trigger version bumps only when module directories change, generating tags that match the expected registry format.

Absolutely. Sign Git tags with GPG or Sigstore. Configure consumers to verify signatures during init to prevent supply chain attacks from compromised repositories or registries.