Building a Listening Room for 10,000 Bootlegs — and Deploying It in 44KB

A 44KB static site for the Aadam Jacobs Archive, deployed to Kubernetes without a container build. One ConfigMap, stock nginx:alpine, Traefik TLS, and a 10-second edit-to-live loop.

Building a Listening Room for 10,000 Bootlegs — and Deploying It in 44KB

The whole site is one HTML file. 44KB. Vanilla JavaScript, a chunk of CSS, and a few Google Fonts. No build step. No node_modules. Nothing to bundle.

Deploying it to a Kubernetes cluster "properly" would mean: write a Dockerfile, build an nginx image with the file baked in, push to a registry, update the deployment to pull the new tag. Every content tweak is a container rebuild. For a single HTML file, that's absurd.

There's a better way, and Claude Code proposed it the moment I described the problem: put the HTML in a ConfigMap and mount it into stock nginx:alpine. No image build. No registry. One kubectl apply from edit to live.

This post is about that shape of deployment — when it works, when it doesn't, and the full manifest for a production-grade setup with TLS.

The Project

The site is the Aadam Jacobs Archive — a browser for one of the more remarkable music collections to land on the Internet Archive in years.

Who Aadam Jacobs Is

Aadam Jacobs is a Chicago music superfan who has been recording shows since the 1980s. Over four decades he amassed roughly 10,000 cassette tapes documenting around 10,000 different concerts — Nirvana in 1989 before they were Nirvana, Sonic Youth, R.E.M., Phish, Liz Phair, Pavement, Neutral Milk Hotel, Tracy Chapman in 1988, plus a long tail of forgotten punk bands whose only surviving recordings might be on his decks. Mostly recorded on, in his words, "pretty mediocre equipment." That doesn't matter. The fact that they exist at all is the point.

Cassettes degrade. Aadam knew it, and earlier this year he agreed to let the Internet Archive's volunteers digitise the lot. As of writing, about 2,500 tapes have been uploaded to the aadamjacobs collection on archive.org, with volunteer audio engineers cleaning up the transfers and other volunteers tracking down song titles from bands nobody remembers. It's a beautiful, slow act of preservation. (TechCrunch covered the project here.)

Why an Alternative Interface

The Internet Archive is an extraordinary preservation machine. As a listening environment, it's a directory listing. The default archive.org/details/aadamjacobs view is a paginated list of items with raw description blobs, an embedded player that resets when you navigate, and no notion of "I want to spend an evening browsing this collection." Metadata that is there — venue, lineage, source equipment, recording notes — is buried inside long unstructured description fields, not in the structured fields the search UI exposes.

For a working archive that's the right call. The IA exists to preserve, not to curate per-collection experiences. But for this collection in particular — where the joy is wandering from a 1989 Nirvana show to a Sonic Youth gig to some unsigned punk band you've never heard of — the directory listing gets in the way.

So I built a thin alternative interface on top of the same data.

The Features I Wanted

A short list, in roughly the order I cared about them:

  • A real player. Continuous playback across tracks within a show, then forward into the next show. Queue, history, shuffle, repeat. The kind of thing every streaming app has had since 2010.
  • Venue and year filters. "Show me everything at the Metro" or "everything from 1992" should be one click, not a search-syntax exercise.
  • Top venues at a glance. Aadam recorded the same Chicago venues over and over — Metro, Lounge Ax, Empty Bottle, Cabaret Metro, the Vic. Surfacing those clusters is more useful than alphabetical sort.
  • Cleaned-up metadata. Pull venue from the title when it's not in the venue field. Extract lineage strings (Mastering: Nakamichi CR-7A → ...) out of the description and present them as a small data-strip on each show. Strip the boilerplate ("Recording N from the Aadam Jacobs Collection...", "Coordinated by the Live Music Archive...") so the actual recording notes can breathe.
  • A look that fits the source material. This is cassette-era live music. The interface should feel like a smudged photocopied zine, not a SaaS dashboard. Special Elite, IBM Plex Mono, Playfair Display italic, a grain overlay, a spinning cassette-reel logo. The whole thing renders in muted newsprint cream and amber on near-black, with a Spotify-green wink on the play buttons.
  • No build, no backend. It's a public collection on a public API. The whole thing should be one HTML file you can read top to bottom, host anywhere, and fork in five minutes.

