<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Cloudflare on zharif.my</title>
        <link>https://zharif.my/tags/cloudflare/</link>
        <description>Recent content in Cloudflare on zharif.my</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>en-us</language>
        <lastBuildDate>Thu, 05 Mar 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://zharif.my/tags/cloudflare/index.xml" rel="self" type="application/rss+xml" /><item>
        <title>Private APT Repository on Cloudflare Workers</title>
        <link>https://zharif.my/posts/apt-repository-cloudflare-workers/</link>
        <pubDate>Thu, 05 Mar 2026 00:00:00 +0000</pubDate>
        
        <guid>https://zharif.my/posts/apt-repository-cloudflare-workers/</guid>
        <description>&lt;img src="https://images.unsplash.com/photo-1564915728039-df87f43461c0?w=800&amp;h=400&amp;fit=crop" alt="Featured image of post Private APT Repository on Cloudflare Workers" /&gt;&lt;h2 id=&#34;the-problem&#34;&gt;The Problem
&lt;/h2&gt;&lt;p&gt;Every homelab needs package caching. Every production environment needs custom packages. Yet most of us update from public mirrors with no offline capability.&lt;/p&gt;
&lt;p&gt;The real pain points:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Latency&lt;/strong&gt;: 50MB download for every new node&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Air-gapped&lt;/strong&gt;: No internet = no packages&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Custom tooling&lt;/strong&gt;: Internal .debs need distribution&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;My setup solves all three: packages live in GitHub Releases (versioned storage), metadata builds in CI (on release), and the worker serves both.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Scale&lt;/strong&gt;: ~10 machines pulling packages. 500 requests/day before caching kicks in.&lt;/p&gt;
&lt;h2 id=&#34;architecture&#34;&gt;Architecture
&lt;/h2&gt;&lt;p&gt;If you&amp;rsquo;re running Debian or Ubuntu systems — whether in production, at home, or across a fleet of machines — you&amp;rsquo;ve probably felt the pain of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Waiting for packages to download from public mirrors&lt;/li&gt;
&lt;li&gt;Needing to patch vulnerable packages urgently across all machines&lt;/li&gt;
&lt;li&gt;Wanting to distribute custom-built packages to your infrastructure&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Public mirrors are great, but sometimes you need your own. Maybe it&amp;rsquo;s:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Custom-built packages for internal tooling&lt;/li&gt;
&lt;li&gt;Pinned versions for stability&lt;/li&gt;
&lt;li&gt;Air-gapped environments that can&amp;rsquo;t reach the internet&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I faced this when building my homelab. I wanted to distribute custom packages to all my Proxmox nodes and GitHub Actions runners without exposing them to the public internet.&lt;/p&gt;
&lt;h2 id=&#34;the-architecture&#34;&gt;The Architecture
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the high-level picture:&lt;/p&gt;
&lt;pre class=&#34;mermaid&#34;&gt;
  flowchart TB
    subgraph Producers
        Dev[Developer]
        Release[GitHub Release]
    end

    subgraph CI
        Workflow[rebuild-index]
        Branch[apt-metadata branch]
    end

    subgraph Runtime
        Auth[Auth Layer]
        Meta[Metadata API]
        Pkg[Package API]
    end

    subgraph Consumers
        Nodes[Proxmox Nodes]
        Actions[GitHub Actions]
        Apt[apt client]
    end

    Dev --&amp;gt; Release
    Release --&amp;gt; Workflow
    Workflow --&amp;gt; Branch
    Branch --&amp;gt; Meta
    Release --&amp;gt; Pkg
    Nodes --&amp;gt; Auth
    Actions --&amp;gt; Auth
    Auth --&amp;gt; Meta
    Auth --&amp;gt; Pkg
&lt;/pre&gt;

