How to Fix Terraform 'Error: failed to download module' via Git SSH (Complete Debugging Guide)
Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 10–20 mins
TL;DR
- What broke: Terraform's
go-getterlibrary cannot complete an SSH handshake to your Git remote when downloading asourcemodule — caused by a missing SSH key, unverified host fingerprint, wrong URL scheme, or a blocking Git credential helper. - How to fix it: Verify your SSH key is loaded in the agent, the remote host is in
~/.ssh/known_hosts, and your modulesourceuses the correctgit::ssh://orgit@syntax with a deploy key that has repo read access. - Shortcut: Use the Client-Side Sandbox above to paste your failing
module {}block and SSH config — it auto-refactors the source URL and generates the correctedgit configandssh-addcommands without sending your keys anywhere.
The Incident (What does the error mean?)
Raw error output from terraform init:
Initializing modules...
╷
│ Error: Failed to download module
│
│ on main.tf line 12, in module "vpc":
│ 12: source = "[email protected]:corp/private-infra-modules.git//vpc?ref=v2.1.0"
│
│ Could not download module "vpc" (main.tf:12) source code from
│ "git::ssh://[email protected]/corp/private-infra-modules.git//vpc?ref=v2.1.0":
│ error downloading 'ssh://[email protected]/corp/private-infra-modules.git//vpc?ref=v2.1.0':
│ /usr/bin/git exited with 128: Host key verification failed.
│ fatal: Could not read from remote repository.
│
│ Please make sure you have the correct access rights
│ and the repository exists.
╵
Immediate consequence: terraform init exits non-zero. No modules are installed. Every subsequent terraform plan or terraform apply in this workspace — including your CI/CD pipeline — is completely blocked. If this is a shared module registry pattern, every downstream consumer of this root module is dead.
The Attack Vector / Blast Radius
This is not just a developer annoyance. The failure surface is wider than it looks:
1. CI/CD Pipeline Full Stop
Any pipeline runner (GitHub Actions, GitLab CI, Jenkins agent, Atlantis) that doesn't have the SSH key injected as an environment secret or mounted volume will fail at terraform init. If your pipeline auto-approves on merge, this breaks your entire infrastructure deployment chain.
2. Hardcoded git@ vs git::ssh:// Scheme Confusion
Terraform's go-getter internally rewrites [email protected]:org/repo.git to git::ssh://[email protected]/org/repo.git. If your ~/.gitconfig has a insteadOf rewrite rule (common in corporate environments that force HTTPS), go-getter and Git fight over the URL scheme — go-getter wins the rewrite but Git then fails authentication because the HTTPS credential helper has no SSH fallback.
3. Host key verification failed = TOFU Bypass Risk
The Host key verification failed error means github.com (or your GitLab/Bitbucket host) is not in ~/.ssh/known_hosts on the runner. The naive "fix" engineers reach for is StrictHostKeyChecking no in ~/.ssh/config. This is a critical security misconfiguration. It opens your pipeline runner to SSH MITM attacks — an adversary on the network path can intercept the Git clone, inject malicious Terraform module code, and your terraform apply will execute it against production infrastructure. Never do this.
4. Deploy Key Scope Creep
Using a personal SSH key (your ~/.ssh/id_rsa) instead of a scoped deploy key means rotating or revoking your personal key breaks all pipelines. It also grants the pipeline identity access to every repo your personal account can reach — a significant blast radius if the runner is compromised.
How to Fix It (The Solution)
Diagnosis Checklist — Run These First
# 1. Is the SSH agent running and is your key loaded?
ssh-add -l
# 2. Can you reach the Git host at all?
ssh -T [email protected]
# Expected: "Hi username! You've successfully authenticated..."
# 3. Is the host in known_hosts?
ssh-keygen -F github.com
# 4. Check for conflicting gitconfig rewrites
git config --global --list | grep insteadOf
Fix 1: Host Key Not in known_hosts (Most Common)
The error: Host key verification failed
# Securely add the host fingerprint — verify it matches GitHub's published fingerprint
# https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints
ssh-keyscan -H github.com >> ~/.ssh/known_hosts
# Verify the fingerprint BEFORE trusting it
ssh-keygen -lf ~/.ssh/known_hosts | grep github.com
# Must match: SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s (RSA)
For CI runners (GitHub Actions):
- # No known_hosts setup — runner fails on first SSH contact
+ - name: Add GitHub to known_hosts
+ run: |
+ mkdir -p ~/.ssh
+ ssh-keyscan -H github.com >> ~/.ssh/known_hosts
+ chmod 600 ~/.ssh/known_hosts
Fix 2: SSH Key Not Loaded in Agent
- # Key exists on disk but ssh-add was never called
- # ssh-add -l returns: "The agent has no identities."
+ eval "$(ssh-agent -s)"
+ ssh-add ~/.ssh/id_ed25519_terraform_deploy
+ # Verify
+ ssh-add -l
Fix 3: Incorrect Module Source URL Scheme
Terraform go-getter requires a specific syntax. The [email protected]:org/repo.git SCP-style shorthand works in native Git but can break go-getter's URL parser in edge cases.
module "vpc" {
- source = "[email protected]:corp/private-infra-modules.git//vpc?ref=v2.1.0"
+ source = "git::ssh://[email protected]/corp/private-infra-modules.git//vpc?ref=v2.1.0"
version = "~> 2.1"
}
Note: The double-slash
//is thego-gettersubdirectory separator. It is not a typo.
Fix 4: Corporate gitconfig insteadOf Rewrite Conflict
If your org forces HTTPS for all Git operations:
# This is the killer — it rewrites ssh:// to https:// and breaks go-getter
git config --global url."https://".insteadOf git://
# Option A: Remove the global rewrite (if you control the machine)
- [url "https://"]
- insteadOf = git://
# Option B: Add a scoped override just for your Git host
+ [url "git::ssh://[email protected]/"]
+ insteadOf = [email protected]:
Or set this per-repo in your pipeline environment:
- # No git config override — corporate insteadOf breaks go-getter
+ - name: Override git insteadOf for Terraform SSH modules
+ run: git config --global url."git::ssh://[email protected]/".insteadOf "[email protected]:"
Enterprise Best Practice: Scoped Deploy Key + SSH Config File
Never use a personal SSH key in CI. Use a read-only deploy key scoped to the specific repository.
Step 1: Generate a dedicated deploy key
ssh-keygen -t ed25519 -C "terraform-ci-deploy-key" -f ~/.ssh/terraform_deploy_ed25519 -N ""
# Add the PUBLIC key to GitHub repo → Settings → Deploy Keys → Read Only
cat ~/.ssh/terraform_deploy_ed25519.pub
Step 2: Configure SSH to use it only for this host alias
# ~/.ssh/config
+ Host github-terraform
+ HostName github.com
+ User git
+ IdentityFile ~/.ssh/terraform_deploy_ed25519
+ IdentitiesOnly yes
+ StrictHostKeyChecking yes
Step 3: Update the module source to use the alias
module "vpc" {
- source = "git::ssh://[email protected]/corp/private-infra-modules.git//vpc?ref=v2.1.0"
+ source = "git::ssh://git@github-terraform/corp/private-infra-modules.git//vpc?ref=v2.1.0"
}
Step 4: Inject the private key in CI as a secret (GitHub Actions)
+ - name: Configure SSH deploy key for Terraform modules
+ env:
+ SSH_PRIVATE_KEY: ${{ secrets.TERRAFORM_MODULE_DEPLOY_KEY }}
+ run: |
+ mkdir -p ~/.ssh
+ echo "$SSH_PRIVATE_KEY" > ~/.ssh/terraform_deploy_ed25519
+ chmod 600 ~/.ssh/terraform_deploy_ed25519
+ ssh-keyscan -H github.com >> ~/.ssh/known_hosts
+ chmod 600 ~/.ssh/known_hosts
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
1. Checkov — Detect Hardcoded SSH Key Paths
pip install checkov
checkov -d . --check CKV_GIT_1
Add a custom Checkov policy to flag non-deploy-key SSH sources:
# checkov/custom/terraform_ssh_source_check.py
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
class TerraformModuleSSHSourceCheck(BaseResourceCheck):
def __init__(self):
name = "Ensure Terraform module SSH sources use deploy key host aliases"
id = "CKV_CUSTOM_TF_SSH_001"
...
2. Pre-commit Hook — Validate SSH Connectivity Before Push
# .pre-commit-config.yaml
- repo: local
hooks:
- id: terraform-ssh-check
name: Validate Terraform SSH module connectivity
entry: bash -c 'ssh -T [email protected] 2>&1 | grep -q "successfully authenticated" || (echo "SSH auth to GitHub failed. Run: ssh-add ~/.ssh/terraform_deploy_ed25519" && exit 1)'
language: system
pass_filenames: false
3. Terraform Cloud / Atlantis — Mount SSH Keys as Workspace Variables
In Terraform Cloud, use the SSH_KEY environment variable (Settings → SSH Keys) to attach a deploy key to the workspace. This is the only supported method — agent forwarding is not available in TFC runners.
In Atlantis, mount the key via Kubernetes Secret:
- # No SSH key mount — Atlantis pod fails terraform init
+ volumeMounts:
+ - name: terraform-ssh-key
+ mountPath: /home/atlantis/.ssh
+ readOnly: true
+ volumes:
+ - name: terraform-ssh-key
+ secret:
+ secretName: terraform-deploy-key
+ defaultMode: 0600
4. OPA/Conftest Policy — Enforce Pinned Refs on SSH Module Sources
Unpinned SSH module sources (?ref=main) are a supply chain risk — a force-push to main changes what your terraform init downloads.
# policy/terraform_module_ref.rego
package terraform.modules
deny[msg] {
mod := input.module[_]
source := mod.source
contains(source, "git::ssh://")
not contains(source, "?ref=")
msg := sprintf("Module source '%v' must pin a ref tag. Use ?ref=vX.Y.Z", [source])
}
conftest test main.tf --policy policy/