# terraform init registry timeout ci: four layers, one message

> A terraform init registry timeout ci failure stacks four messages into one diagnostic. Read it from the bottom, then change the right timeout.

Source: https://latchkey.dev/learn/failures/terraform-provider-registry-timeout-in-ci  
Updated: 2026-09-21

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.

```Captured locally on Terraform v1.14.8, 2026-09-21, against a local listener that accepts and never answers. Not runner output
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 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

1. Set `TF_PLUGIN_CACHE_DIR` to 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.
2. Cache that directory across runs, keyed on the dependency lock file so the key moves exactly when a provider version moves.
3. Run `terraform init -lockfile=readonly` in CI so a run cannot quietly change the lock file and invalidate the key it was restored with.

```.github/workflows/terraform.yml
- 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=readonly
```

### Raise 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.

```.github/workflows/terraform.yml
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.

```.github/workflows/terraform.yml
- 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.

```Terminal
terraform providers mirror ./tf-mirror
terraform init -plugin-dir=./tf-mirror -input=false
```

## 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.

## Reading 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. |

> The suggestion at the end, to run `terraform providers`, is attached to most branches of the same switch regardless of cause. It is useful when a module you did not write pulled in the provider and useless when the registry is unreachable, so do not read its presence as a hint about which failure you have.

## The 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.

```.github/workflows/terraform.yml
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=readonly
```

## How 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.

## FAQ

### What is the default Terraform registry timeout?

Ten seconds per request, set as `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?

Not on its own. That summary is used by every branch of the failure callback in `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?

That a TCP connection to the registry host succeeded and the TLS negotiation on top of it did not finish in time. It rules out DNS and basic reachability, and it points at whatever sits between you and the registry terminating or delaying TLS, such as an inspecting proxy. A `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?

It removes the download, not the lookup. Terraform still consults the registry to resolve version constraints unless your dependency lock file already pins everything, which is why running `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.

## References

- [hashicorp/terraform: queryPackagesFailureCallback, which writes the summary and detail](https://github.com/hashicorp/terraform/blob/v1.14.8/internal/command/init.go)
- [hashicorp/terraform: getproviders errors, where each second clause is rendered](https://github.com/hashicorp/terraform/blob/v1.14.8/internal/getproviders/errors.go)
- [hashicorp/terraform: registry_client.go, the timeout and retry defaults](https://github.com/hashicorp/terraform/blob/v1.14.8/internal/getproviders/registry_client.go)
- [Terraform docs: the provider plugin cache and TF_PLUGIN_CACHE_DIR](https://developer.hashicorp.com/terraform/cli/config/config-file#provider-plugin-cache)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
