Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Welcome to the Pixelflux handbook. This book gathers everything about the project in one place: how to run it, how it is built, how it is tested and secured, and how it deploys. Every page is generated from the canonical Markdown in the repository, so it never drifts from the source.

📊 Prefer the short version? The project slides — a Marp deck on the architecture and SDLC — are authored as Markdown and published right beside this book.

What it is

A shared 200×200 pixel canvas: pick a colour, click to paint, and everyone sees the pixels appear live. The canvas lives in Redis (so it’s shared across instances) and updates are pushed over Server-Sent Events. With no Redis it falls back to an in-memory canvas, so it runs with zero dependencies.

The app itself is small — the point of the project is the tooling around it: a one-command dev shell, a tiny distroless container (< 20 MB, 0-CVE target), automated tests and security scans, and a Kubernetes deployment.

Getting Started

Quick start

Needs Nix (with flakes) and Docker or Podman.

nix develop          # enter the dev shell (provides every tool)
task lock            # generate Cargo.lock (first time only)
task run             # open http://localhost:3000

Run task with no arguments to see all available tasks (build, test, lint, container, sbom, cve, deploy, …).

For the shared, persisted canvas, run a Redis alongside:

docker run -d -p 6379:6379 redis:7-alpine
REDIS_URL=redis://localhost:6379 task run

What task run does

sequenceDiagram
    actor Dev as Developer
    participant T as task run
    participant C as cargo
    participant App as pixelflux (:3000)
    participant S as Canvas store
    actor Br as Browser

    Dev->>T: task run
    T->>C: cargo run
    C->>App: compile (debug) and start
    Note over App,S: REDIS_URL set: Redis<br/>otherwise: in-memory
    Br->>App: open http://localhost:3000
    App-->>Br: embedded web UI
    Br->>App: POST /api/pixel
    App->>S: store the pixel
    App-->>Br: SSE /api/events (live update)

Architecture

Architecture

A single Rust binary (axum) serves the embedded web UI and the API. The canvas lives in Redis — shared across instances — and real-time updates are pushed to browsers over SSE, fanned out between instances with Redis pub/sub. Without Redis it falls back to an in-memory canvas.

flowchart LR
    subgraph B["Browser"]
        UI["Web UI<br/>canvas grid + palette"]
        ES["EventSource<br/>SSE client"]
    end

    subgraph SRV["pixelflux (Rust / axum)"]
        R["Routes<br/>/ &middot; /admin &middot; /health &middot; /info<br/>/api/canvas &middot; /api/pixel &middot; /api/events"]
        ST["AppState<br/>canvas access + broadcast channel"]
        R --> ST
    end

    subgraph RDS["Redis"]
        K["canvas (string)"]
        PS["pub/sub channel"]
    end

    UI -->|GET / &middot; GET /api/canvas| R
    UI -->|POST /api/pixel| R
    R -->|SSE stream| ES
    ST <-->|GET / SETRANGE| K
    ST <-->|PUBLISH / SUBSCRIBE| PS

API Reference

API

MethodRouteDescription
GET/Web UI
GET/healthLiveness probe
GET/infoName, version, and serving instance
GET/api/canvasWhole canvas
POST/registerClaim a unique pseudo → token bound to it
POST/api/pixelPaint a pixel {x, y, color}
GET/api/eventsLive pixel stream (SSE)
GET/api/leaderboardTop-10 players by pixels painted
GET/api/ownershipTerritory % — pixels each player owns now
GET/adminAdmin dashboard (needs a password)

API

Pixelflux exposes a small HTTP API. The full machine-readable spec is in openapi.yaml; contract.hurl is the executable contract test suite.

Endpoints

MethodRouteDescription
GET/Web UI (embedded single page)
GET/healthLiveness probe → {"status":"ok"}
GET/info{"name", "version", "instance", "online"}instance is the serving pod/host; online is the live viewer count (open SSE streams)
GET/api/canvasWhole canvas → {"width", "height", "palette", "pixels"} (pixels is a width*height*6 hex string — an rrggbb colour per cell)
POST/registerRegister a unique pseudo{"token", "name"}. The pseudo is bound to the token server-side. Slow (~5s) anti-abuse (400 invalid · 409 taken · 503 closed)
POST/api/pixelPaint one pixel → header X-Token + body {"x", "y", "color"} (color = rrggbb hex) → {"ok": true} (400 invalid · 401 no/unknown token · 429 rate limited)
GET/api/eventsLive pixel stream (SSE); coalesced batches [{"x","y","color"}, …] + a named leaderboard event (top-10)
GET/api/leaderboardTop-10 players by pixels painted (cumulative) → [{"name","count"}, …]
GET/api/ownershipTerritory — pixels each player currently owns → {"total", "entries":[{"name","count","percent"}]} (transfers when a pixel is overwritten)
GET/adminAdmin dashboard (enabled only when ADMIN_PASSWORD is set); tune limits, maintenance mode, reset canvas, live stats

The canvas is 200×200 in full RGB — each pixel is any rrggbb colour (16M colours); the palette field just gives default preset swatches for the UI. Painting requires a token from /register (sent as X-Token), and is rate limited (default 4096 pixels per token per 30 s). The rate limit, window and other tunables are editable at runtime from the admin page; when maintenance mode is on, painting returns 503.

