The five source types
| Type | Example | When |
|---|---|---|
| Local | ./modules/s3-bucket | The module lives in this same repo |
| Terraform Registry | terraform-aws-modules/vpc/aws | A public module with semver versioning |
| Git | git::https://github.com/org/repo.git//modules/x?ref=v1.2.3 | A private module in git |
| HTTP archive | https://example.com/modules/x-v1.zip//x | A CDN or artifact store |
| S3 | s3::https://s3.amazonaws.com/bucket/x.zip | An internal S3 bucket used as a registry |
The type comes from the shape of the string, not from a separate field. Terraform infers it for you.
Local
module "logs" {source = "./modules/s3-bucket"
name = "..."
}
- The path is relative to the
.tffile that holds themoduleblock. terraform initcreates a symlink under.terraform/modules/logs/.- Any edit to the module's HCL shows up immediately. These are just files in the repo.
- No versioning. The module is part of your repo, so its version is whatever git HEAD points to.
Use this for modules that live alongside the root. Do not reach into a "neighboring" project through a local path, or you will spend your time fighting relative paths.
Terraform Registry
module "vpc" {source = "terraform-aws-modules/vpc/aws"
version = "5.7.0"
name = "main"
cidr = "10.0.0.0/16"
# ...
}
Format: <namespace>/<name>/<provider>. Three segments, no https://.
- The registry is registry.terraform.io (public) or a private one (HCP Terraform, Spacelift, and so on).
versionis a semver constraint:"5.7.0","~> 5.7",">= 5.0, < 6.0". See tf-module-versioning.- Leave out
versionand you get the latest. In CI that is a time bomb: tomorrow 6.0 ships with breaking changes, and the build breaks without a single commit on your side. Always pinversion.
The public AWS modules (terraform-aws-modules/vpc/aws, .../eks/aws,
.../alb/aws) are the de facto standard. They are large and parameterized
for every edge case. They fit when your requirements match the standard. When
they do not, writing your own module is easier than figuring out which of 40
inputs to flip.
Git
# SSH
module "billing" {source = "git::ssh://git@github.com/myorg/terraform-modules.git//billing?ref=v1.2.3"
}
# HTTPS
module "billing" {source = "git::https://github.com/myorg/terraform-modules.git//billing?ref=v1.2.3"
}
Format: git::<git-url>//<subpath>?ref=<branch_tag_or_sha>.
//before the subpath is a Terraform separator, not part of the git URL.refis a branch, tag, or commit SHA. For reproducibility, use only a tag or SHA, never a branch. Otherwiseinitpulls one commit today and a different one tomorrow.- Authentication is standard git: an SSH key, HTTPS basic auth, or a token through
GIT_TERMINAL_PROMPTor a credential helper. - Corporate setups usually keep one git repo with a subdirectory per module. That makes versioning easy: a single tag
v1.2.3pins everything at once.
HTTP archive
module "x" {source = "https://example.com/modules/x-v1.0.tar.gz//x"
}
Terraform downloads the archive, unpacks it, and descends into the //x
subpath. Supported formats: .zip, .tar.gz, .tar.bz2, .tar.xz.
This is handy when modules are published to a CDN or an artifact store (Artifactory, Nexus). Versioning happens through the file name or an HTTP header.
S3
module "x" {source = "s3::https://s3-eu-west-1.amazonaws.com/my-bucket/modules/x-v1.zip"
}
The s3:: prefix tells Terraform to use AWS credentials for access, from the
same credentials chain as the AWS provider.
This is a middle ground between running your own git and running your own registry: cheap, private, and no extra service to operate. The downside is that there is no built-in semver, so you version by hand through file names.
Caching and init
On terraform init, Terraform downloads every module into
.terraform/modules/:
.terraform/modules/
├── modules.json # mapping: name in HCL → path
├── vpc/ # downloaded code for the vpc module
└── billing/ # downloaded code for the billing module
This folder is a cache. Edit a local module's HCL and the change is visible
right away. Change source = "git::...?ref=v2.0.0", though, and you need
terraform init -upgrade. Otherwise Terraform keeps using the old cached
copy.
Gotchas
-
You cannot interpolate
source. Nosource = "git::...?ref=${var.v}". Source is resolved during init, before variables exist. A module's version is a static value. If you need to "switch versions dynamically", keep them in separate root modules or use Terragrunt. -
A branch instead of a tag is a landmine.
?ref=mainmeans "whatever main is right now". Tomorrow someone merges a breaking change into main, yourterraform initpulls it, and plan shows a destroy plus create. Always use a tag or SHA. -
Registry modules can pull in other registry modules. Large AWS modules use dozens of subdependencies internally. One
module "vpc"makes.terraform/modules/swell to 30+ modules. That is normal, not a bug. -
source = "./modules/x"breaks after you move the repo around. Move the modules into a subfolder and the relative path is broken. A bulk grep across the repo fixes it, but it hurts. The deeper the structure, the more often you hit this. -
Do not use HTTP without HTTPS.
source = "http://..."disables the TLS check. That is an opening for a "swap the module on the build machine" attack. -
Authenticate private git through a credential helper, not in the URL. Do not write
git::https://user:token@github.com/..., or the token lands in logs and in.terraform/modules/modules.json. Usegit config --global credential.helper storelocally and a secrets mechanism in CI.