Skip to content
Infrastructure as Code iac modules 4 min read

Module Sources

The source argument tells Terraform where to fetch a module’s code. It is the single most important field in a module block, because it determines how the module is downloaded, whether it can be versioned, and how reproducible your builds are. Terraform supports a handful of source types — local paths, the Terraform Registry, Git, generic HTTP, and S3 — and each has a distinct syntax and set of trade-offs. Everything here works identically on OpenTofu, which shares the same module installer and source addressing scheme.

How sources are resolved

When you run terraform init, Terraform reads every source in the configuration, downloads the referenced code into .terraform/modules, and records the resolution in .terraform/modules/modules.json. Local paths are read in place; all other sources are copied locally. You must re-run terraform init whenever you add, remove, or change a source, and terraform init -upgrade to re-fetch a remote source that is not pinned to an immutable version.

terraform init

Output:

Initializing modules...
Downloading registry.terraform.io/terraform-aws-modules/vpc/aws 5.8.1 for vpc...
- vpc in .terraform/modules/vpc
- network in modules/network

Terraform has been successfully initialized!

Source type reference

Source typeExample addressVersioningBest for
Local path./modules/networkNone (in-repo)Code shared within one configuration/repo
Registryterraform-aws-modules/vpc/awsversion constraintReusable, published, versioned modules
Git (SSH/HTTPS)git::https://github.com/org/repo.git//path?ref=v1.2.0Pin with ?ref=Private modules, mono-repos, forks
GitHub shorthandgithub.com/org/repo//path?ref=v1.2.0Pin with ?ref=Public GitHub repos
HTTP archivehttps://example.com/module.zipURL/content onlySelf-hosted archives, CI artifacts
S3 buckets3::https://s3.amazonaws.com/bucket/module.zipObject versionInternal distribution on AWS

Local paths

A local source begins with ./ or ../ and points at a directory inside your project. Terraform does not copy local modules — it reads them in place — so changes take effect on the next plan without re-initialization (though you still init the first time). Local paths are ideal for splitting one configuration into logical units that ship together.

module "network" {
  source = "./modules/network"

  vpc_cidr = "10.0.0.0/16"
  azs      = ["us-east-1a", "us-east-1b"]
}

A local module that lives in a sibling directory (../shared) is still local — but it must be inside the same VCS checkout. If teammates do not have that directory, init fails. For cross-repo sharing, publish to a registry or Git instead.

The Terraform Registry

Registry sources use the three-part <NAMESPACE>/<NAME>/<PROVIDER> form and resolve against the public Terraform Registry by default. These are the only sources that support the version argument with constraint operators, so they are the cleanest way to consume well-maintained, semantically versioned modules.

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.8"

  name = "prod-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["us-east-1a", "us-east-1b", "us-east-1c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]

  enable_nat_gateway = true
  single_nat_gateway = true
}

A private registry (Terraform Cloud, Spacelift, or a self-hosted one) uses a four-part <HOST>/<NAMESPACE>/<NAME>/<PROVIDER> address — for example app.terraform.io/devcraftly/vpc/aws.

Git and GitHub

Git sources let you pull modules straight from any reachable repository. Prefix the address with git:: for an explicit Git source, use // to point at a subdirectory, and always pin a commit, tag, or branch with ?ref=. Tags are strongly preferred because they are immutable in practice.

module "billing" {
  source = "git::https://github.com/devcraftly/terraform-modules.git//billing?ref=v2.4.0"

  account_id = "123456789012"
}

module "internal_lb" {
  # SSH form — uses your local SSH agent/keys for private repos
  source = "git::ssh://[email protected]/devcraftly/infra.git//modules/lb?ref=fb8c1e2"
}

GitHub and Bitbucket have detected shorthand that does not need the git:: prefix:

module "consul" {
  source = "github.com/hashicorp/terraform-aws-consul//modules/consul-cluster?ref=v0.11.0"
}

Git sources have no version argument. Pinning a moving target like ?ref=main means the next terraform init -upgrade can silently change your plan. Pin a tag or full SHA for reproducible builds.

HTTP and S3 archives

For modules distributed as archives, Terraform can fetch over plain HTTP(S) or directly from S3 (and GCS via gcs::). An HTTP source that ends in .zip, .tar.gz, or similar is unpacked automatically; the //subdir syntax selects a folder inside the archive.

module "agent" {
  source = "https://artifacts.devcraftly.com/modules/agent-1.3.0.zip//agent"
}

module "audit" {
  # AWS credentials are resolved from the standard provider chain
  source = "s3::https://s3.us-east-1.amazonaws.com/devcraftly-modules/audit.zip//audit"
}

These sources are content-addressed only by URL, so embed a version number in the object key (as above) and treat each release as immutable rather than overwriting an existing archive.

Best Practices

  • Use local paths only for code that lives in the same repository; publish anything shared across teams to a registry or Git.
  • Prefer registry sources with a version constraint — they give you proper semantic versioning and init-time validation.
  • Always pin Git sources to an immutable tag or commit SHA via ?ref=, never a bare branch like main.
  • Encode a version in HTTP/S3 archive keys and never overwrite a published archive in place.
  • Use the SSH Git form (git::ssh://) for private repositories so authentication flows through your SSH agent rather than embedded tokens.
  • Re-run terraform init after changing any source, and terraform init -upgrade to deliberately move pinned remote modules forward.
Last updated June 14, 2026
Was this helpful?