Each player registers a unique pseudo, which is bound to their token server-side. The leaderboard credit is derived from the token — not from the paint request body — so a client can’t paint under someone else’s name.

Try it

curl localhost:3000/health
curl localhost:3000/api/canvas
curl -X POST localhost:3000/api/pixel \
  -H 'Content-Type: application/json' \
  -d '{"x":1,"y":2,"color":5}'
curl -N localhost:3000/api/events     # streams pixel events as they happen

Contract tests

contract.hurl validates the live server against the spec (status codes, content types, JSON assertions, including the 400 path for an invalid pixel). Run it with:

task test:api

The task builds and boots the server, runs Hurl against it, and shuts it down. To run it against an already-running server:

hurl --test --variable host=http://localhost:3000 api/contract.hurl

Editing the API

When you add or change an endpoint, update both openapi.yaml (the spec) and contract.hurl (the test), then run task test:api to keep them in sync.

Admin

Admin

An admin dashboard at /admin lets you tune the live limits (paint rate limit and window — or switch the rate limit off entirely, registration delay, token TTL, presence timings, and the SSE coalescing window that controls server fan-out), resize the canvas (8–512 px per side, which resets it), flip the canvas into read-only maintenance mode, reset the canvas, edit the preset colour palette (optionally hide the native colour picker, or enforce the palette server-side so off-palette colours are rejected by the API too — not just hidden in the UI), set a custom maintenance message, post a site-wide announcement banner, open or close registration, remove a player (revokes their token, frees their pseudo, drops them from the leaderboard), revoke legacy unbound tokens, and watch live stats (viewers online, pixels painted, tokens issued).

The admin is disabled unless the ADMIN_PASSWORD environment variable is set, so it is off by default. Set it to turn it on:

ADMIN_PASSWORD='a-long-random-secret' task run    # then open /admin

Login opens a short-lived, HttpOnly SameSite=Strict session cookie; the password is compared in constant time and never stored. Settings are persisted in Redis and propagated to every replica over a config:events pub/sub channel, so a change applies fleet-wide instantly (in-memory when Redis is absent).

Enabling the admin on Kubernetes

The Deployment reads ADMIN_PASSWORD from an optional Secret named pixelflux-admin (so the admin stays off until you create it). Create it once, then restart the pods so the env var is injected — no task k3s:up re-run needed:

kubectl -n pixelflux create secret generic pixelflux-admin \
  --from-literal=password='a-long-random-secret'
kubectl -n pixelflux rollout restart deploy/pixelflux

The Secret is created outside GitOps, so Argo CD’s prune/selfHeal leaves it alone. Serve /admin over HTTPS in production. To rotate the password, update the Secret and rollout restart again.

Deployment

Deploy (Kubernetes + Traefik)

Architecture

Traefik load-balances incoming requests across the app pods (round-robin via the Service); the HorizontalPodAutoscaler adds or removes pods under load. Every pod shares the same canvas through Redis, and real-time pixel updates are fanned out to all pods with Redis pub/sub.

flowchart TD
    U["Visitors (browsers)"]
    T["Traefik — Ingress / Load Balancer<br/>:80 HTTP &middot; :443 HTTPS"]
    S["Service: pixelflux<br/>ClusterIP :80 &rarr; :3000"]
    subgraph D["Deployment: pixelflux"]
        P1["Pod 1"]
        P2["Pod 2"]
        P3["Pod 3"]
    end
    H["HorizontalPodAutoscaler<br/>3 &rarr; 10 pods @ 70% CPU"]
    R["Redis<br/>canvas state + pub/sub"]

    U -->|HTTP / HTTPS| T
    T -->|round-robin| S
    S --> P1
    S --> P2
    S --> P3
    H -. scales .-> D
    P1 <--> R
    P2 <--> R
    P3 <--> R

Whichever pod serves a painted pixel, it reaches every connected browser:

sequenceDiagram
    actor A as Browser A
    participant LB as Traefik (LB)
    participant Pi as Pod i (chosen by LB)
    participant R as Redis
    participant All as Every pod
    actor B as Other browsers

    A->>LB: POST /api/pixel {x,y,color}
    LB->>Pi: forward (load-balanced)
    Pi->>R: SETRANGE canvas + PUBLISH
    R-->>All: pub/sub event
    All-->>B: SSE /api/events (live pixel)

Runs several replicas behind Traefik; the canvas is shared via Redis and real-time updates propagate with Redis pub/sub. There are two ways to ship it.

Push to main, and Argo CD rolls it out for you, in HTTPS. The repo and the GHCR image are public, so no secrets are needed — the only settings are your domain and Let’s Encrypt email, in cluster/config.env:

cp cluster/config.env.example cluster/config.env   # set DOMAIN + ACME_EMAIL
task k3s:up        # k3s + Argo CD + the app, in HTTPS  (local Docker: task k3d:up)

From then on every git push to main is deployed automatically: CI publishes a new image, Argo CD Image Updater (installed by the same command) spots the new :latest digest and pins it, and Argo rolls it out — no version bump, no re-apply. The certificate is issued on the first request. Details in argocd/README.md.

Option B — manual (imperative)

On a single-node k3s host:

task deploy:k3s-install               # once: install k3s + Traefik
task deploy                           # build, import, apply the app, roll out
DOMAIN=your.domain.com ACME_EMAIL=you@domain.com task deploy:tls   # enable HTTPS

After enabling HTTPS, use task deploy:restart (not task deploy) for code changes, so the HTTPS route is preserved. Other helpers: task deploy:status, task deploy:logs, task deploy:down. Manifests are in k8s/.

Kubernetes manifests

Deploys Pixelflux on Kubernetes behind Traefik: 3 app replicas load-balanced by a Service, a Redis for the shared canvas + real-time pub/sub, autoscaling, and a Traefik route (HTTP or HTTPS). Tested on single-node k3s (which bundles Traefik), but works on any cluster with the Traefik CRDs.

Files

FileKind(s)Purpose
redis.yamlDeployment, ServiceRedis for shared canvas state and pub/sub fan-out. Ephemeral (no PVC).
pixelflux.yamlDeployment, ServiceThe app: 3 replicas, non-root (uid 65532), read-only root FS, /health probes. Service exposes port 80 → 3000.
hpa.yamlHorizontalPodAutoscaler, PodDisruptionBudgetAutoscale 3→10 at 70% CPU; keep ≥2 fronts available during disruptions.
ingressroute-tls.yamlMiddleware, IngressRoute ×2HTTPS route: HTTP→HTTPS redirect + TLS route with a Let’s Encrypt cert. Host is a placeholder. In the kustomization.
traefik-acme.yamlHelmChartConfigConfigures the k3s Traefik with a Let’s Encrypt (ACME) resolver le (HTTP-01, persistent store). Email comes from config.env; applied once by the bring-up script — not in the kustomization.
ingressroute.yamlIngressRoutePlain HTTP route (Traefik web entrypoint). Not in the kustomization — kept for an HTTP-only setup (task deploy:ingress).
kustomization.yamlKustomizationBundles redis, pixelflux, hpa, and the HTTPS routing (ingressroute-tls).

Deploy flow (tasks)

The Taskfile wraps everything. On a single-node k3s host:

task deploy:k3s-install     # once: install k3s + Traefik
task deploy                 # build image -> import into k3s -> apply -k k8s/ -> rollout

task deploy applies the HTTPS bundle, but with the pixelflux.example.com placeholder host. Set your real domain (and the ACME email) with:

DOMAIN=your.domain.com ACME_EMAIL=you@domain.com task deploy:tls

This substitutes DOMAIN into the Host(...) rules and the ACME email at apply time; the manifests keep pixelflux.example.com as a placeholder so the domain lives only in the command (or in the Argo CD Application, below). The certificate is issued automatically on the first request (~1 min; needs ports 80 + 443). For a plain HTTP-only setup instead, apply ingressroute.yaml with DOMAIN=your.domain.com task deploy:ingress (it replaces the TLS pixelflux route — last applied wins).

The image is not pulled from a registry by default: pixelflux.yaml uses image: pixelflux:latest (imagePullPolicy: IfNotPresent), so it must already be on the node. task deploy:image builds it with Nix and imports it into k3s. For a remote/multi-node cluster, push to a registry (e.g. GHCR) and point image: there with imagePullPolicy: Always.

Admin panel (optional Secret)

The Deployment reads ADMIN_PASSWORD from an optional Secret named pixelflux-admin, so the /admin dashboard stays disabled until you create it. This works on a running cluster (Argo CD or task deploy) without re-running the bring-up — create the Secret, then restart the pods so the env var is injected:

kubectl -n pixelflux create secret generic pixelflux-admin \
  --from-literal=password='a-long-random-secret'
kubectl -n pixelflux rollout restart deploy/pixelflux

The Secret lives outside the kustomization, so Argo CD’s prune/selfHeal won’t touch it. Rotate the password by updating the Secret and restarting again.

Useful

task deploy:status     # pods, service, ingressroute, hpa
task deploy:logs       # tail logs from all replicas
task deploy:restart    # rebuild image + rollout (preserves the route)
task deploy:down       # remove the kustomized resources

For code changes after the first deploy, use task deploy:restart (rebuild + rollout) so you don’t re-run the full apply each time.

GitOps with Argo CD (optional)

../argocd/application.yaml is an Argo CD Application that syncs this k8s/ kustomization continuously (auto-sync, prune, self-heal) into the pixelflux namespace, with HTTPS (TLS route + redirect). The per-cluster hostname is set there via the kustomize patches.

The repo and the GHCR image are public, so no secrets are needed. The only settings are your domain and Let’s Encrypt email, in cluster/config.env (not in the manifests). One command installs Argo CD and applies everything:

cp ../cluster/config.env.example ../cluster/config.env   # edit DOMAIN + ACME_EMAIL
task k3s:up                                               # (local Docker: task k3d:up)

The bring-up script renders your DOMAIN into the Application’s host patches and applies traefik-acme.yaml (the cluster ACME resolver) with your ACME_EMAIL.

Caveats:

  • selfHeal + prune will revert/remove anything you change by hand. Don’t mix Argo CD with the manual task deploy* flow.
  • It deploys into the pixelflux namespace, so run task deploy:down first if you previously deployed manually, to avoid two copies.
  • Argo syncs manifests, not images — the GHCR image in the Application’s images: override must be published and pullable.

