DEV Community

Cover image for No Autocomplete for Private Terraform Modules, and Nobody Seems to Care
Davide de Paolis
Davide de Paolis

Posted on

No Autocomplete for Private Terraform Modules, and Nobody Seems to Care

TL;DR: Do your engineers get IDE autocomplete for private Terraform modules? Ours don't. After a morning of investigation, I'm fairly convinced nobody does. terraform-ls only queries registry.terraform.io. If you've solved this differently, I genuinely want to know. Read below for more details, or jump straight to the conclusion and share your thoughts/experience


After our platform team merged with the platform team from an acquired organisation, we realised pretty quickly that we managed Terraform modules in completely different ways. Their approach: a monorepo with relative paths, everything deployed together.
Our approach: versioned modules consumed via git sources, released independently, adopted at each team's own pace.

# Their way: relative path, coupled to monorepo HEAD
module "sqs" {
  source = "../../modules/sqs"
}

# Our way: git source, pinned to a release tag
module "sqs" {
  source = "git::ssh://git@github.com/org/terraform-modules.git//sqs?ref=sqs/v1.3.0"
}
Enter fullscreen mode Exit fullscreen mode

After some internal discussions, an RFC, and some concerns raised about governance drift (teams not really keeping modules up to date) and about DevEx (autocomplete, need to terraform init each monorepo folder, etc.), we decided to adopt versioning - but validate these concerns with a PoC.

The PoC went well. We tested techpivot/terraform-module-releaser, a GitHub Action that's newer and much simpler than the scripts we'd hacked together over two years. Releasing, git-tagging, versioning: all smooth, all elegant. The migration path was clear.

Module release

But the concerns about developer experience were still there. Specifically: "When I use the module, I have to run terraform init all the time to be able to look into the module's code (or directly go to the module library repo), and the autocomplete feature is missing."

At first, I dismissed them. terraform init is something you should know how to do if you're a DevOps engineer, or a software engineer dealing with your own infrastructure. It's a basic operation. Seriously, not a big deal. Like running npm install in any Node/TS project using external libraries.

But since as platform engineers, our responsibility is to be enablers - not just introduce changes that solve governance at the cost of usability - I was curious. What is the pain they're bringing up, and why are my cloud platform engineers not understanding it?

As an EM, I wasn't part of the PoC hands-on work; only the conversations. But after the team flagged the DX concerns, I opened one of our consumer repos, added a versioned module source, and started typing inside the module block.

Nothing. No autocomplete. No hover docs. No "what arguments does this thing accept?"

For context: all of this is powered by terraform-ls, HashiCorp's language server for Terraform. The HashiCorp Terraform VS Code extension uses it under the hood to provide autocomplete, go-to-definition, and hover docs.

Why is that? We use external modules all the time, and they work absolutely fine. Exactly like modules written in the same repository. I checked one for reference:

# This gets full autocomplete:
module "lambda" {
  source  = "terraform-aws-modules/lambda/aws"
  version = "7.20.0"
}

# This gets nothing:
module "sqs" {
  source = "git::ssh://git@github.com/org/terraform-modules.git?ref=sqs/v1.3.0"
  queue_name = "account-import"
}
Enter fullscreen mode Exit fullscreen mode

The public module: full autocomplete, hover docs, type information, defaults. Our private module: nothing.

Now I had understood the concern. And I couldn't let it go.

Down the rabbit hole (in one morning)

I had a Friday morning with fewer meetings than usual. I opened Kiro and started exploring the problem. I didn't want to give up the quarterly objective (versionise our modules, allow gradual rollout by team and environment), but I also didn't want to take DevEx away from our consumers.

I thought it must have been a problem of the PoC, of the way maybe the new action was bundling the code of the module. But no. Code was bundled fine, and in fact, running terraform init indeed allowed you to see the code inside the module, but still no autocomplete or hover docs.

So I then assumed the reason was a new feature from Terraform 1.15 that we'd just tried out in the PoC: dynamic sources and the const variables.les.

You can now write:

variable "sqs_module_version" {
  const = true
  default = "1.3.0"
}

module "queue" {
  source = "git::ssh://...?ref=sqs/v${var.sqs_module_version}"
}
Enter fullscreen mode Exit fullscreen mode

But as I said, terraform init and plan work perfectly. terraform-ls simply can't resolve dynamic expressions in source; it's a static analysis tool. Zero autocomplete. HashiCorp shipped the language feature in May 2026 without updating the language server to support it.