The data flow is simple. On load, the page calls archive.org/advancedsearch.php?q=collection:aadamjacobs and pulls down identifier, title, creator, venue, date, description, subject, downloads, item size, and added-date for every item. It then runs each description through a small chain of regexes — venueFromTitle, cityFromDesc, extractLineage, extractNotes — to turn the unstructured text into displayable fields. When you pick a show, it hits archive.org/metadata/<id> to enumerate the audio files and stream them directly from archive.org/download/<id>/<file>. Internet Archive does the storage, bandwidth, and CORS. The page is just a UI.

The important bit for this post: it's all in one index.html. 44,614 bytes. Zero dependencies at runtime beyond Google Fonts and the Internet Archive API. Which brings us to the deployment.

The Temptation to Overbuild

The first instinct with Kubernetes is to reach for the full pipeline. Write a Dockerfile:

FROM nginx:alpine
COPY index.html /usr/share/nginx/html/

Build it. Tag it. Push to GHCR. Update the deployment. Wait for the pull. Reload.

For a site with assets, bundlers, environment-specific builds — yes, do that. You want reproducibility and immutability. But for one HTML file? You've just introduced a registry, credentials, a CI workflow, and image garbage collection as dependencies, all to avoid typing the file contents into a YAML document.

The cluster already knows how to mount files into pods. That's what ConfigMaps are for.

The ConfigMap Pattern

The whole deployment is two files. The first is the ConfigMap, generated from the HTML:

kubectl create configmap aja-html \
  --from-file=index.html=./index.html \
  -n aja \
  --dry-run=client -o yaml > k8s/aja-html-cm.yaml

That produces a YAML file with your HTML embedded as a string value. Commit it. The ConfigMap becomes the source of truth for what nginx serves.

The second file, aja.yaml, is the full deployment. The interesting part is the volume mount:

containers:
  - name: nginx
    image: nginx:1.27-alpine
    ports:
      - containerPort: 80
    volumeMounts:
      - name: html
        mountPath: /usr/share/nginx/html/index.html
        subPath: index.html
        readOnly: true
volumes:
  - name: html
    configMap:
      name: aja-html
      items:
        - key: index.html
          path: index.html

Two details matter here.

subPath is the reason nginx's default /usr/share/nginx/html directory isn't clobbered. Without subPath, mounting a ConfigMap at a directory path replaces the entire directory with just the keys in the map. With subPath, only the single file at index.html is overlaid — the rest of nginx's default content (including its fallbacks) stays intact.

readOnly: true is free hygiene. nginx has no business writing to its own document root, and declaring it readonly makes that explicit.

Resource requests are tiny because the workload is tiny:

resources:
  requests:
    cpu: 10m
    memory: 32Mi
  limits:
    cpu: 100m
    memory: 64Mi

A stock nginx serving one file idles at about 4MB of RAM. 32Mi is already generous.

Iteration Speed

This is where the approach earns its keep. To change the site:

vim index.html
kubectl create configmap aja-html \
  --from-file=index.html=./index.html \
  -n aja \
  --dry-run=client -o yaml | kubectl apply -f -
kubectl rollout restart deployment/aja -n aja

No image build. No push. No registry round-trip. The ConfigMap update is instant, and the rollout restart picks it up in seconds. End-to-end from "edit" to "live on the internet" is under 10 seconds on a decent connection.

If you're feeling particularly lazy, kubectl rollout restart isn't even strictly necessary for mounted-file ConfigMaps — the kubelet will eventually sync the new contents into the running pod — but the restart makes it immediate and predictable.

The TLS Wiring

The deployment needs to be reachable over HTTPS with a real certificate, and HTTP requests need to redirect. The cluster (a Civo K3s) already has Traefik as the ingress controller and cert-manager with a letsencrypt-prod ClusterIssuer. The wiring is three resources.

A Traefik Middleware for the HTTP→HTTPS redirect:

apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
  name: redirect-https
  namespace: aja
spec:
  redirectScheme:
    scheme: https
    permanent: true

A ClusterIP Service pointing at the deployment:

apiVersion: v1
kind: Service
metadata:
  name: aja
  namespace: aja
spec:
  type: ClusterIP
  selector:
    app: aja
  ports:
    - name: http
      port: 80
      targetPort: 80

And the Ingress — this is the one that ties everything together:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: aja
  namespace: aja
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
    traefik.ingress.kubernetes.io/router.middlewares: aja-redirect-https@kubernetescrd