Requirements

  • A cluster with the Traefik IngressRoute CRDs (default on k3s).
  • For HTTPS: ports 80 and 443 reachable, and DNS pointing at the cluster.

Argo CD — GitOps delivery

application.yaml is an Argo CD Application that continuously syncs the k8s/ kustomization to the cluster, so every push to main is rolled out automatically.

What it does

  • Watches path: k8s on targetRevision: main of this repo.
  • Deploys into the pixelflux namespace (created on first sync).
  • automated sync with prune: true and selfHeal: true — the cluster is kept exactly in sync with git; manual changes are reverted, removed manifests are pruned.
  • Sets the per-cluster ingress host via a kustomize patch in spec.source.kustomize.patches — the shared k8s/ base stays domain-agnostic (pixelflux.example.com), the real host lives only here.

No secrets needed

Both the repo and the GHCR image are public, so Argo CD clones over anonymous HTTPS (repoURL: https://github.com/...) and the node pulls the public image — no deploy key and no pull secret. The images: override in application.yaml points the app at ghcr.io/vallsp/pixelflux, published by CI on every push to main.

Automatic image rollout (Argo CD Image Updater)

Argo CD syncs manifests, not images — so a new image under the same tag wouldn’t otherwise trigger a sync. Argo CD Image Updater (pinned v0.18.0, the last classic annotation-driven release) closes that loop. The bring-up script installs it into the argocd namespace; the tracking lives in annotations on application.yaml:

  • What it watches: ghcr.io/vallsp/pixelflux:latest (CI pushes :latest on every push to main).
  • Strategy digest: when the :latest digest changes, it pins the new digest into this Application’s kustomize image override; Argo then rolls the Deployment. No version bump, no re-apply.
  • Write-back argocd: it patches this Application via the Kubernetes API. Its install Role grants applications: get/list/update/patch, so it needs no Argo CD API token; the public GHCR package means no registry secret.

So the full loop is: push to main → CI builds & pushes :latest → Image Updater pins the new digest (polls ~every 2 min) → Argo rolls it out.

# Watch it work:
kubectl -n argocd logs deploy/argocd-image-updater -f
kubectl -n argocd get application pixelflux \
  -o jsonpath='{.spec.source.kustomize.images}{"\n"}'   # flips to a @sha256: ref

Heads-up: re-running task k3s:up/k3d:up re-applies application.yaml, which briefly resets the override to the bootstrap tag until the next poll re-pins the digest. To follow a versioned tag instead of :latest, switch the strategy to semver and bump Cargo.toml.

Configuration (no values hard-coded)

The per-cluster settings — your domain and the Let’s Encrypt email — live in cluster/config.env, never in the manifests. The committed files keep the pixelflux.example.com / admin@example.com placeholders; the bring-up script renders your real values in at apply time.

cp cluster/config.env.example cluster/config.env   # then edit DOMAIN + ACME_EMAIL

Usage

On a fresh k3s/k3d host, one command installs Argo CD and applies everything with your config:

task k3s:up        # VPS / single-node k3s   (local Docker: task k3d:up)

If Argo CD is already installed and you’d rather apply by hand, render the domain yourself (the script does this for you otherwise):

DOMAIN=your.domain.com
sed "s/pixelflux\.example\.com/$DOMAIN/g" argocd/application.yaml | kubectl apply -f -

From then on Argo CD reconciles automatically. Manage it with:

kubectl -n argocd get application pixelflux           # status
kubectl delete -f argocd/application.yaml             # tear down (finalizer cascades)
# pause auto-sync without deleting:
kubectl -n argocd patch application pixelflux --type merge -p '{"spec":{"syncPolicy":null}}'

HTTPS

The synced kustomization carries the HTTPS routing: the HTTP→HTTPS redirect and the TLS route, with the real host injected onto both routes by the Application patches. The cluster-level ACME (Let’s Encrypt) resolver lives in k8s/traefik-acme.yaml and is applied once by the bring-up script with your ACME_EMAIL — kept out of the GitOps sync so the email stays a config value, not committed YAML. The certificate is issued automatically on the first request (~1 min). DNS for the apex and www. must point at the cluster’s IP.

Caveats

  • Don’t mix with the manual task deploy* path. selfHeal/prune would revert or remove anything you apply by hand on top of what Argo manages.
  • Separate namespace. Argo CD deploys into pixelflux; if you previously ran task deploy (default namespace), run task deploy:down first to avoid two copies.
  • The image must exist. The GHCR image must be published (CI does this on each push to main) and the package set to public so the node can pull it and Image Updater can read its tags.
  • :latest is mutable. Image Updater pins a @sha256: digest at runtime, so the running image is deterministic — but the bootstrap images: tag in git is not. Don’t read the committed tag as “what’s deployed”; check the live Application (see the command above).

Testing & Load

Pixelflux is tested at several levels — unit tests (in-memory), integration tests against a real Redis via Testcontainers, API contract tests (Hurl + OpenAPI), and load/benchmark tests (k6):

LevelCommand
Unittask test
Integration (real Redis)task test:integration
API contracttask test:api
Load / benchmarktask bench

Load tests (k6)

health.js is a k6 load test for the HTTP server. It ramps virtual users up and down while reading the canvas and painting random pixels, and asserts latency and error-rate thresholds.

What it does

  • Stages: 0→20 VUs over 10s, 20→50 VUs over 20s, 50→0 over 10s.
  • Per iteration: GET /api/canvas, then POST /api/pixel with random x/y/color.
  • Thresholds (the test fails if breached):
    • http_req_failed < 1% (error rate)
    • http_req_duration p95 < 200 ms

Run it

task bench

The task builds the release binary, boots the server, runs k6 against it, and stops the server afterwards.

To run it against an already-running server (local or remote), set BASE_URL:

BASE_URL=http://localhost:3000 k6 run load/health.js
# or against the deployed instance:
BASE_URL=https://your.domain.com k6 run load/health.js

Tuning

Adjust the stages (load profile) and thresholds (pass/fail criteria) at the top of health.js. Keep the thresholds realistic for the environment you test against — a remote cluster will have higher latency than localhost.

Security Policy

Supported versions

Pixelflux is pre-1.0; only the latest main is supported. Fixes land on main.

Reporting a vulnerability

Please do not open a public issue for security problems. Instead, open a private report via GitHub (Security → Report a vulnerability) with the details and, if possible, a minimal reproduction.

We aim to acknowledge a report within a few days and to fix confirmed issues on main as soon as reasonably possible. Please give us a reasonable window to release a fix before any public disclosure.

What the project already does

Security is part of the pipeline, not an afterthought:

  • Secret scanninggitleaks runs in the pre-commit hook and in CI.
  • Container CVE scanningtrivy scans the image in CI and fails the build on HIGH/CRITICAL findings (task cve).
  • SBOM — a software bill of materials is generated with syft (task sbom).
  • Minimal attack surface — the container is distroless (no shell, no package manager), runs as a non-root user with a read-only root filesystem.

Contributing to Pixelflux

This guide covers everything you need to develop, test, and ship Pixelflux: the dev environment, the git workflow, and how to run each kind of test.

Table of contents

Prerequisites

  • Nix with flakes enabled
  • A container runtime (Docker or Podman) — needed for integration tests and the container image

Nothing else has to be installed: the Nix dev shell provides Rust, the task runner, all linters, the security scanners, and the test tooling at pinned versions.

Getting started

git clone git@github.com:Vallsp/PixelFlux.git
cd PixelFlux

nix develop            # enter the reproducible dev shell (or: direnv allow)
task lock              # generate Cargo.lock (first time only)
task hooks:install     # install the git hooks
task run               # run the server -> http://localhost:3000

Run task with no arguments at any time to list every available task.

Git workflow

Branches

  • main is always releasable; CI runs on every push and pull request.
  • Do your work on a short-lived branch named after the change, e.g. feat/eraser-tool, fix/redis-reconnect, docs/contributing.
  • Keep branches small and focused; rebase on main before opening a PR.
git switch -c feat/eraser-tool
# ...work...
git push -u origin feat/eraser-tool

Commit messages

We follow Conventional Commits. The commit-msg hook rejects any message that doesn’t match. Format:

<type>(<optional scope>): <description>

Allowed types:

TypeWhen to use it
featA new feature
fixA bug fix
docsDocumentation only
styleFormatting, no code change
refactorCode change that neither fixes a bug nor adds a feature
perfA performance improvement
testAdding or fixing tests
buildBuild system or dependencies
ciCI configuration
choreMaintenance, tooling
revertReverting a previous commit

Examples:

feat: add an eraser tool to the canvas
fix(redis): reconnect the pub/sub subscriber on drop
ci: implement sbom task with Syft

Git hooks

Installed with task hooks:install (managed by lefthook):

HookWhat runs
pre-commitformat staged files (treefmt), clippy -D warnings, secret scan on staged changes (gitleaks)
commit-msgenforce Conventional Commits
pre-pushunit tests + a release build

To bypass them in an emergency: git commit --no-verify (avoid on shared branches — CI will still enforce the same checks).

Pull requests

  1. Make sure the full gate passes locally: task check (lint + secrets + tests).
  2. Push your branch and open a PR against main.
  3. CI must be green before merging (it runs the same checks plus the container build and CVE scan).

Everyday commands

task run                # run the server on :3000
task build              # debug build
task fmt                # auto-format every file type (treefmt)
task check              # full local gate: lint + secrets + tests

For the shared, persisted canvas (mirrors production), run Redis alongside:

docker run -d -p 6379:6379 redis:7-alpine
REDIS_URL=redis://localhost:6379 task run

Running the tests

There are four levels of tests. Unit tests run anywhere; the others need a container runtime and/or a running server.

LevelCommandWhat it doesNeeds
Unittask testCanvas logic and routes exercised in-memorynothing
Integrationtask test:integrationA pixel painted via one instance is read back from a real Redis (Testcontainers)Docker/Podman
API contracttask test:apiBoots the server, validates it against api/openapi.yaml with Hurl
Load / benchmarktask benchk6 reads the canvas and paints random pixels under load (p95 < 200 ms, < 1% errors)

task test:api and task bench start the server themselves and stop it when they finish, so you don’t need a server running beforehand.

Quality, formatting & security

task fmt                # format Rust, TOML, Nix, shell, Markdown, YAML, JSON (treefmt)
task lint               # treefmt --fail-on-change + clippy + yamllint + actionlint + markdownlint
task secrets            # scan the whole repo for leaked credentials (gitleaks)

task lint is what CI runs; task fmt fixes most of what it would flag.

Building the container

The image is built entirely by Nix — a distroless static binary, non-root, under 20 MB.

task container          # build the image with Nix
task container:load     # build and load it into Docker
task container:size     # print the size and fail if it exceeds 20 MB
task container:inspect  # explore the layers with dive
task sbom               # generate an SBOM (Syft) -> sbom.json
task cve                # scan the image for CVEs (Trivy), fails on HIGH/CRITICAL

Deploying

Kubernetes + Traefik on a single-node k3s host. See the Deploy section of the README for the full flow; in short:

task deploy:k3s-install                  # once: install k3s + Traefik
task deploy                              # build, import, apply the app
DOMAIN=your.domain.com task deploy:ingress              # expose over HTTP
# or HTTPS (Let's Encrypt):
DOMAIN=your.domain.com ACME_EMAIL=you@domain.com task deploy:tls

deploy:ingress (HTTP) and deploy:tls (HTTPS) define the same route, so the last one applied wins — don’t mix them. After enabling HTTPS, use task deploy:restart (not task deploy) for code changes so the TLS route is preserved.

Continuous integration

.github/workflows/ci.yml runs three jobs, all inside nix develop so CI and local development share the same toolchain:

JobChecks
qualitylint, format check, secret scan
testbuild, unit tests, integration tests (Testcontainers)
containerbuild the distroless image, enforce < 20 MB, SBOM, Trivy CVE scan

The build fails if any check fails, so the server-side pipeline catches issues even when local git hooks are skipped.

AGENTS.md

Instructions for AI agents and new contributors working on Pixelflux. CLAUDE.md points here, so the same guidance is picked up by Claude Code and other agents.

What this is

A collaborative 200×200 pixel canvas (Rust / axum), with real-time updates over SSE + Redis pub/sub, wrapped in a full SDLC pipeline (Nix dev shell, distroless container, multi-level tests, security scans, CI, and a Kubernetes deployment).

Setup

nix develop            # reproducible dev shell — provides every tool
task lock              # generate Cargo.lock (first time only)
task hooks:install     # install the git hooks

Commands — your toolbox

Run task with no arguments to list everything. The ones you’ll use most:

GoalCommand
Run the server (:3000)task run
Buildtask build
Format everythingtask fmt
Lint (code + config)task lint
Check docs (prose + links)task docs:lint
Build the docs site (mdBook)task docs:build
Serve the docs locallytask docs:serve
Unit teststask test
Integration tests (real Redis)task test:integration
API contract teststask test:api
Load testtask bench
Secret scantask secrets
Build containertask container
Image size guard (< 20 MB)task container:size
SBOM / CVE scantask sbom / task cve
Deploy (k3s + Traefik)task deploy, task deploy:tls
Full local gatetask check

Conventions

  • Commits: Conventional Commits, enforced by the commit-msg hook (feat, fix, docs, ci, …).
  • Hooks: pre-commit formats and lints staged files and scans for secrets; don’t bypass with --no-verify on shared branches.
  • Branches: short-lived, named after the change (feat/…, fix/…); rebase on main before a PR.
  • CI must be green before merging.

Where things are

PathWhat
src/Rust app (lib.rs = canvas + routes, main.rs, index.html = embedded UI, admin.html = admin dashboard)
api/OpenAPI spec + Hurl contract tests
load/k6 load test
k8s/Kubernetes manifests + Traefik
argocd/Argo CD GitOps Application
docs/ADRs (docs/adr/) + the mdBook documentation site (docs/book/)
flake.nixDev shell + container build
Taskfile.ymlEvery task

Before you finish a change

  1. task fmt then task check (lint + secrets + tests).
  2. task docs:lint if you touched documentation.
  3. Commit with a Conventional Commit message; push; make sure CI is green.

Going further with real agent skills

This file documents the task commands as the agent toolbox. If you later want versioned, executable Claude/agent skills (e.g. a deploy or review skill), add them under .claude/skills/ and reference them here.

Architecture Decision Records

Significant architecture decisions are recorded as lightweight ADRs — each captures the context, the decision, and its consequences, so newcomers understand why things are the way they are. To change a decision, a new ADR supersedes the old one rather than rewriting history.

#DecisionStatus
0001Use Nix for the dev shell and the container buildAccepted
0002Ship a distroless, non-root, static containerAccepted
0003Back the canvas with Redis and pub/subAccepted
0004Use Server-Sent Events instead of WebSocketsAccepted
0005Deploy on Kubernetes behind TraefikAccepted
0006Publish documentation with mdBookAccepted

0001. Use Nix for the dev shell and the container build

  • Status: Accepted
  • Date: 2026-06-18

Context

A new contributor must be able to clone the repo and build it with nothing installed but a couple of base tools, and the build must be reproducible across machines and CI. We also need the toolchain used in CI to be identical to the one used locally, to avoid “works on my machine” drift across the many tools the project relies on (Rust, the task runner, linters, security scanners, k6, Hurl…).

Decision

Use a Nix flake as the single source of truth for the environment:

  • nix develop provides every tool at pinned versions (the dev shell).
  • The container image is built by Nix (dockerTools) from the same flake.
  • CI runs all steps inside nix develop, so CI and local use the same toolchain.

Consequences

Positive

  • One command (nix develop) to a complete, reproducible environment.
  • CI/local parity; no per-tool installation instructions to maintain.
  • The flake lockfile pins everything, making builds reproducible.

Trade-offs / negative

  • Contributors must install Nix and enable flakes.
  • Nix has a learning curve; first evaluation downloads a lot.

Alternatives considered

  • mise / asdf — simpler, but pins tool versions only, not a hermetic environment, and doesn’t build the container.
  • Plain Dockerfile + docs — easy to start, but drifts and isn’t reproducible the way a flake lock is.

0002. Ship a distroless, non-root, static container

  • Status: Accepted
  • Date: 2026-06-18

Context

The deliverable must be a small, hardened container image with a near-zero CVE count. A typical base image (Debian/Alpine) carries a shell, a package manager, and libraries that add attack surface and CVEs we don’t control.

Decision

Build the binary as a fully static musl executable and package it in a distroless image (built by Nix dockerTools) that contains only the binary closure:

  • No shell, no package manager, no libc layered on top.
  • Runs as a non-root user (uid 65532) with a read-only root filesystem.
  • A CI gate fails the build if the image exceeds 20 MB (task container:size), and Trivy scans it for CVEs.

Consequences

Positive

  • Minimal attack surface and essentially nothing for CVE scanners to flag.
  • Tiny image (low single-digit MB), fast to pull and deploy.
  • Hardened runtime (non-root, read-only FS) by default.

Trade-offs / negative

  • No shell in the image, so kubectl exec debugging needs an ephemeral debug container instead.
  • Static musl builds can be trickier for crates needing system libraries (not an issue for this app’s dependencies).

Alternatives considered

  • gcr.io/distroless base — good, but a Nix-built image is reproducible and even more minimal.
  • Alpine — small, but still ships a shell/package manager and musl as a system library.

0003. Back the canvas with Redis and pub/sub

  • Status: Accepted
  • Date: 2026-06-19

Context

The canvas is shared: every visitor draws on the same grid, and the app runs as several replicas behind a load balancer. State therefore cannot live in a single process’s memory, and a pixel painted on one replica must reach users connected to any other replica in real time.

Decision

Store the canvas in Redis as a single width*height string, mutated with SETRANGE and read with GET. Propagate live updates with Redis pub/sub: each painted pixel is PUBLISHed, and every replica subscribes and fans the event out to its own connected browsers. If no REDIS_URL is configured, the app falls back to an in-memory canvas and an in-process broadcast (single instance).

Consequences

Positive

  • Shared, consistent state across all replicas; horizontal scaling just works.
  • Real-time updates reach users on any replica, no sticky sessions needed.
  • Graceful local development with zero dependencies (in-memory fallback).

Trade-offs / negative

  • Redis is an additional component to run and operate.
  • The current Redis deployment is ephemeral (no persistence); a restart clears the canvas. Acceptable for now; a StatefulSet + PVC would fix it.

Alternatives considered

  • In-memory only — simplest, but breaks as soon as there is more than one replica.
  • A SQL database — overkill for a single mutable blob and a fan-out channel.

0004. Use Server-Sent Events instead of WebSockets

  • Status: Accepted
  • Date: 2026-06-19

Context

Browsers need to receive live pixel updates. The traffic is almost entirely one-way (server → browser): the only client→server action is painting a pixel, which is a plain POST. We want the lightest mechanism that works well through a reverse proxy / load balancer.

Decision

Use Server-Sent Events (GET /api/events) for the live stream, and a normal POST /api/pixel for writes. SSE is built into axum, needs no extra dependency, auto-reconnects in the browser (EventSource), and streams cleanly through Traefik. The browser also does a periodic full resync as a safety net.

Consequences

Positive

  • No heavy WebSocket dependency; smaller binary and simpler code.
  • Native browser reconnection; plays well with HTTP proxies and load balancers.
  • A good fit for the one-way, broadcast-style update pattern.

Trade-offs / negative

  • SSE is one-directional; a future need for low-latency client→server streaming would require revisiting this (e.g. WebSockets).
  • Long-lived connections require the proxy to allow streaming (Traefik does by default).

Alternatives considered

  • WebSockets — bidirectional and powerful, but unnecessary here and adds a dependency and more moving parts.
  • Polling — simplest, but higher latency and wasteful; kept only as the resync safety net.

0005. Deploy on Kubernetes behind Traefik

  • Status: Accepted
  • Date: 2026-06-22

Context

The app must run as several load-balanced replicas, scale with load, and be reachable over HTTPS on a domain. We target a single, modest VPS for the demo but want a setup that also works on a real cluster.

Decision

Deploy on Kubernetes, using k3s on the VPS (it bundles Traefik). Traefik is the ingress / load balancer that round-robins requests across the app pods via a Service; a HorizontalPodAutoscaler adds replicas under load. HTTPS is issued automatically by Traefik’s ACME (Let’s Encrypt) resolver. Manifests live in k8s/, and an Argo CD Application is provided for GitOps delivery.

Consequences

Positive

  • Real load balancing, autoscaling, self-healing, and rolling updates for free.
  • Same manifests work on k3s and on a full cluster.
  • Automatic, renewing TLS certificates.

Trade-offs / negative

  • Kubernetes adds operational complexity for what is a small app.
  • The Argo CD path currently manages only the HTTP route; HTTPS is applied out of band (see k8s/README.md). Unifying them is future work.

Alternatives considered

  • Docker Compose — simpler, but no autoscaling/self-healing and weaker as a learning target for the workshop.
  • A single binary behind a reverse proxy — fine for one instance, but doesn’t demonstrate load balancing across replicas.

0006. Publish documentation with mdBook

  • Status: Accepted
  • Date: 2026-06-23

Context

The project’s documentation is good but scattered: the README.md, these ADRs, CONTRIBUTING.md, AGENTS.md, SECURITY.md, CHANGELOG.md, and a per-directory README.md under api/, k8s/, load/, and argocd/. There is no single navigable, searchable entry point, and the README’s many diagrams are only rendered by GitHub. We want a documentation site that matches the rest of the pipeline: reproducible (built from the Nix dev shell), automated (built and published by CI), and with no content duplication — the Markdown files stay the single source of truth.

Decision

Build the docs with mdBook, a self-contained book project under docs/book/. Chapter files are thin stubs that pull in the existing Markdown with mdBook’s {{#include}} directive (sections of the README are sliced with invisible <!-- ANCHOR --> comments; self-contained files such as ADRs are included whole), so editing a source document updates the book. The mdbook-mermaid preprocessor renders the Mermaid diagrams. mdbook and mdbook-mermaid are added to the Nix dev shell; task docs:build / task docs:serve drive it; CI builds the book on every pull request and deploys it to GitHub Pages on pushes to main.

Consequences

Positive

  • One searchable, themed site covering every document, with rendered diagrams.
  • No duplication: the Markdown files remain canonical and the book never drifts.
  • Same ergonomics as the rest of the project — a Nix tool, a task, a CI job.

Trade-offs / negative

  • {{#include}} does not rewrite repository-relative links, so a few cross-file links inside included content can resolve differently in the book than on GitHub.
  • GitHub Pages from a private repository requires a paid GitHub plan; until the repo is public or the plan is upgraded, only the build/validation step runs (the site is still fully usable locally via task docs:serve).

Alternatives considered

  • A static site generator (Docusaurus / MkDocs) — heavier, pulls in a Node/Python toolchain that the project otherwise avoids, and is overkill for a handful of Markdown files.
  • Leave the docs as scattered Markdown — simplest, but no unified navigation, search, or rendered diagrams.

Changelog

All notable changes to this project are documented here.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

Added

  • Collaborative 64×64 pixel canvas (Rust / axum) with an embedded web UI.
  • Real-time updates over Server-Sent Events, shared across instances with Redis pub/sub; in-memory fallback when no REDIS_URL is set.
  • HTTP API: /health, /info (with serving instance), /api/canvas, /api/pixel, /api/events.
  • Reproducible Nix dev shell and a distroless, non-root container (< 20 MB).
  • Task runner (go-task) exposing build, test, lint, security, container, and deploy actions.
  • Multi-level tests: unit, integration (Testcontainers + real Redis), API contract (Hurl + OpenAPI), and load (k6).
  • Supply-chain security: gitleaks (secrets), Syft (SBOM), Trivy (CVE scan).
  • Git hooks (lefthook) and Conventional Commits enforcement.
  • CI (GitHub Actions): quality, tests, and container build + scan; image published to GHCR on push to main.
  • Kubernetes deployment: Traefik load balancing, 3 replicas, HPA, and automatic HTTPS via Let’s Encrypt; Argo CD Application for GitOps.
  • Admin dashboard at /admin (enabled by ADMIN_PASSWORD): runtime-tunable limits (rate limit/window — with an on/off switch, registration delay, token TTL, presence timings, and the SSE coalescing window for server fan-out), an editable canvas size (8–512 px per side, which resets the canvas), read-only maintenance mode (with a custom banner message), canvas reset, an editable preset colour palette (optionally hide the colour picker or enforce the palette server-side so off-palette colours are rejected by the API), a site-wide announcement banner, an open/close registration switch, and live stats. Settings persist in Redis and propagate to every replica via a config:events pub/sub channel. Auth uses a constant-time password check and an HttpOnly, SameSite=Strict session cookie.
  • Players & leaderboard: each visitor registers a unique pseudo that is bound to their token server-side, so the leaderboard credit is derived from the token (not the paint request) and can’t be spoofed. POST /register takes the pseudo (409 if taken); GET /api/leaderboard returns the top-10, also pushed live as a named leaderboard SSE event. Admins can remove a player (revokes the token, frees the pseudo, drops them from the leaderboard) and revoke legacy tokens that carry no bound pseudo.
  • Territory / ownership: each pixel remembers its current owner, so GET /api/ownership reports how many pixels each player owns and their % share of the canvas. Ownership transfers when a pixel is overwritten (unlike the cumulative leaderboard) and resets with the canvas.
  • Documentation: README with diagrams, CONTRIBUTING, per-directory READMEs, AGENTS.md, and Architecture Decision Records.