2026-07-10
    7 min read

    Why terraform init Downloads the Same Provider 40 Times — and How to Stop It

    Hardik Shah

    Hardik Shah

    Cloud Architect & AWS Expert

    Terraform
    Infrastructure as Code
    DevOps
    CI/CD
    Provider Cache
    IaC
    Performance
    HashiCorp
    Why terraform init Downloads the Same Provider 40 Times — and How to Stop It

    Open a fresh Terraform working directory, run terraform init, and watch it pull down the AWS provider. It's more than half a gigabyte. Now open the directory next door — a different environment, same provider — and run init again. Terraform downloads the whole thing a second time, into its own private .terraformfolder. Do that across forty environments in a monorepo and you've downloaded the exact same bytes forty times and parked forty copies on your disk.

    There's a one-line fix that's been in Terraform for years and that a surprising number of teams still don't use: the provider plugin cache. Point Terraform at a shared directory and every initchecks there first. The bytes hit the network and the disk once; everything after that is a symlink. Here's how it works, what it actually saves, and the one sharp edge that trips people up in CI.

    Diagram: three Terraform working directories (prod, stage, dev) all symlinking into one shared plugin-cache directory instead of each downloading its own provider copy

    Every working directory installs from one shared cache instead of downloading its own copy.

    What init is actually doing

    The mental model most people carry — "initsets up my directory" — hides the expensive part. Under the hood, for every provider your configuration requires, init resolves a version, downloads a platform-specific package from the origin registry, verifies it, and unpacks it into .terraform/providers/ inside this working directory. That last word is the whole problem. The install is scoped to the directory, so nothing is shared. Ten directories that all use hashicorp/aws version 5.x means ten downloads and ten unpacked copies of the same binary.

    On a laptop that's annoying. In CI, where each job starts from an empty checkout, it's a tax you pay on every single pipeline run — network time, registry rate limits, and the wall-clock minutes engineers spend watching a progress bar.

    The fix is one environment variable

    Tell Terraform where to keep a shared cache and it does the rest:

    bash
    # create the directory first — Terraform will NOT create it for you
    mkdir -p "$HOME/terraform-cache"
    
    # point Terraform at it
    export TF_PLUGIN_CACHE_DIR="$HOME/terraform-cache"

    Prefer it permanent? Put it in the Terraform CLI config file instead of your shell profile — ~/.terraformrc on macOS and Linux, or %APPDATA%\terraform.rc on Windows:

    hcl
    # ~/.terraformrc
    plugin_cache_dir = "$HOME/terraform-cache"

    If both are set, the TF_PLUGIN_CACHE_DIRenvironment variable wins. That's handy: a sane default in your config file, overridden per-job in CI.

    Run the numbers on your own setup before you bother — for a single directory this saves nothing. The payoff scales with how many directories share providers:

    Try it: your repo, your numbers

    Drag the sliders. The gap on the right is what the plugin cache gives back.

    No cache

    25 GB

    on disk

    40 full downloads from the registry

    Plugin cache on

    650 MB

    on disk — the rest are symlinks

    1 download, then cache hits

    That's 25 GB of disk and 39 redundant downloads gone — about 98%.

    What happens on the next init

    With the cache set, init changes its behavior for each required provider:

    • Cache hit — the exact provider, version, and platform is already in the cache, so Terraform installs it into .terraform as a symlink on any OS that supports them (macOS, Linux). No download, no second copy on disk.
    • Cache miss — Terraform downloads the package into the cache first, then installs from there. So even the very first directory populates the cache for everyone after it.
    • No symlinks available(Windows, some filesystems) — Terraform copies from the cache instead of downloading. You still save the network round trip; you don't save the disk.

    The cache is keyed on provider, version, and platform, so multiple versions and multiple OS/arch builds coexist happily. Upgrade one environment to a newer AWS provider and it downloads that one new version once — the older environments keep pointing at the version they pinned.

    Why it's worth the two minutes

    You getBecause
    Faster initA cache hit is a local symlink, not an HTTPS download and unpack. Second-and-later inits go from seconds-to-minutes down to near-instant.
    Less diskOne real copy per version instead of one per working directory. On a monorepo that's the difference between gigabytes and megabytes.
    Less bandwidthEach provider version is pulled from the registry once per machine, not once per directory. Kinder to your network and to the public registry.
    Fewer registry failuresA warm cache means most inits never call the registry, so a registry hiccup or rate limit doesn't block your pipeline.

    The real payoff is CI

    On a laptop the cache warms itself over a week of normal work. In CI, every job is a cold start — which is exactly where a persisted cache pays for itself. Set the variable, point it at a path your CI can cache between runs, and key that cache on your lock file so it refreshes when you bump a provider:

    yaml
    jobs:
      plan:
        runs-on: ubuntu-latest
        env:
          TF_PLUGIN_CACHE_DIR: ${{ github.workspace }}/.terraform-plugin-cache
        steps:
          - uses: actions/checkout@v4
    
          - name: Create the cache directory
            run: mkdir -p "$TF_PLUGIN_CACHE_DIR"
    
          - name: Restore Terraform provider cache
            uses: actions/cache@v4
            with:
              path: ${{ env.TF_PLUGIN_CACHE_DIR }}
              # re-download only when a pinned provider version changes
              key: tf-plugins-${{ hashFiles('**/.terraform.lock.hcl') }}
              restore-keys: tf-plugins-
    
          - uses: hashicorp/setup-terraform@v3
    
          - run: terraform init
          - run: terraform plan

    First run seeds the cache. Every run after that restores it and skips the downloads entirely, until someone changes a version in the lock file. That's usually the single biggest, cheapest win available on a Terraform pipeline.

    The gotcha nobody warns you about

    Here's the one that generates confused Slack messages. When Terraform installs from the cache, it's installing from an already-unpacked directory, not the signed archive it would normally pull from the registry. That means it can only record the checksum for your current platform in .terraform.lock.hcl— it can't compute the registry hashes for the other platforms your team runs on.

    So a lock file generated with the cache on a Mac may not contain a hash that matches when a colleague on Linux — or your Linux CI runner — runs init, and their init fails a checksum check. The clean fix is to populate every platform's hash explicitly and commit the result:

    bash
    terraform providers lock \
      -platform=linux_amd64 \
      -platform=darwin_arm64 \
      -platform=darwin_amd64 \
      -platform=windows_amd64

    If you'd rather just silence the warning and accept the older, laxer behavior, set this in your CLI config:

    hcl
    # ~/.terraformrc
    plugin_cache_dir = "$HOME/terraform-cache"
    plugin_cache_may_break_dependency_lock_file = true

    A couple more things to know. The cache isn't safe to share across parallel inits. HashiCorp says so plainly. If your CI fans out ten plan jobs that all populate the same cold cache at once, they can race and leave a half-written binary behind. Warm the cache in one step first, or give each parallel job its own cache path.

    Terraform also never prunes it. The cache grows forever, one more copy every time you bump a provider, until you clean it out yourself:

    bash
    # how big has it gotten?
    du -sh "$HOME/terraform-cache"
    
    # drop provider binaries untouched for 90 days, then tidy the empty dirs
    find "$HOME/terraform-cache" -name 'terraform-provider-*' -mtime +90 -delete
    find "$HOME/terraform-cache" -type d -empty -delete

    And because the installs are symlinks, wiping that directory breaks every working directory that pointed into it. Re-run init to heal them.

    Prove it actually kicked in

    Don't take the setup on faith. You can see the symlinks. After an init that hit the cache, list the installed provider and look at where it points:

    bash
    ls -la .terraform/providers/registry.terraform.io/hashicorp/aws/5.30.0/darwin_arm64/

    On a cache hit the binary is a symlink whose target is your plugin-cache directory, not a real file sitting inside .terraform. That arrow is the whole point: the working directory is borrowing one shared copy. The cache itself mirrors the registry's own layout, keyed by source, namespace, provider, version, and platform:

    bash
    $HOME/.terraform.d/plugin-cache/
    └── registry.terraform.io/hashicorp/aws/5.30.0/darwin_arm64/
        └── terraform-provider-aws_v5.30.0_x5

    Cache or mirror — which one?

    The plugin cache is a convenience layer. It still talks to the origin registry to discover versions and to fetch anything it doesn't already have. If your goal is a fully offline or air-gapped setup, you want a provider mirror instead — an authoritative local copy that Terraform treats as the source of truth:

    bash
    # build a filesystem mirror of everything your configs use
    terraform providers mirror ./terraform-mirror

    Point Terraform at that mirror with a provider_installation block and it never reaches the public registry at all. Rule of thumb: reach for the cache to make a connected workflow faster, and a mirrorwhen you need to run with no registry access at all. They're not mutually exclusive — plenty of teams mirror in locked-down environments and cache everywhere else.

    Do it right now

    This is a two-minute change with no downside for the single-directory case and a large upside the moment you have more than a couple. Set it up, then run initin two directories back to back and watch the second one finish before you've finished reading the output of the first:

    bash
    mkdir -p "$HOME/terraform-cache"
    echo 'plugin_cache_dir = "$HOME/terraform-cache"' >> ~/.terraformrc
    
    cd infra/prod  && terraform init   # populates the cache
    cd ../stage    && terraform init   # cache hit — symlinks, no download

    Then wire the same variable into CI, key the cache on your lock file, and reclaim those minutes on every pipeline run for the rest of the project's life.

    Hardik Shah

    About Hardik Shah

    Hardik is a dedicated Cloud Architect specializing in AWS solutions and DevOps automation. With years of industry experience, he focuses on building scalable, resilient architectures and sharing technical insights to help teams optimize their cloud-native journeys.