terraform init registry timeout ci: four layers, one message
A terraform init registry timeout ci failure prints one diagnostic assembled from four separate error messages, each added by a different layer as the failure travelled up, and the useful one is at the bottom. Read from the top and you will conclude the provider is missing; read from the bottom and you will see that a TCP connection or a TLS handshake never completed.

What this error means
The Finding <provider> versions matching ... line is followed by a diagnostic headed Failed to query available provider packages, then a body that nests several clauses separated by colons, then a suggestion to run terraform providers. The summary line is the same for every failure in this family, including a provider name that does not exist, so it carries no diagnostic value on its own. The clause worth finding is the second one. could not connect to <host> means service discovery failed, so Terraform never learned where the registry's provider endpoints are. could not query provider registry for <address> means discovery succeeded and the versions call failed. failed to query provider mirror <url> means you are on a network mirror and possibly did not know it.
Error: Failed to query available provider packages
Could not retrieve the list of available versions for provider
localhost:8946/hashicorp/random: could not connect to localhost:8946: failed
to request discovery document: Get
"https://localhost:8946/.well-known/terraform.json": net/http: TLS handshake
timeout
To see which modules are currently depending on
localhost:8946/hashicorp/random and what versions are specified, run the
following command:
terraform providersReading the chain from the bottom
The outermost layer is terraform init itself. queryPackagesFailureCallback in internal/command/init.go appends a diagnostic whose summary is always Failed to query available provider packages and whose detail begins Could not retrieve the list of available versions for provider <address>: , followed by whatever error it was handed and a suggestion. Every branch of that switch, including the one for a provider that simply does not exist, uses the same summary.
Below it sits internal/getproviders/errors.go. ErrHostUnreachable renders as could not connect to <hostname>: <wrapped>, and ErrQueryFailed renders as could not query provider registry for <provider>: <wrapped> or, when a mirror URL is set, failed to query provider mirror <url> for <provider>: <wrapped>. Those three are mutually exclusive and they tell you which stage of the lookup died.
Below that is service discovery, which contributes failed to request discovery document: , and at the very bottom is Go's HTTP client with the actual transport error. In the block above, that bottom clause is net/http: TLS handshake timeout, which means the TCP connection was established and the TLS negotiation never finished. A dial tcp ...: i/o timeout in the same position would mean the connection itself never completed, and a no such host would mean the name never resolved. Same four-layer message, three different problems.
| Second clause | What already worked | Where to look |
|---|---|---|
could not connect to <host> | Nothing. Discovery never completed. | Egress to the registry host, TLS interception, DNS. |
could not query provider registry for <addr> | Discovery. The versions endpoint failed. | The registry itself, or the client timeout. |
failed to query provider mirror <url> for <addr> | Terraform is using a mirror, not the public registry. | Your CLI configuration, and the mirror's health. |
provider registry <host> does not have a provider named ... | Everything. The registry answered. | The source address in required_providers. |
Common causes
TLS interception in the egress path
A proxy that terminates TLS to inspect it will either present a certificate Terraform does not trust or stall the handshake under load. The handshake timeout clause is the signature, because it proves a TCP connection was made: the packets are reaching something, and that something is not completing the negotiation. Nothing about the registry is implicated.
Every job runs terraform init and downloads every provider
Without a plugin cache, each job in each workflow fetches the full provider set again, which for a stack with several large providers is hundreds of megabytes per run. That is both the slowest part of the pipeline and the part most exposed to a network blip, and it grows with the matrix rather than with the codebase.
A CLI configuration file redirects you to a mirror you forgot about
A provider_installation block in the CLI configuration, inherited from an image or an organisation default, sends every lookup to a network mirror. When that mirror is slow or partly populated, the failure names the mirror rather than the registry, which is the clue, and in our experience the response to that clue is usually surprise that a mirror was configured at all.
The registry is genuinely having a bad few minutes
It happens, and it is the case the default of two attempts is least equipped for. The distinguishing feature is that the bottom clause is a 5xx status or a read timeout rather than a connection or handshake failure, and that a re-run a few minutes later succeeds with nothing changed. This is the only one of these four where retrying the job is the correct response.
How to fix it
Cache providers so init stops being a network operation
- Set
TF_PLUGIN_CACHE_DIRto a path inside the workspace and create it before init runs, because Terraform will not create it for you and silently skips caching if it is missing. - Cache that directory across runs, keyed on the dependency lock file so the key moves exactly when a provider version moves.
- Run
terraform init -lockfile=readonlyin CI so a run cannot quietly change the lock file and invalidate the key it was restored with.
- uses: actions/cache@v4
with:
path: .terraform.d/plugin-cache
key: tf-plugins-${{ runner.os }}-${{ hashFiles('**/.terraform.lock.hcl') }}
restore-keys: tf-plugins-${{ runner.os }}-
- run: |
mkdir -p .terraform.d/plugin-cache
terraform init -input=false -lockfile=readonlyRaise the two registry defaults when the clock says timeout
Ten seconds and one retry are tuned for an interactive session on a good network. In CI behind a proxy, thirty seconds and three retries cost a few seconds on the happy path and remove a category of failure. Set them in the job environment so every Terraform invocation in the job agrees rather than only the one that failed last.
env:
TF_REGISTRY_CLIENT_TIMEOUT: "30"
TF_REGISTRY_DISCOVERY_RETRY: "3"Find out whether you are on a mirror
Before changing anything about the public registry, check whether your jobs are even talking to it. The CLI configuration file can be supplied by an image, by an environment variable or by a home directory nobody inspects, and the second clause of the error names a mirror when one is in use. Printing the configuration once per run settles it permanently.
- run: |
echo "TF_CLI_CONFIG_FILE=${TF_CLI_CONFIG_FILE:-unset}"
cat "${TF_CLI_CONFIG_FILE:-$HOME/.terraformrc}" 2>/dev/null || echo "no CLI config file"Run your own mirror when the registry is a dependency you cannot accept
For a pipeline that must not be stopped by a third party, terraform providers mirror writes every provider your configuration needs into a directory you can serve or commit. Initialising against that removes the public registry from the critical path entirely, at the cost of a deliberate step whenever you add or upgrade a provider.
terraform providers mirror ./tf-mirror
terraform init -plugin-dir=./tf-mirror -input=falseThe two numbers that decide how this fails
Both are constants in internal/getproviders/registry_client.go and both are overridable by environment variable, which makes them the cheapest lever in a CI job. defaultRequestTimeout is 10 seconds, read from TF_REGISTRY_CLIENT_TIMEOUT in seconds. defaultRetry is 1, read from TF_REGISTRY_DISCOVERY_RETRY, and it is the retry count rather than the attempt count, so the default is two attempts.
When the retries are used up, the client's error handler produces one of two sentences depending on whether it retried at all. With retries it reads the request failed after <n> attempts, please try again later, where the count is retries plus one, so the default failure says two attempts. Without retries it reads the request failed, please try again later. Either way a colon and the underlying detail follow. Seeing the attempt count in your log is the quickest way to confirm what your environment variables are actually set to.
Ten seconds is short for a registry behind a congested proxy and long for an endpoint that is simply unreachable, which is why both directions are sometimes right. The honest test is whether the failure arrives after roughly ten seconds per attempt, in which case the timeout is firing, or immediately, in which case something is refusing or failing to resolve and no timeout value will change it.
jobs:
plan:
runs-on: ubuntu-latest
env:
TF_REGISTRY_CLIENT_TIMEOUT: "30"
TF_REGISTRY_DISCOVERY_RETRY: "3"
TF_PLUGIN_CACHE_DIR: ${{ github.workspace }}/.terraform.d/plugin-cache
steps:
- uses: actions/checkout@v5
- run: mkdir -p "$TF_PLUGIN_CACHE_DIR"
- run: terraform init -input=false -lockfile=readonlyHow the block above was produced
A local TCP listener was started that accepts connections and never writes anything back, and a provider source was pointed at it with TF_REGISTRY_CLIENT_TIMEOUT set to three seconds to keep the run short. Terraform tried to fetch the discovery document over HTTPS, the handshake stalled, and Go's client reported it. The line wrapping in the block is Terraform's own diagnostic renderer, not something added afterwards.
That was run on a laptop on 2026-09-21 on Terraform v1.14.8, not on a Latchkey runner, and the host in it is a local port rather than the public registry. Doing the same thing against registry.terraform.io would mean waiting for a real outage or breaking a runner's egress, and the second of those produces a message about the runner. What a runner reproduction would have added is nothing the chain does not already show, since every clause in it is a format string at a named version. content/heal-evidence.mjs has no record for this slug, so this page makes no claim that Latchkey repairs the failure.
How to prevent it
- Set TF_PLUGIN_CACHE_DIR and create the directory, then cache it keyed on the lock file.
- Put the registry timeout and retry values in the job environment rather than on one command.
- Print whether a CLI configuration file is in play, so a mirror can never be a surprise.
- Alert on the second clause of the diagnostic, since the summary line is identical for every cause.
Frequently asked questions
What is the default Terraform registry timeout?
defaultRequestTimeout in internal/getproviders/registry_client.go and overridable with TF_REGISTRY_CLIENT_TIMEOUT, which is read in seconds. The companion setting is defaultRetry, which is 1 and is read from TF_REGISTRY_DISCOVERY_RETRY. Because it counts retries rather than attempts, the default is two attempts in total.Does "Failed to query available provider packages" mean the provider does not exist?
init.go, including unreachable hosts, failed mirrors and registries that answered normally to say they have no such provider. The detail underneath is where the branches differ, and the clause right after the provider address is the one that tells you which case you have.What does "net/http: TLS handshake timeout" mean here?
dial tcp timeout in the same position would mean the connection itself never completed.Will a plugin cache stop terraform init from contacting the registry?
terraform init -lockfile=readonly against a committed lock file alongside the cache is what makes init close to offline. A full provider mirror is the only way to remove the registry entirely.Related guides
References
- hashicorp/terraform: queryPackagesFailureCallback, which writes the summary and detail
- hashicorp/terraform: getproviders errors, where each second clause is rendered
- hashicorp/terraform: registry_client.go, the timeout and retry defaults
- Terraform docs: the provider plugin cache and TF_PLUGIN_CACHE_DIR
- Terraform documentation
- GitHub Actions documentation