&lt;p&gt;The key insight: &lt;strong&gt;static metadata in Git, dynamic packages from releases&lt;/strong&gt;.&lt;/p&gt;
&lt;h2 id=&#34;how-it-works-the-full-story&#34;&gt;How It Works: The Full Story
&lt;/h2&gt;&lt;h3 id=&#34;1-package-publishing-manual--automated&#34;&gt;1. Package Publishing (Manual + Automated)
&lt;/h3&gt;&lt;p&gt;When you release a &lt;code&gt;.deb&lt;/code&gt; package, it goes to GitHub Releases:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;# Upload your package
gh release upload v1.2.3 my-package_1.2.3_amd64.deb&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;But APT needs more than just the &lt;code&gt;.deb&lt;/code&gt; file — it needs metadata. That&amp;rsquo;s where the rebuild workflow comes in.&lt;/p&gt;
&lt;h3 id=&#34;2-metadata-generation-the-ci-pipeline&#34;&gt;2. Metadata Generation (The CI Pipeline)
&lt;/h3&gt;&lt;p&gt;A GitHub Actions workflow (&lt;code&gt;rebuild-index&lt;/code&gt;) listens for releases and generates the metadata:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-python&#34;&gt;# scripts/build_index.py (from actual implementation)
async def build_metadata(releases: list[Release]):
    &amp;#34;&amp;#34;&amp;#34;Build complete APT metadata for all releases.&amp;#34;&amp;#34;&amp;#34;
    
    packages = []
    for release in releases:
        for asset in release.assets:
            if asset.name.endswith(&amp;#39;.deb&amp;#39;):
                pkg = parse_deb_control(asset)
                packages.append(pkg)
    
    # Generate Packages.gz (compressed package index)
    packages_content = &amp;#34;\n\n&amp;#34;.join(p.as_apt_control() for p in packages)
    packages_gz = gzip.compress(packages_content.encode())
    
    # Generate InRelease (inline release file)
    inrelease = generate_inrelease(packages_gz, len(packages))
    
    # Sign with GPG
    gpg_signature = gpg_sign(inrelease)
    
    return {
        &amp;#34;Packages.gz&amp;#34;: packages_gz,
        &amp;#34;InRelease&amp;#34;: inrelease,
        &amp;#34;Release.gpg&amp;#34;: gpg_signature
    }&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This runs in CI and pushes the generated metadata to a dedicated &lt;code&gt;apt-metadata&lt;/code&gt; branch. The branch is never checked out locally — it&amp;rsquo;s just the persistence layer for the index.&lt;/p&gt;
&lt;h3 id=&#34;3-runtime-the-cloudflare-worker&#34;&gt;3. Runtime: The Cloudflare Worker
&lt;/h3&gt;&lt;p&gt;The worker handles three types of requests:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-python&#34;&gt;# src/entry.py - Main request router
async def on_fetch(request, env):
    path = urlparse(request.url).path
    
    if path == &amp;#34;/public.key&amp;#34;:
        return serve_public_key(env)
    
    if path.startswith(&amp;#34;/dists/&amp;#34;):
        return await serve_metadata(request, path, env)
    
    if path.startswith(&amp;#34;/pool/&amp;#34;):
        return await serve_package(request, path, env)
    
    return Response.new(&amp;#34;Not Found&amp;#34;, status=404)&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&#34;authentication-oidc--basic-auth&#34;&gt;Authentication: OIDC + Basic Auth
&lt;/h2&gt;&lt;p&gt;This is where the design got interesting. I needed two authentication modes:&lt;/p&gt;
&lt;h3 id=&#34;github-actions-oidc-preferred&#34;&gt;GitHub Actions OIDC (Preferred)
&lt;/h3&gt;&lt;p&gt;For GitHub Actions runners and other automated systems:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-python&#34;&gt;# src/auth.py
async def validate_oidc_token(token: str, env) -&amp;gt; bool:
    &amp;#34;&amp;#34;&amp;#34;Validate GitHub Actions OIDC token.&amp;#34;&amp;#34;&amp;#34;
    
    # Fetch JWKS from GitHub
    jwks = await fetch_jwks(&amp;#34;https://token.actions.githubusercontent.com/.well-known/jwks&amp;#34;)
    
    # Verify signature and claims
    claims = jwt.decode(token, jwks, algorithms=[&amp;#34;RS256&amp;#34;], audience=env.OIDC_AUDIENCE)
    
    # Check repository and organization claims
    repo = claims.get(&amp;#34;repository&amp;#34;, &amp;#34;&amp;#34;)
    org = claims.get(&amp;#34;organization&amp;#34;, &amp;#34;&amp;#34;)
    
    return org in env.ALLOWED_ORGS and repo in env.ALLOWED_REPOS&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The trick: put the OIDC token in the &lt;strong&gt;password&lt;/strong&gt; field of Basic Auth:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;# /etc/apt/auth.conf (on runners)
machine apt.example.com
login github-action
password: &amp;lt;oidc_token&amp;gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This works because &lt;code&gt;apt&lt;/code&gt; sends Basic Auth headers, and the worker detects &lt;code&gt;login == &amp;quot;github-action&amp;quot;&lt;/code&gt; to trigger OIDC validation instead of regular Basic Auth.&lt;/p&gt;
&lt;h3 id=&#34;basic-auth-fallback&#34;&gt;Basic Auth (Fallback)
&lt;/h3&gt;&lt;p&gt;For developer machines that can&amp;rsquo;t use OIDC:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-python&#34;&gt;async def validate_basic_auth(auth_header: str, env) -&amp;gt; bool:
    &amp;#34;&amp;#34;&amp;#34;Validate username/password from Authorization header.&amp;#34;&amp;#34;&amp;#34;
    credentials = parse_basic_auth(auth_header)
    return (credentials.username == env.APT_USER and 
            credentials.password == env.APT_PASS)&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&#34;the-package-serving-logic&#34;&gt;The Package Serving Logic
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s how the worker fetches packages from GitHub:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-python&#34;&gt;# src/packages.py
async def serve_package(path: str, env, github_token: str):
    &amp;#34;&amp;#34;&amp;#34;Serve .deb file from GitHub Releases.&amp;#34;&amp;#34;&amp;#34;
    
    # Parse: /pool/main/a/awesome_1.0.0_amd64.deb
    # Extract: owner, repo, version, filename
    
    # Fetch from GitHub Release Assets
    release_url = f&amp;#34;https://api.github.com/repos/{owner}/{repo}/releases/tags/v{version}&amp;#34;
    release = await github_fetch(release_url, token=github_token)
    
    # Find the matching asset
    asset = next(a for a in release.assets if a.name == filename)
    
    # Stream directly to client
    return Response.new(asset.body, headers={
        &amp;#34;Content-Type&amp;#34;: &amp;#34;application/x-debian-package&amp;#34;,
        &amp;#34;Content-Length&amp;#34;: str(asset.size)
    })&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The worker acts as a proxy — it doesn&amp;rsquo;t store packages, just streams them from GitHub.&lt;/p&gt;
&lt;h2 id=&#34;design-considerations&#34;&gt;Design Considerations
&lt;/h2&gt;&lt;h3 id=&#34;why-git-branch-for-metadata&#34;&gt;Why Git Branch for Metadata?
&lt;/h3&gt;&lt;p&gt;Using a Git branch (&lt;code&gt;apt-metadata&lt;/code&gt;) for metadata storage means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Versioning&lt;/strong&gt; — every index update is a commit&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Audit trail&lt;/strong&gt; — who changed what, when&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No external storage&lt;/strong&gt; — no database, no R2, just Git&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Easy rollback&lt;/strong&gt; — &lt;code&gt;git revert&lt;/code&gt; to go back&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;why-not-store-packages-in-git&#34;&gt;Why Not Store Packages in Git?
&lt;/h3&gt;&lt;p&gt;Git LFS could work, but:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Release assets are already in GitHub Releases&lt;/li&gt;
&lt;li&gt;Streaming from Releases avoids git clone overhead&lt;/li&gt;
&lt;li&gt;Separates metadata from binary storage&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;metadata-freshness&#34;&gt;Metadata Freshness
&lt;/h3&gt;&lt;p&gt;The metadata is generated &lt;strong&gt;on release&lt;/strong&gt;. There&amp;rsquo;s no on-the-fly generation:&lt;/p&gt;
&lt;pre class=&#34;mermaid&#34;&gt;
  flowchart LR
    Dev[Developer releases v1.2.3] --&amp;gt; CI[CI runs]
    CI --&amp;gt; Branch[Metadata pushed to apt-metadata branch]
    Branch --&amp;gt; Worker[Worker fetches from apt-metadata branch]
    Apt[apt client requests] --&amp;gt; Worker
&lt;/pre&gt;

&lt;p&gt;This trades freshness for simplicity. If you need real-time, you&amp;rsquo;d need a different architecture.&lt;/p&gt;
&lt;h2 id=&#34;security-model&#34;&gt;Security Model
&lt;/h2&gt;&lt;pre class=&#34;mermaid&#34;&gt;
  sequenceDiagram
    participant APT as apt client
    participant Worker as Cloudflare Worker
    participant GitHub as GitHub API
    
    APT-&amp;gt;&amp;gt;Worker: GET /public.key
    Worker--&amp;gt;&amp;gt;APT: GPG public key
    
    APT-&amp;gt;&amp;gt;Worker: GET /dists/stable/Release (Basic Auth)
    alt GitHub Actions OIDC
        Worker-&amp;gt;&amp;gt;Worker: Validate OIDC token
        Worker-&amp;gt;&amp;gt;GitHub: Fetch metadata from apt-metadata
        Worker--&amp;gt;&amp;gt;APT: APT metadata
    else Basic Auth
        Worker-&amp;gt;&amp;gt;Worker: Validate username/password
        Worker-&amp;gt;&amp;gt;GitHub: Fetch metadata from apt-metadata
        Worker--&amp;gt;&amp;gt;APT: APT metadata
    end
    
    APT-&amp;gt;&amp;gt;Worker: GET /pool/main/a/awesome_1.2.3.deb (Auth)
    Worker-&amp;gt;&amp;gt;GitHub: Stream .deb from Release
    Worker--&amp;gt;&amp;gt;APT: .deb package
&lt;/pre&gt;

&lt;p&gt;The client must authenticate for both metadata and package downloads. The GPG key is public and unauthenticated.&lt;/p&gt;
&lt;h2 id=&#34;constraints&#34;&gt;Constraints
&lt;/h2&gt;&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;Aspect&lt;/th&gt;
          &lt;th&gt;Limit&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;CPU Time&lt;/td&gt;
          &lt;td&gt;30s per request&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Memory&lt;/td&gt;
          &lt;td&gt;128 MB&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Bandwidth&lt;/td&gt;
          &lt;td&gt;Edge network to GitHub&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Auth Rate&lt;/td&gt;
          &lt;td&gt;GitHub API limits apply&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;For high-volume scenarios, add caching headers. The worker already caches GitHub tokens — package caching could be added similarly.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&#34;on-prem-deployment&#34;&gt;On-Prem Deployment
&lt;/h2&gt;&lt;p&gt;Just like the Terraform registry, this runs on workerd:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;# docker-compose.yml
services:
  apt-worker:
    image: cloudflare/workerd:latest
    ports:
      - &amp;#34;8787:8787&amp;#34;
    volumes:
      - ./config.workerd:/etc/workerd/config.capnp:ro
    environment:
      - APT_USER=admin
      - APT_PASS_FILE=/run/secrets/apt_password&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-toml&#34;&gt;# wrangler.toml for on-prem
name = &amp;#34;apt-repository&amp;#34;
main = &amp;#34;src/entry.py&amp;#34;

[vars]
GITHUB_OWNER = &amp;#34;your-org&amp;#34;
ALLOWED_ORGS = [&amp;#34;your-org&amp;#34;]

[secrets]
APT_USER = &amp;#34;admin&amp;#34;
# APT_PASS via secret put&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&#34;usage&#34;&gt;Usage
&lt;/h2&gt;&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;# Add the repository
echo &amp;#34;deb [trusted=yes] https://apt.example.com stable main&amp;#34; | \
    sudo tee /etc/apt/sources.list.d/your-repo.list

# Add GPG key (unauthenticated)
curl -fsSL https://apt.example.com/public.key | \
    sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/your-repo.gpg

# Update and install
sudo apt update
sudo apt install my-internal-tool&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The &lt;code&gt;[trusted=yes]&lt;/code&gt; is needed because we&amp;rsquo;re self-signing. In production, you&amp;rsquo;d want proper GPG setup without the &lt;code&gt;trusted&lt;/code&gt; flag.&lt;/p&gt;
&lt;h2 id=&#34;what-most-people-get-wrong&#34;&gt;What Most People Get Wrong
&lt;/h2&gt;&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&amp;ldquo;Metadata refreshes on every request&amp;rdquo;&lt;/strong&gt; — No. Metadata is generated on release, stored in Git. Fresh on release, stale until next release.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&amp;ldquo;Packages live in the worker&amp;rdquo;&lt;/strong&gt; — They&amp;rsquo;re in GitHub Releases. The worker proxies them. No storage cost at the edge.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&amp;ldquo;OIDC is more complex than API tokens&amp;rdquo;&lt;/strong&gt; — For CI systems, OIDC tokens are ephemeral and rotate automatically. Fewer secrets to manage.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&#34;when-to-use--when-not-to-use&#34;&gt;When to Use / When NOT to Use
&lt;/h2&gt;&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;Use Private APT&lt;/th&gt;
          &lt;th&gt;Use Public Mirror&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;Air-gapped environments&lt;/td&gt;
          &lt;td&gt;Internet-connected systems&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Custom packages&lt;/td&gt;
          &lt;td&gt;Standard OS packages&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Version pinning required&lt;/td&gt;
          &lt;td&gt;Rolling releases OK&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id=&#34;whats-next&#34;&gt;What&amp;rsquo;s Next
&lt;/h2&gt;&lt;p&gt;Both the Terraform registry and APT repository share the same architectural DNA:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Serverless on Cloudflare&lt;/li&gt;
&lt;li&gt;Git-backed persistence&lt;/li&gt;
&lt;li&gt;Optional on-prem via workerd&lt;/li&gt;
&lt;li&gt;No external databases or storage&lt;/li&gt;
&lt;/ul&gt;
</description>
        </item>
        <item>
        <title>Private Terraform Registry on Cloudflare Workers</title>
        <link>https://zharif.my/posts/terraform-registry-cloudflare-workers/</link>
        <pubDate>Fri, 20 Feb 2026 00:00:00 +0000</pubDate>
        
        <guid>https://zharif.my/posts/terraform-registry-cloudflare-workers/</guid>
        <description>&lt;img src="https://images.unsplash.com/photo-1592659762303-90081d34b277?w=800&amp;h=400&amp;fit=crop" alt="Featured image of post Private Terraform Registry on Cloudflare Workers" /&gt;&lt;h2 id=&#34;the-alternatives&#34;&gt;The Alternatives
&lt;/h2&gt;&lt;p&gt;When you need to share Terraform modules across projects:&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;Option&lt;/th&gt;
          &lt;th&gt;Cost&lt;/th&gt;
          &lt;th&gt;Complexity&lt;/th&gt;
          &lt;th&gt;Trade-off&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;Terraform Cloud&lt;/td&gt;
          &lt;td&gt;$20+/month&lt;/td&gt;
          &lt;td&gt;Low&lt;/td&gt;
          &lt;td&gt;Free tier doesn&amp;rsquo;t support custom modules&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Nexus/Artifactory&lt;/td&gt;
          &lt;td&gt;Free&lt;/td&gt;
          &lt;td&gt;High&lt;/td&gt;
          &lt;td&gt;Java-based, heavy for this use case&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Custom (this)&lt;/td&gt;
          &lt;td&gt;Free&lt;/td&gt;
          &lt;td&gt;Medium&lt;/td&gt;
          &lt;td&gt;Implement registry protocol yourself&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The killer: Terraform Cloud&amp;rsquo;s free tier doesn&amp;rsquo;t support private module registries. You need the paid plan. For homelab use, that&amp;rsquo;s 10x the cost.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What this handles&lt;/strong&gt;: service discovery, version listing, download URL redirects, and zipball proxy — the full registry protocol.&lt;/p&gt;
&lt;h2 id=&#34;worker-endpoints&#34;&gt;Worker Endpoints
&lt;/h2&gt;&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;Endpoint&lt;/th&gt;
          &lt;th&gt;Purpose&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;&lt;code&gt;/.well-known/terraform.json&lt;/code&gt;&lt;/td&gt;
          &lt;td&gt;Service discovery — tells Terraform where the API lives&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;&lt;code&gt;/v1/modules/:ns/:name/:provider/versions&lt;/code&gt;&lt;/td&gt;
          &lt;td&gt;Lists available versions (from GitHub tags)&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;&lt;code&gt;/v1/modules/:ns/:name/:provider/:version/download&lt;/code&gt;&lt;/td&gt;
          &lt;td&gt;Returns the download URL&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;&lt;code&gt;/archive/:ns/:name/:provider/:version&lt;/code&gt;&lt;/td&gt;
          &lt;td&gt;Proxies the actual module download&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id=&#34;the-implementation&#34;&gt;The Implementation
&lt;/h2&gt;&lt;p&gt;Let me walk through the actual code. The worker is written in Python using Pyodide, which means full CPython 3.12 in WebAssembly.&lt;/p&gt;
&lt;h3 id=&#34;service-discovery&#34;&gt;Service Discovery
&lt;/h3&gt;&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-python&#34;&gt;async def handle_well_known(req, env):
    &amp;#34;&amp;#34;&amp;#34;Handle /.well-known/terraform.json&amp;#34;&amp;#34;&amp;#34;
    response = {
        &amp;#34;modules.v1&amp;#34;: f&amp;#34;{env.get(&amp;#39;BASE_URL&amp;#39;, &amp;#39;https://terraform.example.com&amp;#39;)}/v1/modules/&amp;#34;
    }
    return json_response(response)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Simple enough — returns a JSON object telling Terraform where to find the modules API.&lt;/p&gt;
&lt;h3 id=&#34;version-listing&#34;&gt;Version Listing
&lt;/h3&gt;&lt;p&gt;This is where things get interesting. I needed to fetch GitHub tags and filter for valid semver:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-python&#34;&gt;def filter_semver_tags(tags: list[str]) -&amp;gt; list[str]:
    &amp;#34;&amp;#34;&amp;#34;Filter tags to only valid semver versions.&amp;#34;&amp;#34;&amp;#34;
    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&amp;#34;^v?\d&amp;#43;\.\d&amp;#43;\.\d&amp;#43;(?:[.&amp;#43;-].&amp;#43;)?$&amp;#34;)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The regex ensures only proper semver tags (&lt;code&gt;v1.0.0&lt;/code&gt;, &lt;code&gt;2.3.1&lt;/code&gt;, etc.) are returned. Prerelease versions like &lt;code&gt;v1.0.0-beta&lt;/code&gt; are supported too.&lt;/p&gt;
&lt;h3 id=&#34;authentication-the-github-app-dance&#34;&gt;Authentication: The GitHub App Dance
&lt;/h3&gt;&lt;p&gt;Here&amp;rsquo;s where it gets clever. Instead of requiring users to generate GitHub tokens, the worker handles authentication server-side using a GitHub App:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-python&#34;&gt;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) -&amp;gt; str:
        &amp;#34;&amp;#34;&amp;#34;Get cached installation token, refreshing if needed.&amp;#34;&amp;#34;&amp;#34;
        now = time.time()
        if self._token_cache and now &amp;lt; 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 &amp;#43; 3300
        return token

    def _generate_jwt(self) -&amp;gt; str:
        &amp;#34;&amp;#34;&amp;#34;Create signed JWT using RS256.&amp;#34;&amp;#34;&amp;#34;
        # ... WebCrypto implementation&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The worker generates a JWT signed with the GitHub App&amp;rsquo;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.&lt;/p&gt;
&lt;h2 id=&#34;design-considerations&#34;&gt;Design Considerations
&lt;/h2&gt;&lt;h3 id=&#34;why-server-side-auth&#34;&gt;Why Server-Side Auth?
&lt;/h3&gt;&lt;p&gt;The alternative would be API tokens in &lt;code&gt;.terraformrc&lt;/code&gt;. But that means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Users need to generate tokens&lt;/li&gt;
&lt;li&gt;Tokens expire and need refreshing&lt;/li&gt;
&lt;li&gt;You&amp;rsquo;re exposing long-lived credentials&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;With server-side GitHub App auth, Terraform just talks to your worker — no credentials needed on the client side.&lt;/p&gt;
&lt;h3 id=&#34;token-caching-strategy&#34;&gt;Token Caching Strategy
&lt;/h3&gt;&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-python&#34;&gt;# Using Cloudflare&amp;#39;s Cache API for cross-request caching
cache = caches.default
cache_key = f&amp;#34;gh-token-{installation_id}&amp;#34;
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={
        &amp;#34;Cache-Control&amp;#34;: &amp;#34;max-age=3300&amp;#34;
    })
    await cache.put(cache_key, response)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This is critical because GitHub rate limits would hit hard without caching.&lt;/p&gt;
&lt;h3 id=&#34;download-url-handling&#34;&gt;Download URL Handling
&lt;/h3&gt;&lt;p&gt;Terraform expects the download endpoint to return a &lt;code&gt;X-Terraform-Get&lt;/code&gt; header with the location of the actual module archive. I point it back to my worker:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-python&#34;&gt;async def handle_module_download(req, env, ns, name, provider, version):
    # Return redirect to archive endpoint
    archive_url = f&amp;#34;/archive/{ns}/{name}/{provider}/{version}&amp;#34;
    return Response.new(&amp;#34;&amp;#34;, headers={
        &amp;#34;X-Terraform-Get&amp;#34;: archive_url
    })&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Then the archive endpoint fetches from GitHub&amp;rsquo;s zipball URL and streams it through.&lt;/p&gt;
&lt;h2 id=&#34;constraints-and-limits&#34;&gt;Constraints and Limits
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t a &amp;ldquo;run anything anywhere&amp;rdquo; solution. There are real constraints:&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;Constraint&lt;/th&gt;
          &lt;th&gt;Value&lt;/th&gt;
          &lt;th&gt;Implication&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;CPU Time&lt;/td&gt;
          &lt;td&gt;30s (Paid) / 10ms (Free)&lt;/td&gt;
          &lt;td&gt;Complex auth may timeout on free tier&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Memory&lt;/td&gt;
          &lt;td&gt;128 MB&lt;/td&gt;
          &lt;td&gt;Enough for Python + JSON parsing&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Packages&lt;/td&gt;
          &lt;td&gt;Pyodide stdlib only&lt;/td&gt;
          &lt;td&gt;No external dependencies&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;Terraform Cloud&amp;rsquo;s free tier doesn&amp;rsquo;t support private module registries. You need the paid plan.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&#34;on-prem-deployment-with-workerd&#34;&gt;On-Prem Deployment with workerd
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the part that makes this interesting for self-hosted scenarios: these workers can run locally using &lt;a class=&#34;link&#34; href=&#34;https://github.com/cloudflare/workerd&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;
    &gt;workerd&lt;/a&gt;, Cloudflare&amp;rsquo;s Workers runtime as a container.&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;# docker-compose.yml for local workerd
services:
  workerd:
    image: cloudflare/workerd:latest
    ports:
      - &amp;#34;8787:8787&amp;#34;
    volumes:
      - ./config.workerd:/etc/workerd/config.capnp
    environment:
      - PORT=8787&lt;/code&gt;&lt;/pre&gt;&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-javascript&#34;&gt;// config.workerd (JavaScript service binding syntax)
services: [
  {
    name: &amp;#34;terraform-registry&amp;#34;,
    script: readFile(&amp;#34;dist/worker.js&amp;#34;),
    bindings: {
      GITHUB_APP_ID: &amp;#34;123456&amp;#34;,
      GITHUB_INSTALLATION_ID: &amp;#34;789012&amp;#34;,
    }
  }
]&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This means you can:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Develop and test workers locally&lt;/li&gt;
&lt;li&gt;Run the same code on Cloudflare&amp;rsquo;s edge&lt;/li&gt;
&lt;li&gt;Deploy on-prem for air-gapped environments&lt;/li&gt;
&lt;li&gt;Use identical infrastructure everywhere&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&#34;usage-example&#34;&gt;Usage Example
&lt;/h2&gt;&lt;p&gt;With everything configured, using the registry is straightforward:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-hcl&#34;&gt;# main.tf
module &amp;#34;proxmox_vm&amp;#34; {
  source  = &amp;#34;registry.example.com/namespace/tf-module-proxmox-vm/proxmox&amp;#34;
  version = &amp;#34;1.0.7&amp;#34;

  configuration = {
    name        = &amp;#34;web-server&amp;#34;
    node_name   = &amp;#34;pve1&amp;#34;
    cpu = { cores = 2, type = &amp;#34;host&amp;#34; }
    memory = { dedicated = 4096 }
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And the CLI configuration:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-hcl&#34;&gt;# ~/.terraformrc
host &amp;#34;registry.example.com&amp;#34; {
  services = {
    &amp;#34;modules.v1&amp;#34; = &amp;#34;https://registry.example.com/v1/modules/&amp;#34;
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;No credentials needed — the worker handles everything server-side.&lt;/p&gt;
&lt;h2 id=&#34;what-most-people-get-wrong&#34;&gt;What Most People Get Wrong
&lt;/h2&gt;&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&amp;ldquo;Free tier has no limits&amp;rdquo;&lt;/strong&gt; — 10ms CPU time free, 30s paid. Complex auth on free tier may timeout.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&amp;ldquo;Pyodide supports all Python packages&amp;rdquo;&lt;/strong&gt; — No. ~150 bundled, no arbitrary pip install. Use WebCrypto via JavaScript instead.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&amp;ldquo;One worker does everything&amp;rdquo;&lt;/strong&gt; — For high traffic, add caching. GitHub API rate limits (5K/hour) apply.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&#34;when-to-use--when-not-to-use&#34;&gt;When to Use / When NOT to Use
&lt;/h2&gt;&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;Use Private Registry&lt;/th&gt;
          &lt;th&gt;Use Terraform Cloud&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;Free tier budget&lt;/td&gt;
          &lt;td&gt;Team with &amp;gt;5 users&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;10-50 modules&lt;/td&gt;
          &lt;td&gt;100+ modules&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Simple requirements&lt;/td&gt;
          &lt;td&gt;Policy enforcement needed&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Air-gapped capable&lt;/td&gt;
          &lt;td&gt;Cloud-hosted required&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id=&#34;whats-next&#34;&gt;What&amp;rsquo;s Next
&lt;/h2&gt;&lt;p&gt;This pattern — serverless registry with on-prem fallback — has proven so useful I&amp;rsquo;ve applied it to APT repositories, which I&amp;rsquo;ll cover in the next post. The same architecture, different protocol, same benefits.&lt;/p&gt;
&lt;p&gt;This pattern can be adapted to your own infrastructure by implementing the Terraform Module Registry Protocol on any serverless or traditional hosting platform.&lt;/p&gt;
</description>
        </item>
        <item>
        <title>Python Workers on Cloudflare</title>
        <link>https://zharif.my/posts/cloudflare-workers-python/</link>
        <pubDate>Thu, 15 Jan 2026 00:00:00 +0000</pubDate>
        
        <guid>https://zharif.my/posts/cloudflare-workers-python/</guid>
        <description>&lt;img src="https://images.unsplash.com/photo-1526379095098-d400fd0bf935?w=800&amp;h=400&amp;fit=crop" alt="Featured image of post Python Workers on Cloudflare" /&gt;&lt;h2 id=&#34;the-critical-distinction&#34;&gt;The Critical Distinction
&lt;/h2&gt;&lt;p&gt;Cloudflare announced Python support. You think: &amp;ldquo;Finally, real Python on serverless!&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Reality check: &lt;strong&gt;Pyodide ≠ CPython&lt;/strong&gt;. It&amp;rsquo;s CPython compiled to WebAssembly. The implications:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;No OS calls, no &lt;code&gt;os&lt;/code&gt; module&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;pip install&lt;/code&gt; — packages must be pre-bundled&lt;/li&gt;
&lt;li&gt;Single-threaded execution&lt;/li&gt;
&lt;li&gt;HTTP via JavaScript &lt;code&gt;fetch&lt;/code&gt;, not &lt;code&gt;requests&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This isn&amp;rsquo;t a problem — it&amp;rsquo;s just different. Understanding the constraints makes the difference between &amp;ldquo;why doesn&amp;rsquo;t this work&amp;rdquo; and &amp;ldquo;I know exactly what to use.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;My use case&lt;/strong&gt;: Terraform Registry (~2K requests/day) + APT Repository (~500 requests/day). Both run on free tier.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Pyodide bundles a subset of Python&amp;rsquo;s standard library and about 150+ packages (numpy, pandas, etc.). But arbitrary PyPI packages won&amp;rsquo;t work.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&#34;pyodide-architecture&#34;&gt;Pyodide Architecture
&lt;/h2&gt;&lt;p&gt;The implications:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No OS&lt;/strong&gt; — there&amp;rsquo;s no Linux, no system calls, no &lt;code&gt;os&lt;/code&gt; module as you&amp;rsquo;d expect&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No pip&lt;/strong&gt; — you can&amp;rsquo;t &lt;code&gt;pip install requests&lt;/code&gt;. Packages must be pre-bundled&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No threads&lt;/strong&gt; — single-threaded execution model&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Limited stdlib&lt;/strong&gt; — not everything is compiled to WASM&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;the-template&#34;&gt;The Template
&lt;/h2&gt;&lt;p&gt;I built a template that handles the boilerplate. Here&amp;rsquo;s how to start:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;# Clone the template
git clone https://github.com/cloudflare/worker-python-template.git
cd worker-python-template

# Install dependencies
npm install

# Run locally
npx wrangler dev&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&#34;project-structure&#34;&gt;Project Structure
&lt;/h2&gt;&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-text&#34;&gt;cloudflare-worker-python-template/
├── src/
│   └── entry.py          # Your worker code
├── tests/
│   └── test_worker.py    # Unit tests
├── wrangler.toml         # Worker configuration
├── pyproject.toml        # Python tooling
├── requirements.txt      # Pyodide packages
├── .github/
│   └── workflows/
│       └── deploy.yml    # CI/CD pipeline
└── README.md&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&#34;the-entry-point&#34;&gt;The Entry Point
&lt;/h2&gt;&lt;p&gt;Every Worker needs an entry point. In Python Workers, it&amp;rsquo;s &lt;code&gt;on_fetch&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-python&#34;&gt;# src/entry.py (from actual template)
import json
from urllib.parse import urlparse

async def on_fetch(request, env):
    &amp;#34;&amp;#34;&amp;#34;Handle incoming requests.&amp;#34;&amp;#34;&amp;#34;
    
    # Parse the URL path
    path = urlparse(request.url).path
    
    # Route to handlers
    if path == &amp;#34;/&amp;#34;:
        return Response.new(&amp;#34;Hello from Python Workers!&amp;#34;)
    
    elif path == &amp;#34;/health&amp;#34;:
        return Response.new(
            json.dumps({&amp;#34;status&amp;#34;: &amp;#34;ok&amp;#34;}),
            headers={&amp;#34;Content-Type&amp;#34;: &amp;#34;application/json&amp;#34;}
        )
    
    # 404 for everything else
    return Response.new(&amp;#34;Not Found&amp;#34;, status=404)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Simple, familiar, Pythonic.&lt;/p&gt;
&lt;h2 id=&#34;accessing-environment-variables&#34;&gt;Accessing Environment Variables
&lt;/h2&gt;&lt;p&gt;Just like in Node.js Workers, you access secrets and environment variables via the &lt;code&gt;env&lt;/code&gt; object:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-python&#34;&gt;# src/entry.py
async def on_fetch(request, env):
    # String variables from wrangler.toml [vars]
    debug_mode = env.get(&amp;#34;DEBUG&amp;#34;, &amp;#34;false&amp;#34;)
    
    # Secrets (set via: npx wrangler secret put API_KEY)
    api_key = env.API_KEY
    
    # Use them
    if debug_mode == &amp;#34;true&amp;#34;:
        console.log(f&amp;#34;API Key loaded: {api_key[:4]}...&amp;#34;)
    
    return Response.new(f&amp;#34;API Key: {api_key[:4]}***&amp;#34;)&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&#34;working-with-javascript-apis&#34;&gt;Working with JavaScript APIs
&lt;/h2&gt;&lt;p&gt;This is where Pyodide gets interesting. You can import JavaScript objects directly into Python:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-python&#34;&gt;from js import console, fetch, Response, URL

# Use browser/Workers APIs
async def on_fetch(request, env):
    # fetch is available directly
    resp = await fetch(&amp;#34;https://api.github.com/users/your-username&amp;#34;)
    data = await resp.text()
    
    return Response.new(data, headers={&amp;#34;Content-Type&amp;#34;: &amp;#34;application/json&amp;#34;})&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The &lt;code&gt;js&lt;/code&gt; module exposes global JavaScript objects. This is how you do HTTP requests, interact with the Cache API, use WebCrypto, etc.&lt;/p&gt;
&lt;h2 id=&#34;understanding-the-constraints&#34;&gt;Understanding the Constraints
&lt;/h2&gt;&lt;p&gt;This is critical. Python Workers aren&amp;rsquo;t Node.js Workers:&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;Aspect&lt;/th&gt;
          &lt;th&gt;Python Workers&lt;/th&gt;
          &lt;th&gt;Node.js Workers&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;Package Manager&lt;/td&gt;
          &lt;td&gt;Pyodide bundles only&lt;/td&gt;
          &lt;td&gt;npm (everything)&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Cold Start&lt;/td&gt;
          &lt;td&gt;~5-10ms&lt;/td&gt;
          &lt;td&gt;~1ms&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Memory&lt;/td&gt;
          &lt;td&gt;128 MB&lt;/td&gt;
          &lt;td&gt;128 MB&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;CPU Time (Free)&lt;/td&gt;
          &lt;td&gt;10ms&lt;/td&gt;
          &lt;td&gt;10ms&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;CPU Time (Paid)&lt;/td&gt;
          &lt;td&gt;30s&lt;/td&gt;
          &lt;td&gt;50ms-30s&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Filesystem&lt;/td&gt;
          &lt;td&gt;None&lt;/td&gt;
          &lt;td&gt;None&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&#34;available-packages&#34;&gt;Available Packages
&lt;/h3&gt;&lt;p&gt;Pyodide includes ~150+ packages out of the box:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Standard Library&lt;/strong&gt;: &lt;code&gt;json&lt;/code&gt;, &lt;code&gt;re&lt;/code&gt;, &lt;code&gt;urllib&lt;/code&gt;, &lt;code&gt;hashlib&lt;/code&gt;, &lt;code&gt;base64&lt;/code&gt;, &lt;code&gt;datetime&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data&lt;/strong&gt;: &lt;code&gt;numpy&lt;/code&gt;, &lt;code&gt;pandas&lt;/code&gt;, &lt;code&gt;scipy&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Web&lt;/strong&gt;: (limited — use &lt;code&gt;fetch&lt;/code&gt; from JS instead of &lt;code&gt;requests&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-python&#34;&gt;# This works
import json
import re
import hashlib
from urllib.parse import urlparse

# This does NOT work (not bundled)
# import requests  # ❌
# import httpx      # ❌
# import cryptography  # ❌&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;For HTTP, use the JavaScript &lt;code&gt;fetch&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-python&#34;&gt;from js import fetch

async def call_api(url):
    resp = await fetch(url)
    return await resp.json()&lt;/code&gt;&lt;/pre&gt;&lt;h3 id=&#34;working-around-missing-packages&#34;&gt;Working Around Missing Packages
&lt;/h3&gt;&lt;p&gt;For things like cryptographic operations, use WebCrypto via JavaScript:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-python&#34;&gt;from js import crypto, TextEncoder

async def hash_sha256(data: str) -&amp;gt; str:
    &amp;#34;&amp;#34;&amp;#34;Hash data using WebCrypto.&amp;#34;&amp;#34;&amp;#34;
    encoder = TextEncoder.new()
    encoded = encoder.encode(data)
    hash_buffer = await crypto.subtle.digest(&amp;#34;SHA-256&amp;#34;, encoded)
    return bytes(hash_buffer).hex()&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&#34;configuration-wranglertoml&#34;&gt;Configuration (wrangler.toml)
&lt;/h2&gt;&lt;p&gt;The worker configuration lives in &lt;code&gt;wrangler.toml&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-toml&#34;&gt;name = &amp;#34;my-worker&amp;#34;
main = &amp;#34;src/entry.py&amp;#34;
compatibility_date = &amp;#34;2026-04-25&amp;#34;

# Environment variables (non-sensitive)
[vars]
ENVIRONMENT = &amp;#34;production&amp;#34;
DEBUG = &amp;#34;false&amp;#34;

# KV Namespace for key-value storage
[[kv_namespaces]]
binding = &amp;#34;CACHE&amp;#34;
id = &amp;#34;abc123def456&amp;#34;

# D1 Database for SQL
[[d1_databases]]
binding = &amp;#34;DB&amp;#34;
database_name = &amp;#34;my-db&amp;#34;
database_id = &amp;#34;def456abc789&amp;#34;

# R2 Bucket for object storage
[[r2_buckets]]
binding = &amp;#34;ASSETS&amp;#34;
bucket_name = &amp;#34;my-assets&amp;#34;

# Deploy to specific environment
[env.staging]
name = &amp;#34;my-worker-staging&amp;#34;

[env.staging.vars]
ENVIRONMENT = &amp;#34;staging&amp;#34;&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&#34;development-workflow&#34;&gt;Development Workflow
&lt;/h2&gt;&lt;h3 id=&#34;local-development&#34;&gt;Local Development
&lt;/h3&gt;&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;# Start the dev server
npx wrangler dev

# Test with curl
curl http://localhost:8787/
# {&amp;#34;status&amp;#34;: &amp;#34;ok&amp;#34;}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The dev server reloads on file changes. It&amp;rsquo;s fast and works well.&lt;/p&gt;
&lt;h3 id=&#34;testing&#34;&gt;Testing
&lt;/h3&gt;&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-python&#34;&gt;# tests/test_worker.py (pytest)
import pytest
from src.entry import on_fetch

class MockEnv:
    DEBUG = &amp;#34;false&amp;#34;
    API_KEY = &amp;#34;test-key&amp;#34;

class MockRequest:
    def __init__(self, url):
        self.url = url

def test_health_endpoint():
    request = MockRequest(&amp;#34;http://localhost/health&amp;#34;)
    response = on_fetch(request, MockEnv())
    
    assert response.status == 200

def test_root_endpoint():
    request = MockRequest(&amp;#34;http://localhost/&amp;#34;)
    response = on_fetch(request, MockEnv())
    
    assert response.status == 200
    assert &amp;#34;Hello&amp;#34; in response.body&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Run tests:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;pip install pytest
pytest -v&lt;/code&gt;&lt;/pre&gt;&lt;h3 id=&#34;linting&#34;&gt;Linting
&lt;/h3&gt;&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;pip install ruff
ruff check src/ tests/
ruff format src/ tests/&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The template includes CI that runs both tests and linting.&lt;/p&gt;
&lt;h2 id=&#34;cicd-pipeline&#34;&gt;CI/CD Pipeline
&lt;/h2&gt;&lt;p&gt;The included GitHub Actions workflow:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: &amp;#39;3.12&amp;#39;
      
      - name: Lint
        run: |
          pip install ruff
          ruff check src/ tests/
      
      - name: Test
        run: |
          pip install pytest
          pytest -v
      
      - name: Deploy
        uses: cloudflare/wrangler-action@v3
        with:
          api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          account-id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}&lt;/code&gt;&lt;/pre&gt;&lt;h2 id=&#34;on-prem-deployment-with-workerd&#34;&gt;On-Prem Deployment with workerd
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s where this gets powerful: you can run the same Python Worker locally using &lt;a class=&#34;link&#34; href=&#34;https://github.com/cloudflare/workerd&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;
    &gt;workerd&lt;/a&gt;, Cloudflare&amp;rsquo;s open-source Workers runtime.&lt;/p&gt;
&lt;h3 id=&#34;why-run-locally&#34;&gt;Why Run Locally?
&lt;/h3&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Development&lt;/strong&gt; — faster iteration than deploy-then-test&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Testing&lt;/strong&gt; — consistent environment for integration tests&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Air-gapped&lt;/strong&gt; — run in environments without internet&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Privacy&lt;/strong&gt; — keep traffic local for sensitive workloads&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;docker-setup&#34;&gt;Docker Setup
&lt;/h3&gt;&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;# docker-compose.yml
services:
  worker:
    image: cloudflare/workerd:latest
    ports:
      - &amp;#34;8787:8787&amp;#34;
    volumes:
      - ./config.workerd:/etc/workerd/config.capnp:ro
    cap_add:
      - SYS_ADMIN&lt;/code&gt;&lt;/pre&gt;&lt;h3 id=&#34;the-config&#34;&gt;The Config
&lt;/h3&gt;&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-javascript&#34;&gt;// config.workerd (JavaScript format, not TOML)
export default {
  services: [
    {
      name: &amp;#34;my-worker&amp;#34;,
      script: readFile(&amp;#34;dist/worker.mjs&amp;#34;),
      bindings: {
        ENVIRONMENT: &amp;#34;development&amp;#34;,
        API_KEY: &amp;#34;dev-key&amp;#34;,
      }
    }
  ],
  sockets: [
    {
      address: &amp;#34;0.0.0.0:8787&amp;#34;,
      http: {
        endpoint: &amp;#34;0.0.0.0:8787&amp;#34;
      }
    }
  ]
};&lt;/code&gt;&lt;/pre&gt;&lt;h3 id=&#34;building-for-workerd&#34;&gt;Building for workerd
&lt;/h3&gt;&lt;p&gt;The trick: Wrangler outputs JavaScript, but workerd needs its own format. The template handles this:&lt;/p&gt;
&lt;pre class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;# Build for Cloudflare (default)
npx wrangler deploy

# Build for workerd (local)
npm run build:workerd&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This outputs a compatible &lt;code&gt;worker.mjs&lt;/code&gt; for local testing.&lt;/p&gt;
&lt;h2 id=&#34;real-world-usage&#34;&gt;Real-World Usage
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;ve built several production workers using this template:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;terraform-registry&lt;/strong&gt; — ~2K requests/day, handles module distribution&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;apt-repository&lt;/strong&gt; — ~500 requests/day, serves packages to 10+ machines&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;cloudflare-ddns&lt;/strong&gt; — Updates DNS records based on IP changes&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;All run on the free tier. All deploy in seconds. All can run locally.&lt;/p&gt;
&lt;h2 id=&#34;what-i-love&#34;&gt;What I Love
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Python syntax&lt;/strong&gt; — feels like writing regular Python&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Global distribution&lt;/strong&gt; — edge deployment out of the box&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zero infra&lt;/strong&gt; — no servers, no scaling concerns&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;On-prem option&lt;/strong&gt; — workerd for local/air-gapped needs&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;what-most-people-get-wrong&#34;&gt;What Most People Get Wrong
&lt;/h2&gt;&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&amp;ldquo;Pyodide = CPython&amp;rdquo;&lt;/strong&gt; — No OS, no pip, no threads. Use &lt;code&gt;js&lt;/code&gt; module for HTTP/WebCrypto.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&amp;ldquo;Free tier is unlimited&amp;rdquo;&lt;/strong&gt; — 10ms CPU cap. Complex Python on free tier = timeouts.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&amp;ldquo;Works locally = works on edge&amp;rdquo;&lt;/strong&gt; — Local dev uses Node, edge uses V8. Test with workerd.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&#34;when-to-use-python-workers&#34;&gt;When to Use Python Workers
&lt;/h2&gt;&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;Use Python Workers&lt;/th&gt;
          &lt;th&gt;Use Node.js Workers&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;Python expertise&lt;/td&gt;
          &lt;td&gt;JavaScript expertise&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Data processing&lt;/td&gt;
          &lt;td&gt;I/O-heavy&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;Simple logic&lt;/td&gt;
          &lt;td&gt;Complex async&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;~150 bundled packages needed&lt;/td&gt;
          &lt;td&gt;Full npm ecosystem&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id=&#34;getting-started&#34;&gt;Getting Started
&lt;/h2&gt;&lt;p&gt;If you want to build your own Python Workers:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Use the &lt;a class=&#34;link&#34; href=&#34;https://developers.cloudflare.com/workers/languages/python&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;
    &gt;Workers Python template&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Write your &lt;code&gt;on_fetch&lt;/code&gt; handler&lt;/li&gt;
&lt;li&gt;Deploy with &lt;code&gt;wrangler deploy&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This post covers building workers with Python. In future posts, I&amp;rsquo;ll dive into specific patterns like handling async operations, using KV/D1/R2 bindings, and testing strategies for Workers.&lt;/p&gt;
</description>
        </item>
        
    </channel>
</rss>
