Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Write and Publish a Private Terraform Module

By Kokil Thapa | Last reviewed: September 2026

Your team repeats the same VPC, database, and load-balancer blocks in every project. You need to write and publish a private Terraform module once, version it safely, and let other stacks consume it without copy-paste drift. Private modules keep credentials, network layouts, and compliance rules inside your organisation. They work like internal libraries — except the contract is infrastructure, not PHP classes. This guide walks through repo layout, publishing paths, and the CI habits that stop a broken tag from reaching production. If you are new to the wider pattern, start with our infrastructure as code with Terraform practical guide.

What Is a Private Terraform Module and When Should You Use One?

A Terraform module is a reusable bundle of .tf files with its own inputs and outputs. A private module lives inside your organisation — not on the public Terraform Registry. You control who reads it, who publishes tags, and how breaking changes roll out.

Use a private module when three or more stacks need the same pattern. Examples include a standard Laravel-ready EC2 layout, a MySQL subnet group with backup tags, or a legal-tech portal baseline with WAF and TLS. On client projects I have maintained, shared modules cut review time because reviewers already knew the shape.

Skip a module when the logic is truly one-off. A single S3 bucket for logs does not need its own repo. Promote to a module only after the second copy-paste.

Private Terraform Module LifecycleAuthorModule repoTag v1.2.0Semver releasePrivate registryHCP or Git sourceConsumersRoot modulesEach consumer pins version = "1.2.0"terraform init downloads module sourcePlan runs against pinned code, not main branchUpgrade is an explicit version bump PRSame idea as Composer private packages
Write and publish a private Terraform module once, then let multiple root modules consume pinned semver releases.

Private modules pair naturally with remote state. Your module defines resources; each consumer keeps its own state file. Read how to manage Terraform state safely before you wire modules into production pipelines.

How Do You Structure a Private Terraform Module Repository?

Keep the repo boring. Predictable layout helps reviewers and CI scripts. HashiCorp’s module conventions are the baseline — see the official Terraform module documentation for the full spec.

Minimum file layout

Start with this skeleton for an AWS VPC module:

terraform-aws-vpc-private/
├── README.md
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
├── examples/
│   └── complete/
│       ├── main.tf
│       └── versions.tf
└── tests/
    └── vpc.tftest.hcl

Pin provider versions in versions.tf

Every module needs explicit provider constraints. Consumer projects inherit them during init.

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

Pinning avoids surprise provider upgrades. Our Terraform provider version pinning guide explains the trade-offs in depth.

Define a narrow public interface

Expose only what callers need. Keep internal resources private — no outputs for every intermediate object.

# variables.tf
variable "name_prefix" {
  type        = string
  description = "Prefix for VPC resource names"
}

variable "cidr_block" {
  type        = string
  description = "VPC CIDR, e.g. 10.0.0.0/16"
}

variable "tags" {
  type        = map(string)
  description = "Tags applied to all resources"
  default     = {}
}
# outputs.tf
output "vpc_id" {
  description = "ID of the created VPC"
  value       = aws_vpc.this.id
}

output "private_subnet_ids" {
  description = "Private subnet IDs for app tiers"
  value       = aws_subnet.private[*].id
}

Write a README consumers actually read

Include purpose, inputs table, outputs table, and a copy-paste example. Mention breaking changes in a CHANGELOG. Teams that skip this step reopen the same Slack threads every quarter.

For reusable patterns across environments, compare your module inputs with advice in Terraform variables, locals, and outputs and Terraform modules for reusable infrastructure.

How Do You Publish a Private Terraform Module to HCP Terraform?

HCP Terraform (formerly Terraform Cloud) offers the smoothest private registry experience. It indexes semver tags, shows README rendering, and enforces organisation boundaries. HashiCorp documents the flow in their private module registry guide.

Step 1 — Create the module repo and connect VCS

  1. Create a Git repo named terraform-<name>-<provider>, e.g. terraform-vpc-aws.
  2. In HCP Terraform, open your organisation → Registry → Publish → Module.
  3. Connect GitHub, GitLab, or Bitbucket with read access to that repo.
  4. Select the repo and confirm the module name matches your naming standard.