spec:
  ingressClassName: traefik
  rules:
    - host: aja.jankylabs.co.uk
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: aja
                port:
                  number: 80
  tls:
    - hosts:
        - aja.jankylabs.co.uk
      secretName: aja-tls

The cert-manager.io/cluster-issuer annotation is what triggers automatic certificate issuance — cert-manager watches for Ingresses with this annotation, performs the ACME HTTP-01 challenge via Traefik, and drops the signed certificate into the aja-tls secret. The ingress picks it up automatically.

The middleware reference uses Traefik's cross-namespace syntax: aja-redirect-https@kubernetescrd means "the Middleware named redirect-https in namespace aja, installed via the Kubernetes CRD provider." Easy to get wrong — the namespace prefix is required even when the middleware is in the same namespace as the ingress.

When This Approach Breaks

It's a sharp tool but it cuts in exactly one direction. The ConfigMap pattern works because:

  • The site is small (well under the 1MB ConfigMap size limit)
  • There's only one file to serve
  • Content updates are infrequent
  • There's no build step

The moment any of those stop being true, you want a proper image build:

  • Assets. Images, fonts, JS bundles — even a handful of them makes the ConfigMap approach ugly. Base64-encoding binaries into YAML works but feels wrong, and you'll hit the 1MB limit fast.
  • Build output. If you're running a bundler, the output should be baked into an immutable image. ConfigMaps don't give you content addressing.
  • Multiple files with structure. You can use multiple ConfigMaps or a single one with many keys, but at that point you're using Kubernetes as a bad filesystem.
  • Content that changes often. Every edit churns the ConfigMap and triggers a rollout. For a static marketing page that changes twice a year, that's fine. For anything more active, you want a CDN.

The rule of thumb: one small file that doesn't change much. Below that bar, ConfigMap + stock nginx is the simplest thing that works.

What Claude Code Got Right

The instinct to skip the build. When I described the project — "I've got this single HTML file, I want to put it on the cluster" — the first response wasn't "let me write you a Dockerfile." It was "this is a ConfigMap use case." That's the right call, and it saved a pipeline I would otherwise have built and then wished I hadn't.

Matching the existing cluster pattern. The cluster already runs another deployment with the same Traefik + cert-manager setup. Claude noticed the pattern, matched the middleware naming, the annotation format, and the ingress shape. New deployments in a cluster should look like existing ones. The boring consistency is the feature.

The subPath detail. Mounting a ConfigMap at a file path (rather than at a directory) using subPath is the kind of specific Kubernetes knowledge that's easy to get wrong. Without it, you replace the entire nginx document root and break the default error pages. Claude got this right on the first pass.

The redirect middleware. I didn't ask for HTTP→HTTPS redirection; Claude added it because the sibling deployment had it. That's a small piece of project-aware behaviour — inferring a convention from one deployment and applying it to another.

Where I Steered

Which cluster. The cluster choice wasn't derivable. I had a couple of clusters available with slightly different configurations; pointing Claude at the right one and telling it to match the existing pattern collapsed a lot of ambiguity in one sentence.

The domain. The hostname is a human DNS decision — cert-manager needs a real domain with DNS already pointing at the cluster's load balancer before the ACME challenge will succeed. That setup is a human step.

Accepting the limitation. Claude flagged the 1MB ConfigMap limit and asked whether the site might grow. I said no — it's a one-shot thing, a single static page, and if it ever needs to be more than that, it can be rebuilt properly. That's a judgement call about the project's trajectory, and it belongs with the person who knows what the thing is for.

One File, Two Manifests, One Site

The full deployment is:

  • index.html — 44KB, the entire site
  • k8s/aja-html-cm.yaml — the ConfigMap generated from the HTML
  • k8s/aja.yaml — Namespace, Deployment, Service, Middleware, Ingress

That's it. No registry. No CI workflow. No image tags to track. The site is live at aja.jankylabs.co.uk with a real Let's Encrypt certificate, HTTP redirecting to HTTPS, and nginx serving a single file from a ConfigMap.

Kubernetes has a reputation for being the heavy-weight option — for good reasons, most of the time. But the primitives are flexible enough that "deploy a static site" can genuinely be a small thing. You don't have to reach for the full image-build machinery every time. Sometimes the cluster already has everything you need, and all the file wants is a home.