Designs by Duhart All demos

Designs by Duhart

How the demos are served

The Billboard, BoxOffice and Dating demos are the actual Next.js application, served from a container on my own Kubernetes node and talking to the same Cloudflare Workers, R2 bucket and Stream account it uses in production. This page is how that is wired.

Why a pod, not a copy

The alternative was to copy the player source into this site, 67 files and about 530 KB, and stub out the network calls. That works, and it starts lying the first time a component upstream changes. Running the real application leaves no copy to drift.

designsbyduhart.org  (Cloudflare Workers)
    │
    └── iframe ──►  demo.designsbyduhart.org
                        │  Cloudflare edge · TLS
                        ▼
                    cloudflared tunnel  ──►  127.0.0.1:30900
                        │
                        ▼
                    Service/NodePort · namespace portfolio-demo
                        │
                        ▼
                    Pod: Next.js standalone server
                        │
            ┌───────────┼───────────────┐
            ▼           ▼               ▼
    billboard-api   boxoffice-api   dating: bundled
      + R2 (audio)    + Stream (HLS)   fictional profiles

The deployment is the exhibit

The Billboard, BoxOffice and Dating demos are not recordings and not copies. They are served by a pod, and the pod is the part worth reading about. A single-node k3s cluster that already runs a twenty-one pod workload, with no ingress controller, no registry, and no memory to spare. Every decision below was forced by one of those three facts.

What it actually took

  1. The cluster has no ingress controllerkube-system runs only CoreDNS and metrics-server. An Ingress object already existed for another service and had never served a request, so nothing was watching it.Exposed the Service as a NodePort on loopback and pointed a cloudflared tunnel hostname at it. TLS and the public name come from the Cloudflare edge; the host opens no new ports. Installing an ingress controller was the alternative and it costs memory this node does not have.
  2. With no ingress, there is nowhere in front to filter pathsThe container serves the whole application, dating, staff and moderation consoles, an LLM tier. A tunnel routes by hostname, not by path, so none of that could be excluded at the edge.The route allow-list moved inside the image: a Next proxy that answers 404 to everything outside the demo routes, with its enabling flag set as a Dockerfile ENV rather than only in the Deployment. The image is gated by construction, so running it by hand, or from a manifest missing that variable, still yields a gated demo.
  3. runAsNonRoot rejected an image that runs as a named userCreateContainerConfigError: “container has runAsNonRoot and image has non-numeric user (node), cannot verify user is non-root”. The pod never started.The kubelet checks the image config and does not resolve names out of the image's /etc/passwd, so USER node is not enough. Pinned runAsUser/runAsGroup to 1000 alongside readOnlyRootFilesystem, dropped capabilities and an emptyDir for the one path the server writes.
  4. k3s runs containerd, and there is no registryA locally built image is invisible to the cluster. Left at the default pull policy the pod sits in ErrImagePull, reaching out to Docker Hub for a tag that exists only on this disk.Exported the image and imported it straight into containerd, with imagePullPolicy: Never so the kubelet never goes looking for a registry that was never part of the design.
  5. The build cannot run on the cluster it deploys toThis node already runs a 21-pod fleet with a few GB spare and no swap headroom. The application's build peaks past 4 GB, enough to trigger an OOM that reaps unrelated workloads and presents as a network outage rather than as a memory problem.The build runs on the host with a capped heap and writes to its own output directory so it cannot clobber a running dev server's; the Dockerfile only copies the finished artifact. The image carries a server, not a toolchain. A guard fails the build outright if an environment file reaches the artifact.

The manifest

# deploy/demo/k8s/deployment.yaml  (excerpt)
spec:
  containers:
    - name: web
      image: web-platform-demo:latest
      imagePullPolicy: Never        # side-loaded into containerd; no registry
      env:
        - name: DEMO_MODE           # also baked into the image, deliberately
          value: "1"
      resources:                    # sized against a node already 22% requested
        requests: { cpu: 100m, memory: 256Mi }
        limits:   { cpu: "1",  memory: 768Mi }
      readinessProbe:
        httpGet: { path: /billboard, port: http }
      securityContext:
        runAsUser: 1000             # numeric: the kubelet cannot resolve "node"
        runAsNonRoot: true
        readOnlyRootFilesystem: true
        allowPrivilegeEscalation: false
        capabilities: { drop: ["ALL"] }

Running, now

Captured from the cluster serving the demos. The pod name changes on every rollout; everything else is stable.

$ kubectl -n portfolio-demo get deploy,svc,pod
NAME                                READY   IMAGES
deployment.apps/web-platform-demo   1/1     web-platform-demo:latest

NAME                        TYPE       CLUSTER-IP   PORT(S)
service/web-platform-demo   NodePort   10.43.41.5   80:30900/TCP

NAME                                    READY   STATUS    IP           NODE
pod/web-platform-demo-66c94fd8c-k7bbx   1/1     Running   10.42.0.75   theone

Two upstream defects surfaced while proving the players actually work, and both are written up rather than quietly patched around: a streaming route that returns 500 to any HTTP Range request, which browsers send for all media, so seeking was broken in production, and a client that manufactured its own 401 before issuing a request the server would have answered.

What is real, and what is not

Real: the components, the audio engine, the HLS pipeline, the API calls and the media. Changed for the demo: the route allow-list above, and one flag that lets an anonymous visitor’s playback request reach the server instead of being refused in the browser. No session is faked and no token is minted, so a genuine refusal from the API still refuses.

The Dating deck is the exception, and says so. It has no anonymous read path worth exposing, so in the demo build its data layer answers from invented profiles with drawn portraits. The UI is the product’s; the people are not real.

The write paths are the honest gap. Liking a track, saving a resume position and adding to a playlist all need an account, so they fail quietly here exactly as they were built to. Playback, seeking, queueing and adaptive bitrate do not.