The Alternatives
When you need to share Terraform modules across projects:
| Option | Cost | Complexity | Trade-off |
|---|---|---|---|
| Terraform Cloud | $20+/month | Low | Free tier doesn’t support custom modules |
| Nexus/Artifactory | Free | High | Java-based, heavy for this use case |
| Custom (this) | Free | Medium | Implement registry protocol yourself |
The killer: Terraform Cloud’s free tier doesn’t support private module registries. You need the paid plan. For homelab use, that’s 10x the cost.
What this handles: service discovery, version listing, download URL redirects, and zipball proxy — the full registry protocol.
Worker Endpoints
| Endpoint | Purpose |
|---|---|
/.well-known/terraform.json |
Service discovery — tells Terraform where the API lives |
/v1/modules/:ns/:name/:provider/versions |
Lists available versions (from GitHub tags) |
/v1/modules/:ns/:name/:provider/:version/download |
Returns the download URL |
/archive/:ns/:name/:provider/:version |
Proxies the actual module download |
The Implementation
Let me walk through the actual code. The worker is written in Python using Pyodide, which means full CPython 3.12 in WebAssembly.
Service Discovery
async def handle_well_known(req, env):
"""Handle /.well-known/terraform.json"""
response = {
"modules.v1": f"{env.get('BASE_URL', 'https://terraform.example.com')}/v1/modules/"
}
return json_response(response)Simple enough — returns a JSON object telling Terraform where to find the modules API.
Version Listing
This is where things get interesting. I needed to fetch GitHub tags and filter for valid semver:
def filter_semver_tags(tags: list[str]) -> list[str]:
"""Filter tags to only valid semver versions."""
valid_tags = []
for tag in tags:
if _SEMVER_RE.match(tag):
valid_tags.append(tag)
return sorted(valid_tags, key=parse_version, reverse=True)
# Semver pattern from actual implementation
_SEMVER_RE = re.compile(r"^v?\d+\.\d+\.\d+(?:[.+-].+)?$")The regex ensures only proper semver tags (v1.0.0, 2.3.1, etc.) are returned. Prerelease versions like v1.0.0-beta are supported too.
Authentication: The GitHub App Dance
Here’s where it gets clever. Instead of requiring users to generate GitHub tokens, the worker handles authentication server-side using a GitHub App:
class GitHubAppAuth:
def __init__(self, app_id: str, private_key: str, installation_id: str):
self.app_id = app_id
self.private_key = private_key
self.installation_id = installation_id
self._token_cache = None
self._token_expiry = 0
async def get_token(self) -> str:
"""Get cached installation token, refreshing if needed."""
now = time.time()
if self._token_cache and now < self._token_expiry:
return self._token_cache
# Generate JWT and exchange for token
jwt = self._generate_jwt()
token = await self._exchange_jwt_for_token(jwt)
# Cache for 55 minutes (tokens last 60, safe margin)
self._token_cache = token
self._token_expiry = now + 3300
return token
def _generate_jwt(self) -> str:
"""Create signed JWT using RS256."""
# ... WebCrypto implementationThe worker generates a JWT signed with the GitHub App’s private key, exchanges it for an Installation Access Token, and caches that token for 55 minutes. This avoids rate limiting and keeps things snappy.
Design Considerations
Why Server-Side Auth?
The alternative would be API tokens in .terraformrc. But that means:
- Users need to generate tokens
- Tokens expire and need refreshing
- You’re exposing long-lived credentials
With server-side GitHub App auth, Terraform just talks to your worker — no credentials needed on the client side.
Token Caching Strategy
# Using Cloudflare's Cache API for cross-request caching
cache = caches.default
cache_key = f"gh-token-{installation_id}"
cached = await cache.get(cache_key)
if cached:
token = cached.text
else:
token = await github_app.get_token()
# Cache for 55 minutes
response = Response.new(token, headers={
"Cache-Control": "max-age=3300"
})
await cache.put(cache_key, response)This is critical because GitHub rate limits would hit hard without caching.
Download URL Handling
Terraform expects the download endpoint to return a X-Terraform-Get header with the location of the actual module archive. I point it back to my worker:
async def handle_module_download(req, env, ns, name, provider, version):
# Return redirect to archive endpoint
archive_url = f"/archive/{ns}/{name}/{provider}/{version}"
return Response.new("", headers={
"X-Terraform-Get": archive_url
})Then the archive endpoint fetches from GitHub’s zipball URL and streams it through.
Constraints and Limits
This isn’t a “run anything anywhere” solution. There are real constraints:
| Constraint | Value | Implication |
|---|---|---|
| CPU Time | 30s (Paid) / 10ms (Free) | Complex auth may timeout on free tier |
| Memory | 128 MB | Enough for Python + JSON parsing |
| Packages | Pyodide stdlib only | No external dependencies |
Terraform Cloud’s free tier doesn’t support private module registries. You need the paid plan.
On-Prem Deployment with workerd
Here’s the part that makes this interesting for self-hosted scenarios: these workers can run locally using workerd, Cloudflare’s Workers runtime as a container.
# docker-compose.yml for local workerd
services:
workerd:
image: cloudflare/workerd:latest
ports:
- "8787:8787"
volumes:
- ./config.workerd:/etc/workerd/config.capnp
environment:
- PORT=8787// config.workerd (JavaScript service binding syntax)
services: [
{
name: "terraform-registry",
script: readFile("dist/worker.js"),
bindings: {
GITHUB_APP_ID: "123456",
GITHUB_INSTALLATION_ID: "789012",
}
}
]This means you can:
- Develop and test workers locally
- Run the same code on Cloudflare’s edge
- Deploy on-prem for air-gapped environments
- Use identical infrastructure everywhere
Usage Example
With everything configured, using the registry is straightforward:
# main.tf
module "proxmox_vm" {
source = "registry.example.com/namespace/tf-module-proxmox-vm/proxmox"
version = "1.0.7"
configuration = {
name = "web-server"
node_name = "pve1"
cpu = { cores = 2, type = "host" }
memory = { dedicated = 4096 }
}
}And the CLI configuration:
# ~/.terraformrc
host "registry.example.com" {
services = {
"modules.v1" = "https://registry.example.com/v1/modules/"
}
}No credentials needed — the worker handles everything server-side.
What Most People Get Wrong
-
“Free tier has no limits” — 10ms CPU time free, 30s paid. Complex auth on free tier may timeout.
-
“Pyodide supports all Python packages” — No. ~150 bundled, no arbitrary pip install. Use WebCrypto via JavaScript instead.
-
“One worker does everything” — For high traffic, add caching. GitHub API rate limits (5K/hour) apply.
When to Use / When NOT to Use
| Use Private Registry | Use Terraform Cloud |
|---|---|
| Free tier budget | Team with >5 users |
| 10-50 modules | 100+ modules |
| Simple requirements | Policy enforcement needed |
| Air-gapped capable | Cloud-hosted required |
What’s Next
This pattern — serverless registry with on-prem fallback — has proven so useful I’ve applied it to APT repositories, which I’ll cover in the next post. The same architecture, different protocol, same benefits.
This pattern can be adapted to your own infrastructure by implementing the Terraform Module Registry Protocol on any serverless or traditional hosting platform.