Step 2 — Tag with semantic versioning

Registry indexing depends on Git tags. Use vMAJOR.MINOR.PATCH format aligned with Semantic Versioning.

git add .
git commit -m "feat: add optional flow logs"
git tag v1.3.0
git push origin main --tags

HCP Terraform picks up the tag within minutes. Pre-release tags like v2.0.0-beta.1 work for staging consumers.

Step 3 — Consume from the private registry

Callers reference your organisation hostname and module address:

module "vpc" {
  source  = "app.terraform.io/my-org/vpc/aws"
  version = "1.3.0"

  name_prefix = "prod-app"
  cidr_block  = "10.10.0.0/16"
  tags = {
    Environment = "production"
    ManagedBy   = "terraform"
  }
}

Add credentials in CI via TF_TOKEN_app_terraform_io or the HCP Terraform CLI login flow. Our Terraform CI/CD with GitHub Actions article shows token wiring for pipelines.

HCP Terraform Private RegistryGit pushTag v1.0.0WebhookIndexedOrganisation ACLOnly members see modulesRBAC on publish rightsAudit trail on tagsREADME rendered in UIConsumer configsource = org/name/provversion = "1.0.0"TF_TOKEN for CI authterraform init -upgrade
Publishing to HCP Terraform indexes semver Git tags into an organisation-scoped private module registry.

Which Private Module Registry Options Should You Choose?

Not every team uses HCP Terraform. Git-only and object-storage sources are valid. Pick based on auth model, budget, and existing tooling.

OptionBest forAuth modelVersion pinningTrade-off
HCP Terraform private registryTeams already on HCP TerraformOrg tokens, SSOversion = "1.2.0"Per-seat cost; best UX
Git source with tagsGitLab CI shops, small teamsDeploy keys, OAuth tokens?ref=v1.2.0 query paramNo central module catalog
S3 / GCS module bucketAir-gapped or strict artifact controlIAM rolesObject version ID or pathManual upload step per release
GitLab Terraform Module RegistryGitLab Ultimate customersGitLab project tokensSemver in GitLab UIGitLab tier dependency

Git source without a registry

This pattern mirrors how many agencies first ship private packages. It works on day one with GitLab — the same platform I use for Deployer pipelines on sister sites.

module "vpc" {
  source = "git::https://gitlab.com/my-org/terraform-aws-vpc.git?ref=v1.3.0"

  name_prefix = "staging"
  cidr_block  = "10.20.0.0/16"
}

For HTTPS with a token in CI, use the OAuth2 prefix GitLab documents. Never commit tokens into .tf files. Inject them via environment variables in the pipeline.

S3 module archive

Package the module as a zip, upload to a private bucket, and reference it:

module "vpc" {
  source = "s3::https://s3.amazonaws.com/my-org-terraform-modules/vpc-1.3.0.zip"

  name_prefix = "prod"
  cidr_block  = "10.30.0.0/16"
}

S3 fits teams that already treat artifacts as immutable blobs. Pair it with Terraform remote state on S3 with locking for a consistent AWS-native stack.

If your team manages PHP Composer private feeds today, the mental model is similar. See private Composer packages via Satis and Repman for the application-side analogue.

Private Module Source OptionsHCP RegistryBest UXOrg-scoped ACLAuto index on tagGit + ref tagZero extra costWorks on GitLab CIManual discoveryS3 zip archiveImmutable blobIAM-controlledUpload per releaseDecision ruleAlready on HCP? Use private registry.GitLab-only team? Git source + semver tags.Strict artifact policy? S3 zip + IAM.
Choose HCP Terraform registry, Git tags, or S3 archives based on tooling, budget, and access-control needs.

How Do You Validate and Consume a Private Module in CI/CD?

Publishing without validation is how a typo in variables.tf breaks twelve downstream plans at once. Treat the module repo like application code — lint, test, then tag.

CI pipeline for the module repo

A minimal GitLab CI stage set I have used on infrastructure repos:

stages:
  - validate
  - test