To test if dynamic sourcing was really the cause, I replaced it with a static string.

What really threw me off at the beginning, and misled my research for a while, was that static source = "git::ssh://..." with terraform init -backend=false does in fact download modules locally. Once they were on disk, terraform-ls was able to read them (and provide code navigation directly)... sometimes. I thought it was a problem of having multiple folders with multiple different module versions and I filed an issue for that (hashicorp/terraform-ls#2145), but I discovered later that that was not the case.

I went to an old project of ours, a feature team repository using one of our old versioned modules. Even there: no autocomplete. Nothing.
And I was really disappointed.

This wasn't new. In the new organisation, the lack of autocomplete was exactly why they'd moved away from a module library and resorted to a monorepo with local paths. That was the concern they'd raised, and now we were about to take that DX away again. I'd taken it for granted. And now I discovered that in our old project, it had always been broken too.

Engineers have been working by opening the module's variables.tf on GitHub in a separate tab, copying and pasting argument names, and running terraform validate as their only feedback loop.
For years. Nobody raised it in my previous team. It pissed me off and made me even more committed to finding a solution.

Private registry

This is where I got excited. The hypothesis: terraform-ls uses the registry protocol to discover modules and fetch their metadata. If we host a private registry that implements the same protocol, modules sourced as source = "registry.example.com/org/module/provider" should work without reference to git tags or anything, and should get autocomplete!

I set up Terralist locally with HTTPS, uploaded our modules, and it was great to see the custom page with the module (which we could share with teams as a kind of service catalogue, like the official Terraform Registry). More info at terralist.io.

Hitting the endpoints of the protocol on the terralist local server, I was getting the right info about our private module.

$ curl -sk https://localhost.direct:5758/v1/modules/org/sqs/aws/versions
{"modules":[{"versions":[{"version":"1.3.0"}]}]}
Enter fullscreen mode Exit fullscreen mode

Then I confirmed terraform init worked with the new source ( and no reference to our private repo).
Still, no autocomplete. terraform init was no longer necessary (terraform-ls retrieves information from registries directly), but the module source wasn't even clickable in the editor. The public registry module in the same file still worked perfectly.

Not much of an improvement compared to our GitHub tag-based releases, and more hassle because we'd need to deploy Terralist on Fargate or elsewhere (although I was already dreaming of a use case for the brand new Lambda MicroVMs).

The detective work

I started - well, I asked Kiro to do that for me - comparing what the public registry serves versus what Terralist serves.

The official registry protocol defines three endpoints:

  1. Service discovery (/.well-known/terraform.json)
  2. List versions (/v1/modules/{ns}/{name}/{provider}/versions)
  3. Download source (/v1/modules/{ns}/{name}/{provider}/{version}/download)

Terralist implements all three. But when I watched the network traffic from my editor, terraform-ls was calling a fourth, undocumented endpoint:

$ curl https://registry.terraform.io/v1/modules/terraform-aws-modules/lambda/aws | jq '.root.inputs[:2]'
[
  {"name": "function_name", "type": "string", "description": "...", "required": true},
  {"name": "handler", "type": "string", "description": "...", "required": false}
]
Enter fullscreen mode Exit fullscreen mode

A metadata endpoint. GET /v1/modules/{ns}/{name}/{provider} returns parsed inputs, outputs, types, descriptions, and defaults. Apparently, THAT is what allows terraform-ls to show the autocomplete features! But it's not in the specs, and after looking for similar tools to Terralist, I could not find any supporting that.

Kiro suggested creating an issue on Terralist's GitHub proposing the implementation, and I approved it. In a second, the issue was created: terralist/terralist#881.

But since I was rolling with vibecoding between meetings, why not implement it myself and prove that would be the solution?

Forking Terralist and implementing the metadata endpoint

Terralist is written in Go. I'm not a Go developer. I've managed a Go team for a few months, years ago and can read the code, but forking and adding a feature just to prove a point would normally be too time-consuming. But the problem was well-scoped: one HTTP endpoint that parses a module archive and returns structured metadata.
AI is definitely able to do that.

Kiro helped me set up the Go dev environment, then I forked Terralist and got it to work. The goal: make GET /v1/modules/{ns}/{name}/{provider} return the same JSON structure that registry.terraform.io serves:

{
  "id": "terraform-aws-modules/lambda/aws/7.20.0",
  "owner": "terraform-aws-modules",
  "namespace": "terraform-aws-modules",
  "name": "lambda",
  "version": "7.20.0",
  "root": {
    "inputs": [
      {"name": "function_name", "type": "string", "description": "A unique name for your Lambda Function", "default": "\"\"", "required": true},
      {"name": "handler", "type": "string", "description": "Lambda Function entrypoint", "default": "\"index.handler\"", "required": false}
    ],
    "outputs": [
      {"name": "lambda_function_arn", "description": "The ARN of the Lambda Function"}
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

The implementation touches 4 files.

pkg/docs/schema.go - Parses the uploaded module archive into structured inputs/outputs. Uses terraform-config-inspect, the same library Terralist already uses internally for generating docs.

internal/server/controllers/module.go - The new route, sitting alongside the existing version listing and download endpoints:

// GET /v1/modules/:namespace/:name/:provider
// Returns module metadata (inputs, outputs) for the latest version.
// This matches the undocumented endpoint that terraform-ls uses
// on registry.terraform.io for autocomplete.
Enter fullscreen mode Exit fullscreen mode

internal/server/services/module.go - Business logic: find the latest version, resolve the stored archive, and parse it.

At this stage, I didn't care about code quality or performance; I just wanted to validate whether the endpoint would enable autocomplete. Still, there was some debugging needed.

The trickiest bit was archive handling. go-getter (which Terralist uses to decompress module archives) creates a nested directory structure that doesn't always match what terraform-config-inspect expects. With Kiro, solved within minutes; navigating go-getter internals alone would have been a multi-hour detour.

Everything, at least by curling the endpoint, looked fine:

$ curl -sk https://localhost.direct:5758/v1/modules/org/sqs/aws | jq '.root.inputs[:3]'
[
  {"name": "queue_name", "type": "string", "description": "Name of the SQS queue", "default": "", "required": true},
  {"name": "visibility_timeout_seconds", "type": "number", "description": "The visibility timeout for the queue", "default": "30", "required": false},
  {"name": "message_retention_seconds", "type": "number", "description": "Seconds a message is retained", "default": "345600", "required": false}
]
Enter fullscreen mode Exit fullscreen mode

All 14 inputs with types, descriptions, and defaults. Exactly matching the public registry format.

I was genuinely excited. This should be the missing piece. We could proceed with our quarterly goal: improve governance and security without taking DX away. And this could be a great contribution for Terralist and a solution for other teams, too.

Unfortunately, when I tested autocomplete again, nothing happened. Absolutely nothing changed.

This is one of the first times I got mad at Kiro.

Because when we started debugging why terraform-ls was not loading metadata despite the new endpoint, Kiro innocently admitted that:

The registry client is hardcoded to registry.terraform.io. terraform-ls never reads the hostname from the module source. It never performs service discovery on private registries. It never queries anything other than the public registry.

// internal/registry/registry.go
const defaultBaseURL = "https://registry.terraform.io"

func NewClient() Client {
    return Client{
        BaseURL: defaultBaseURL,
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

you could have told me before

We spent time finding a private registry, installing it, running it, forking it, and implementing a missing endpoint that works but is never called.

Thanks to AI, all of that probably took less than an hour. But it was completely unnecessary. The agent could have read the terraform-ls source first and avoided the entire detour. Instead, Kiro put me on the wrong path, and I excitedly ran down it. Disappointing, and a bit embarrassing.

The uncomfortable conclusion

Source type Autocomplete?
Public registry (terraform-aws-modules/vpc/aws) Yes
Local path (../modules/sqs) Yes
Git source (git::ssh://...) No
Private registry (Terralist, Artifactory) No

Every team using private Terraform modules, regardless of tooling, budget, or registry choice, works without IDE autocomplete.

This isn't a configuration problem. It's not a documentation gap you can work around. It's a design decision baked into terraform-ls: only registry.terraform.io gets the full IDE experience. Everyone else reads from the source code (in the module library GitHub repo, or from the module downloaded by terraform init into the .terraform folder).

Not even a paid solution like HCP Terraform changes this. The IDE gap lives in terraform-ls, and it's been there since 2022.

I opened an issue: hashicorp/terraform-ls#2146. Let's see.

The Terralist fork: ready and waiting

The fork is functional. I'm planning to open a PR, but until terraform-ls allows reading from a different endpoint, it's useless anyway.

For reference, the metadata endpoint format is simple. Here's what terraform-ls expects:

{
  "id": "{namespace}/{name}/{provider}/{version}",
  "owner": "{namespace}",
  "namespace": "{namespace}",
  "name": "{name}",
  "provider": "{provider}",
  "version": "{latest_version}",
  "root": {
    "inputs": [
      {
        "name": "variable_name",
        "type": "string",
        "description": "What this variable does",
        "default": "\"default_value\"",
        "required": false
      }
    ],
    "outputs": [
      {
        "name": "output_name",
        "description": "What this output returns"
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

It's essentially the output of terraform-config-inspect (or any HCL parser that reads variables.tf and outputs.tf) serialised as JSON. If you already have modules uploaded to your registry, you already have this data. You just need to serve it.

Honestly, I believe the easiest first move is on the terraform-ls side. The change is localised: instead of hardcoding registry.terraform.io, read the hostname from the module source and perform service discovery. The metadata endpoint on the registry side is useful (and will have to be added to the protocol) only once terraform-ls can query something other than the public registry.

A reflection on platform engineering

What drives me crazy isn't the bug itself. It's that nobody seems to bother.

I come from software engineering before landing in cloud/platform. From that angle, shipping a module library that technically works but strips away basic IDE support is not enabling anyone. It's adding friction where less was supposed to happen. And that's where many platform teams fail: little or no adoption, because we validated the functionality but never validated the experience.

Are we validating the developer experience of what we ship, or just the functionality?

Platform teams have no mandate. Teams should want to use what we build. A module library that forces engineers to break flow, context-switch to GitHub, and manually copy-paste argument names is not something anyone would choose if they had an alternative. The monorepo with local paths was the alternative, and now we understand why.

I don't know how we'll continue with this goal. For me, it's a no-go to take DX away. But my team is still wondering why this is such a problem, and every colleague I've asked, people working with Terraform for ages, has accepted that this is just how the workflow is.

Is it?

I might be stubborn or spoilt, but autocomplete for infrastructure code is something I take for granted. Maybe because I used AWS CDK before moving to Terraform, and this entire category of issue simply doesn't exist there.

In CDK, constructs are classes.

  • L1 constructs map 1:1 to CloudFormation resources (like a Terraform resource).
  • L2 constructs add sensible defaults and convenience methods (like a well-written Terraform module).
  • L3 constructs, called patterns, compose multiple resources into opinionated architectures (like a module that provisions an entire service).

The parallel to Terraform modules is direct, but the DX is worlds apart.

Because constructs are classes in a real programming language (TypeScript, Go, Python), you get inheritance, composition, and a type system that ensures everything fits together. You extend a base construct, override what you need, npm install your org's package, and the IDE gives you full autocomplete instantly. That's probably why I was taking it so much for granted, and why I initially dismissed the concern. Going without autocomplete feels like being asked to use Notepad.

I miss AWS CDK's elegance of building infrastructure with real composition and inheritance. HCL's simplicity is a strength, especially for large stateful infrastructure repeated across environments with different configurations. But this DX gap is real, and pretending it doesn't matter doesn't serve our users.

For now I'm pausing. I'll see if the issues I opened on Terralist and terraform-ls get any traction.

No matter what, it was a great learning experience: we might accept the limitation, but we came a long way in understanding our customers' needs and thinking of the platform more as a product. And personally, it was a good morning. Kiro gave me the speed to explore the problem deeply in fragmented time, speeding up grunt work (debugging, installation, implementation) so I could focus on the actual problem and its shape. Don't delegate the thinking, but amplify the outcome.


Genuinely asking: how do you do this?

I asked around. Colleagues, community Slack channels. Nobody seems to have solved this. And more surprisingly, most people don't seem to care.

So I'm asking publicly:

  • If you maintain private Terraform modules for your org: do your consumers get autocomplete? How?
  • If you've worked around this: what's the trick?
  • If you just live with it: Is it really fine? Does your team not lose time on this?

Because right now it looks like the entire Terraform ecosystem outside of registry.terraform.io operates without basic IDE support. And either everyone has accepted that, or nobody has noticed.

I'd love to be wrong about this. Tell me I'm wrong.

Have you solved this? Drop a comment. I'll update this post with any solutions that surface.


Resources

Top comments (0)