
September 10, 2026
12 min read
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 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
- Create a Git repo named
terraform-<name>-<provider>, e.g.terraform-vpc-aws. - In HCP Terraform, open your organisation → Registry → Publish → Module.
- Connect GitHub, GitLab, or Bitbucket with read access to that repo.
- 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.
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.
| Option | Best for | Auth model | Version pinning | Trade-off |
|---|---|---|---|---|
| HCP Terraform private registry | Teams already on HCP Terraform | Org tokens, SSO | version = "1.2.0" | Per-seat cost; best UX |
| Git source with tags | GitLab CI shops, small teams | Deploy keys, OAuth tokens | ?ref=v1.2.0 query param | No central module catalog |
| S3 / GCS module bucket | Air-gapped or strict artifact control | IAM roles | Object version ID or path | Manual upload step per release |
| GitLab Terraform Module Registry | GitLab Ultimate customers | GitLab project tokens | Semver in GitLab UI | GitLab 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.
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
versiondeliberately, never floating refs.
Never point production at ref=main. That ref tracks moving code and defeats the purpose of semver.
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 workingexamples/directory. - Publish semver Git tags (
v1.2.0) and never let production consumers trackmain. - 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
versionin 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
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.