fmt:
  stage: validate
  image: hashicorp/terraform:1.9
  script:
    - terraform fmt -check -recursive

validate:
  stage: validate
  script:
    - cd examples/complete
    - terraform init -backend=false
    - terraform validate

tftest:
  stage: test
  script:
    - terraform test

Add Checkov scans for Terraform misconfigurations before you tag. Block merges on CRITICAL findings unless waived with ticket reference.

Consumer root module pattern

Keep environment differences in root modules, not inside shared modules. Use workspaces or separate state backends — see Terraform workspaces and environments.

# environments/production/main.tf
terraform {
  backend "s3" {
    bucket = "my-org-tfstate"
    key    = "prod/network/terraform.tfstate"
    region = "ap-south-1"
  }
}

module "network" {
  source  = "app.terraform.io/my-org/vpc/aws"
  version = "1.3.0"

  name_prefix = "prod-nepal-app"
  cidr_block  = "10.40.0.0/16"
  tags = {
    Project = "legal-portal"
  }
}

For multi-stack repos, Terragrunt to keep Terraform DRY reduces duplicated backend blocks. Validate JSON outputs from CI logs with our JSON formatter tool when debugging pipeline failures.

Access control checklist

  • Module repo: write access limited to infra maintainers.
  • CI tokens: scoped read-only for consumers, write for release jobs.
  • State buckets: separate from module artifact buckets.
  • Tag protection: only maintainers can push v* tags.
  • Dependabot-style PRs: bump consumer version deliberately, never floating refs.

Never point production at ref=main. That ref tracks moving code and defeats the purpose of semver.

Module CI Before TagPR openedfmt checkvalidateCheckovTag v1.xBlocked: tag on red pipelineConsumer plans stay greenUpgrade PR bumps version pin onlyRollback = revert tag or pin older semver
Run fmt, validate, and policy scans in CI before you write and publish a private Terraform module tag.

What Are Common Mistakes When Publishing Private Terraform Modules?

Most failures are process problems, not Terraform syntax errors. These show up repeatedly across client infrastructure work.

Leaking secrets into module sources

Modules should accept credentials via provider config or variables marked sensitive = true. Never embed API keys in main.tf. Use HCP Terraform variable sets or CI secret stores instead.

Breaking changes without a major version bump

Renaming an output or removing a variable is a breaking change. Bump MAJOR semver and document migration steps. Consumers need a clear upgrade path — same discipline as database migrations on a production Laravel app.

Over-flexible modules

A module with forty boolean flags becomes unmaintainable. Split into two modules when use cases diverge. Composition beats configuration soup.

Skipping examples/

The examples/complete directory is your integration test fixture. If it cannot terraform plan cleanly, consumers will struggle too.

Ignoring drift between environments

Modules standardise creation; they do not stop manual console edits. Schedule drift detection — see Terraform drift detection strategies — on stacks that consume your modules.

For enterprise rollouts spanning application and infrastructure teams, our enterprise application development services and Linux system administration services cover the full delivery path from app code to provisioned servers. The Adventure Third Pole Trek booking platform and SRP Infrastructure Development Nepal portfolio entries show production systems where repeatable infrastructure patterns matter.

Key Takeaways

  • Structure every private module with variables.tf, outputs.tf, versions.tf, README, and a working examples/ directory.
  • Publish semver Git tags (v1.2.0) and never let production consumers track main.
  • Use HCP Terraform private registry when you already run workspaces there; otherwise Git tag sources or S3 zips work fine.
  • Run terraform fmt, validate, and policy scans in CI before any tag push.
  • Pin version in consumer root modules and upgrade through explicit PRs, not floating refs.
  • Document breaking changes in CHANGELOG and bump MAJOR semver when you remove or rename outputs.

People Also Ask

Can you use Terraform modules without the public registry?

Yes. Terraform supports Git URLs, S3 and GCS archives, and private registry hostnames. You only need the public registry for community modules. Internal modules stay entirely inside your Git host, HCP Terraform organisation, or private object storage with IAM controls.

What Git tag format does the private Terraform registry require?

