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/>/ · /admin · /health · /info<br/>/api/canvas · /api/pixel · /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 / · GET /api/canvas| R
UI -->|POST /api/pixel| R
R -->|SSE stream| ES
ST <-->|GET / SETRANGE| K
ST <-->|PUBLISH / SUBSCRIBE| PS
API Reference
API
| Method | Route | Description |
|---|---|---|
| GET | / | Web UI |
| GET | /health | Liveness probe |
| GET | /info | Name, version, and serving instance |
| GET | /api/canvas | Whole canvas |
| POST | /register | Claim a unique pseudo → token bound to it |
| POST | /api/pixel | Paint a pixel {x, y, color} |
| GET | /api/events | Live pixel stream (SSE) |
| GET | /api/leaderboard | Top-10 players by pixels painted |
| GET | /api/ownership | Territory % — pixels each player owns now |
| GET | /admin | Admin 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
| Method | Route | Description |
|---|---|---|
| GET | / | Web UI (embedded single page) |
| GET | /health | Liveness 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/canvas | Whole canvas → {"width", "height", "palette", "pixels"} (pixels is a width*height*6 hex string — an rrggbb colour per cell) |
| POST | /register | Register 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/pixel | Paint 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/events | Live pixel stream (SSE); coalesced batches [{"x","y","color"}, …] + a named leaderboard event (top-10) |
| GET | /api/leaderboard | Top-10 players by pixels painted (cumulative) → [{"name","count"}, …] |
| GET | /api/ownership | Territory — pixels each player currently owns → {"total", "entries":[{"name","count","percent"}]} (transfers when a pixel is overwritten) |
| GET | /admin | Admin 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 · :443 HTTPS"]
S["Service: pixelflux<br/>ClusterIP :80 → :3000"]
subgraph D["Deployment: pixelflux"]
P1["Pod 1"]
P2["Pod 2"]
P3["Pod 3"]
end
H["HorizontalPodAutoscaler<br/>3 → 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.
Option A — GitOps with Argo CD (recommended)
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
| File | Kind(s) | Purpose |
|---|---|---|
redis.yaml | Deployment, Service | Redis for shared canvas state and pub/sub fan-out. Ephemeral (no PVC). |
pixelflux.yaml | Deployment, Service | The app: 3 replicas, non-root (uid 65532), read-only root FS, /health probes. Service exposes port 80 → 3000. |
hpa.yaml | HorizontalPodAutoscaler, PodDisruptionBudget | Autoscale 3→10 at 70% CPU; keep ≥2 fronts available during disruptions. |
ingressroute-tls.yaml | Middleware, IngressRoute ×2 | HTTPS route: HTTP→HTTPS redirect + TLS route with a Let’s Encrypt cert. Host is a placeholder. In the kustomization. |
traefik-acme.yaml | HelmChartConfig | Configures 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.yaml | IngressRoute | Plain HTTP route (Traefik web entrypoint). Not in the kustomization — kept for an HTTP-only setup (task deploy:ingress). |
kustomization.yaml | Kustomization | Bundles 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.yamlusesimage: pixelflux:latest(imagePullPolicy: IfNotPresent), so it must already be on the node.task deploy:imagebuilds it with Nix and imports it into k3s. For a remote/multi-node cluster, push to a registry (e.g. GHCR) and pointimage:there withimagePullPolicy: 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+prunewill revert/remove anything you change by hand. Don’t mix Argo CD with the manualtask deploy*flow.- It deploys into the
pixelfluxnamespace, so runtask deploy:downfirst 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: k8sontargetRevision: mainof this repo. - Deploys into the
pixelfluxnamespace (created on first sync). automatedsync withprune: trueandselfHeal: 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 sharedk8s/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:lateston every push tomain). - Strategy
digest: when the:latestdigest 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 grantsapplications: 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:upre-appliesapplication.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 tosemverand bumpCargo.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/prunewould revert or remove anything you apply by hand on top of what Argo manages. - Separate namespace. Argo CD deploys into
pixelflux; if you previously rantask deploy(default namespace), runtask deploy:downfirst 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. :latestis mutable. Image Updater pins a@sha256:digest at runtime, so the running image is deterministic — but the bootstrapimages: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):
| Level | Command |
|---|---|
| Unit | task test |
| Integration (real Redis) | task test:integration |
| API contract | task test:api |
| Load / benchmark | task 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, thenPOST /api/pixelwith randomx/y/color. - Thresholds (the test fails if breached):
http_req_failed< 1% (error rate)http_req_durationp95 < 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 scanning —
gitleaksruns in the pre-commit hook and in CI. - Container CVE scanning —
trivyscans 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
- Getting started
- Git workflow
- Everyday commands
- Running the tests
- Quality, formatting & security
- Building the container
- Deploying
- Continuous integration
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
mainis 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
mainbefore 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:
| Type | When to use it |
|---|---|
feat | A new feature |
fix | A bug fix |
docs | Documentation only |
style | Formatting, no code change |
refactor | Code change that neither fixes a bug nor adds a feature |
perf | A performance improvement |
test | Adding or fixing tests |
build | Build system or dependencies |
ci | CI configuration |
chore | Maintenance, tooling |
revert | Reverting 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):
| Hook | What runs |
|---|---|
pre-commit | format staged files (treefmt), clippy -D warnings, secret scan on staged changes (gitleaks) |
commit-msg | enforce Conventional Commits |
pre-push | unit 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
- Make sure the full gate passes locally:
task check(lint + secrets + tests). - Push your branch and open a PR against
main. - 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.
| Level | Command | What it does | Needs |
|---|---|---|---|
| Unit | task test | Canvas logic and routes exercised in-memory | nothing |
| Integration | task test:integration | A pixel painted via one instance is read back from a real Redis (Testcontainers) | Docker/Podman |
| API contract | task test:api | Boots the server, validates it against api/openapi.yaml with Hurl | — |
| Load / benchmark | task bench | k6 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) anddeploy:tls(HTTPS) define the same route, so the last one applied wins — don’t mix them. After enabling HTTPS, usetask deploy:restart(nottask 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:
| Job | Checks |
|---|---|
quality | lint, format check, secret scan |
test | build, unit tests, integration tests (Testcontainers) |
container | build 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:
| Goal | Command |
|---|---|
Run the server (:3000) | task run |
| Build | task build |
| Format everything | task fmt |
| Lint (code + config) | task lint |
| Check docs (prose + links) | task docs:lint |
| Build the docs site (mdBook) | task docs:build |
| Serve the docs locally | task docs:serve |
| Unit tests | task test |
| Integration tests (real Redis) | task test:integration |
| API contract tests | task test:api |
| Load test | task bench |
| Secret scan | task secrets |
| Build container | task container |
| Image size guard (< 20 MB) | task container:size |
| SBOM / CVE scan | task sbom / task cve |
| Deploy (k3s + Traefik) | task deploy, task deploy:tls |
| Full local gate | task check |
Conventions
- Commits: Conventional Commits,
enforced by the
commit-msghook (feat,fix,docs,ci, …). - Hooks:
pre-commitformats and lints staged files and scans for secrets; don’t bypass with--no-verifyon shared branches. - Branches: short-lived, named after the change (
feat/…,fix/…); rebase onmainbefore a PR. - CI must be green before merging.
Where things are
| Path | What |
|---|---|
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.nix | Dev shell + container build |
Taskfile.yml | Every task |
Before you finish a change
task fmtthentask check(lint + secrets + tests).task docs:lintif you touched documentation.- 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.
| # | Decision | Status |
|---|---|---|
| 0001 | Use Nix for the dev shell and the container build | Accepted |
| 0002 | Ship a distroless, non-root, static container | Accepted |
| 0003 | Back the canvas with Redis and pub/sub | Accepted |
| 0004 | Use Server-Sent Events instead of WebSockets | Accepted |
| 0005 | Deploy on Kubernetes behind Traefik | Accepted |
| 0006 | Publish documentation with mdBook | Accepted |
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 developprovides 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 execdebugging 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/distrolessbase — 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_URLis 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 byADMIN_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 aconfig:eventspub/sub channel. Auth uses a constant-time password check and anHttpOnly,SameSite=Strictsession 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 /registertakes the pseudo (409 if taken);GET /api/leaderboardreturns the top-10, also pushed live as a namedleaderboardSSE 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/ownershipreports 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.