Featured image of post Private Terraform Registry on Cloudflare Workers

Private Terraform Registry on Cloudflare Workers

Self-hosted Terraform registry without Terraform Cloud costs. Serverless on Cloudflare.

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 implementation

The 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:

  1. Develop and test workers locally
  2. Run the same code on Cloudflare’s edge
  3. Deploy on-prem for air-gapped environments
  4. 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

  1. “Free tier has no limits” — 10ms CPU time free, 30s paid. Complex auth on free tier may timeout.

  2. “Pyodide supports all Python packages” — No. ~150 bundled, no arbitrary pip install. Use WebCrypto via JavaScript instead.

  3. “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.