HCP Terraform expects semantic version tags prefixed with v, such as v1.0.0 or v2.1.3-beta.1. Tags without the v prefix or without three numeric segments may not index. Git-only sources use the same tags via the ?ref= query parameter.

How do you authenticate CI pipelines to download private modules?

For HCP Terraform, set TF_TOKEN_app_terraform_io in the pipeline environment. For GitLab or GitHub sources, use a read-only deploy key or fine-grained personal access token injected at runtime. For S3 modules, attach an IAM role to the CI runner with s3:GetObject on the module bucket prefix.

Should private modules contain provider configuration blocks?

No. Child modules should declare required_providers in versions.tf but leave provider "aws" {} blocks to the root module. Root modules pass provider aliases when a module must target multiple regions or accounts — see Terraform provider aliases for multi-cloud.

Ship Reusable Infrastructure Your Team Can Trust

When you write and publish a private Terraform module with semver discipline, CI gates, and pinned consumers, infrastructure stops being tribal knowledge locked in one engineer’s laptop. You get repeatable VPCs, databases, and app stacks that upgrade on your schedule — not during a Friday deploy surprise. Start with one module that three projects already duplicate, publish v1.0.0, and migrate one consumer this sprint. Need help wiring modules into your Laravel or legal-tech deployment pipeline? Contact us or explore ongoing support and maintenance for production systems you rely on daily.

Frequently Asked Questions

A reusable bundle of .tf files with its own inputs and outputs, hosted inside your organisation rather than on the public Terraform Registry. You control who reads it, who publishes tags, and how breaking changes roll out.

When three or more stacks need the same pattern, such as a standard VPC, database subnet group, or app baseline. Skip a module for truly one-off resources like a single S3 log bucket.

Semantic version tags prefixed with v, such as v1.0.0 or v2.1.3-beta.1. Tags without the v prefix or without three numeric segments may not index in the private registry.

Keep the layout predictable and aligned with HashiCorp module conventions. A practical skeleton includes README.md, main.tf, variables.tf, outputs.tf, versions.tf, an examples/complete directory, and tests such as vpc.tftest.hcl. Pin required_version and provider constraints in versions.tf so consumers do not inherit surprise upgrades during terraform init. Expose a narrow public interface: only the variables callers need and outputs such as vpc_id or private_subnet_ids, not every intermediate resource. The README should include purpose, input and output tables, a copy-paste example, and breaking changes documented in a CHANGELOG.

Create a Git repo named to your standard, such as terraform-vpc-aws, then connect it in HCP Terraform under your organisation via Registry, Publish, Module. Grant read access to GitHub, GitLab, or Bitbucket, select the repo, and confirm the module name matches your naming convention. Tag releases with semver Git tags like v1.3.0 and push them to the remote; HCP Terraform indexes tags within minutes. Pre-release tags such as v2.0.0-beta.1 work for staging consumers. Callers then reference app.terraform.io/my-org/vpc/aws with an explicit version pin, for example version = "1.3.0", and authenticate CI using TF_TOKEN_app_terraform_io or the HCP Terraform CLI login flow.

Yes. Terraform supports private module sources beyond the public registry, including HCP Terraform private registry hostnames, Git URLs with ref query parameters, and S3 or GCS archives. You only need the public registry for community modules. Internal patterns stay inside your Git host, HCP Terraform organisation, or private object storage protected by IAM. Many teams start with Git tag sources on day one, especially on GitLab, mirroring how agencies first ship private application packages before adopting a central catalog.

Pick based on auth model, budget, and existing tooling rather than chasing the newest option. HCP Terraform private registry fits teams already running workspaces there: org tokens or SSO, semver version pins like version = "1.2.0", and the best UX, though per-seat cost applies. Git source with tags suits GitLab CI shops and small teams using deploy keys or OAuth tokens and ?ref=v1.2.0, but offers no central module catalog. S3 or GCS buckets fit air-gapped or strict artifact control using IAM roles and object version IDs or paths, with a manual upload step per release. GitLab Terraform Module Registry is an option for GitLab Ultimate customers with semver in the GitLab UI.

