<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Apt on zharif.my</title>
        <link>https://zharif.my/tags/apt/</link>
        <description>Recent content in Apt 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/apt/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>
        
    </channel>
</rss>