Reference the repo URL with an explicit tag ref, never a floating branch. For example, source = "git::https://gitlab.com/my-org/terraform-aws-vpc.git?ref=v1.3.0" pins consumers to v1.3.0. Pass module inputs such as name_prefix and cidr_block in the module block as you would for any registry source. For HTTPS with a token in CI, use the OAuth2 prefix GitLab documents and inject credentials via environment variables in the pipeline. Never commit tokens into .tf files. This pattern works immediately without HCP Terraform and matches how many teams first publish reusable infrastructure before adopting a private catalog.

Package the module as a zip archive, upload it to a private bucket, and reference the immutable object in consumer root modules. For example, source = "s3::https://s3.amazonaws.com/my-org-terraform-modules/vpc-1.3.0.zip" with inputs such as name_prefix and cidr_block. S3 fits teams that already treat artifacts as immutable blobs and want AWS-native access control. Pair module storage with Terraform remote state on S3 with locking for a consistent stack. CI runners need an IAM role with s3:GetObject on the module bucket prefix. Each release requires an explicit upload step, so version discipline matters as much as with Git tags.

Treat the module repo like application code: lint, test, then tag. A minimal GitLab CI pipeline uses validate stages for terraform fmt -check -recursive and terraform validate against examples/complete with terraform init -backend=false, then a test stage running terraform test against files such as vpc.tftest.hcl. Add Checkov scans for Terraform misconfigurations and block merges on CRITICAL findings unless waived with a ticket reference. Publishing without validation is how a typo in variables.tf breaks multiple downstream plans at once. Only maintainers should push v-star tags after CI passes, keeping broken releases out of production consumers.

Match credentials to the module source. For HCP Terraform private registry modules, set TF_TOKEN_app_terraform_io in the pipeline environment or use the HCP Terraform CLI login flow. For GitLab or GitHub Git sources, use a read-only deploy key or fine-grained personal access token injected at runtime, never embedded in .tf files. For S3 module archives, attach an IAM role to the CI runner with s3:GetObject on the module bucket prefix. Consumer pipelines need read-only access; release jobs on the module repo need write access for tagging. Scope tokens narrowly and rotate them on the same schedule as application secrets.

No. Child modules should declare required_providers constraints in versions.tf but leave provider "aws" {} configuration blocks to the root module. Root modules pass credentials and region settings through their own provider blocks, and they pass provider aliases when a module must target multiple regions or accounts. This separation keeps private modules portable across environments and prevents hidden provider assumptions from leaking into shared code. Consumers inherit provider version constraints from the module during init, but they retain control over authentication and account targeting at the stack level.

Always pin an explicit semver release in production root modules, such as version = "1.3.0" for HCP Terraform or ?ref=v1.3.0 for Git sources. Never point production at ref=main; that tracks moving code and defeats semver discipline. Keep environment differences in root modules, not inside shared modules, using separate backends or workspaces per environment. Upgrade through deliberate pull requests that bump the pinned version, similar to Dependabot-style dependency updates, rather than floating refs. Each consumer keeps its own remote state file while the module defines reusable resources.

The recurring failures are process problems, not syntax errors. Leaking secrets into module sources instead of passing credentials via provider config or sensitive variables breaks security immediately. Renaming outputs or removing variables without a MAJOR semver bump strands consumers without a migration path. Over-flexible modules with dozens of boolean flags become unmaintainable; split into two modules when use cases diverge. Skipping examples/complete means you lose your integration test fixture, and if it cannot plan cleanly, consumers will struggle too. Modules standardise creation but do not stop manual console drift, so schedule drift detection on stacks that consume your modules.

Limit module repo write access to infrastructure maintainers and protect v-star tags so only maintainers can push release tags. Give CI tokens scoped read-only access for consumer pipelines and write access only for release jobs that create tags. Keep Terraform state buckets separate from module artifact buckets to reduce blast radius if credentials leak. Document breaking changes in CHANGELOG and bump MAJOR semver when removing or renaming outputs so consumers can upgrade deliberately. Pair registry or Git access controls with organisation boundaries in HCP Terraform so private modules never appear outside the intended team.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: