110 KiB
Application Operations
Adding Apps
Add Kubernetes manifests under apps/<name> and register them in
bootstrap/apps's applications map. Argo CD will own sync, pruning, and
self-healing for the app.
The heimdall Argo CD app name is retained as a small route bundle for
Grafana, Prometheus, Alertmanager, and Argo CD ingresses so the next app sync can
prune the old in-cluster Heimdall workload cleanly. Do not add new Kubernetes
workloads there. Use ./jeannie deploy-heimdall for the dashboard
itself.
The n8n app runs in the n8n namespace with retained OpenEBS storage and is
exposed at https://n8n.lab2025.duckdns.org. It receives
OLLAMA_BASE_URL=http://192.168.100.73:11434 so workflows can call the existing
Debian-host Ollama API for translation prewarming and batch jobs.
The ARR/media stack is intentionally not deployed by the Kubernetes app
pipeline. It is better suited to a Docker Compose stack on the Debian host with
persistent volumes under /data, where large media paths, qBittorrent writes,
and service config directories can survive cluster rebuilds without OpenEBS
node-affinity or PVC migration concerns. The active host-level Compose
definition lives under infra/arr-stack and stores persistent data under
/data/arr. Historical Kubernetes manifests are archived under
archive/apps/arr-stack for migration reference only.
Storage
OpenEBS provides the platform storage provisioner. Stateful Kubernetes apps use
retained local PV paths such as /data/openebs/local/registry; these paths are
intentionally outside kubeadm reset paths so data can survive cluster
destroy/create cycles. Those critical volumes are declared explicitly as
retained local PVs so a rebuilt cluster binds back to the same host paths
instead of creating fresh directories.
For the current lab, the HP Debian laptop's root filesystem is on NVMe, so the
standard Docker root /var/lib/docker is acceptable. OpenEBS retained hostpath
data still lives under /data/openebs/local, and larger service data such as
Gitea, Ollama models, and app volumes should stay under /data.
Gitea
Gitea is external bootstrap infrastructure. It runs on the Debian host as an
always-on Docker Compose service from infra/gitea/docker-compose.yml, not as a
Kubernetes workload. This keeps Git available when the Kubernetes cluster is
destroyed and rebuilt.
The default data path is /data/homelab-gitea/data on the HP laptop NVMe.
Docker may use the standard /var/lib/docker root because / is also on NVMe.
Public source browsing stays available through
https://lab2025.duckdns.org/git/. Registration is disabled and anonymous users
can view public repositories, so the blog can link to code read-only while
writes still require an authenticated Gitea account.
Heimdall
Heimdall is external bootstrap infrastructure. It runs on the Debian host as an
always-on Docker Compose service from infra/heimdall/docker-compose.yml, not
as a Kubernetes workload. This keeps the lab navigation page available when the
cluster is destroyed, rebuilding, or unhealthy.
Deploy or refresh it from the Debian server with:
./jeannie deploy-heimdall
./jeannie up also runs this step during the independent host
services stage unless LAB_HEIMDALL_DEPLOY=false is set. The default data path
is /data/homelab-heimdall/data/config, and the local HTTP port is 8082.
Dashboard links are seeded from infra/heimdall/links.json by a sidecar
container that upserts repo-managed tiles without deleting manually added ones.
The Debian bare repo remains the GitOps mirror:
/home/jv/git-server/my-homelab-configs.git
Argo CD consumes that Debian mirror through the default gitops_repo_url.
Gitea Actions pushes the main commit into the mirror before running the
selected deploy command.
The platform bootstrap registers the Argo CD repository secret and the SSH host
key for the Debian GitOps mirror. If Argo CD reports
knownhosts: key is unknown after the Debian host was rebuilt or its SSH host
key changed, refresh argocd-ssh-known-hosts-cm in the argocd namespace,
restart argocd-repo-server, and hard-refresh the affected Application.
Deploy or refresh the external Gitea container from the Debian host with:
./jeannie deploy-gitea
Gitea Backups
./jeannie up installs a Debian-host systemd timer named
homelab-gitea-backup.timer. The timer runs daily, SSHes to the configured
Gitea host, executes gitea dump inside the Gitea Docker container, copies the
dump back to Debian, and stores it under /home/jv/backups/gitea. The default
retention is 30 days.
The same install step also creates homelab-gitea-restore-drill.timer. The
monthly drill is non-destructive: it verifies the latest backup ZIP, extracts it
to a temporary directory, records a report under
/home/jv/backups/gitea-restore-drills, and removes the temporary extract. It
does not write into the live Gitea data directory.
Run a manual backup from the Debian server with:
./jeannie backup-gitea
Run the restore drill manually with:
./jeannie drill-gitea-restore
./jeannie drill-restore
./jeannie drill-pihole-restore
drill-restore validates the latest Gitea backup archive, Pi-hole repo-managed
config inputs, the latest OpenTofu state backup archive when present, and the
GitOps rebuild path. It does not replace running services or mutate Kubernetes.
Useful checks:
./jeannie backup-status
systemctl list-timers homelab-gitea-backup.timer
systemctl list-timers homelab-gitea-restore-drill.timer
sudo systemctl start homelab-gitea-backup.service
ls -lh /home/jv/backups/gitea
ls -lh /home/jv/backups/gitea-restore-drills
Gitea Actions
This repo includes a Gitea Actions workflow at
.gitea/workflows/homelab-main.yml. It runs validation on pushes to dev and
main, and deploys only from main. That keeps the promotion path simple:
dev -> ./jeannie validate in Gitea Actions -> main -> Argo CD sync
The workflow targets a repository-scoped Debian host runner with the label
homelab-debian.
Run the same validation locally before promoting:
./jeannie validate
Identity and access audit
Run a read-only access inventory from the Debian server with:
./jeannie access-audit
The audit reports configured SSH targets and key file modes, Git/Gitea remotes, Tailscale ACL policy validation, current Kubernetes authorization, broad cluster-admin bindings, automation service accounts, and Gitea runner status. It does not create users, tokens, kubeconfigs, or keys. Use it to move toward separate identities:
- admin kubeconfig only on the Debian control host
- read-only kubeconfig for dashboards and audits
- separate service accounts for automation
- explicit SSH inventory for Debian, RPi, Pimox, and OCI edge
- repo-managed Tailscale ACLs
The read-only Kubernetes identity is managed in apps/access-control. After
that app syncs, generate a separate read-only kubeconfig on the Debian host:
./jeannie kubeconfig-readonly
The workflow only blocks automatic deploy for external Gitea service
changes: files under infra/gitea/, or edits inside the deploy_gitea,
install_gitea_backup_timer, backup_gitea, or drill_gitea_restore
functions in jeannie. Other changes use HOMELAB_ACTION_COMMAND=auto by
default: Actions runs ./jeannie doctor-versions when the Debian runner already
has a cluster kubeconfig. If node versions are aligned it runs ./jeannie apps;
if kubelet minor drift is detected it runs ./jeannie rebuild-cluster; if no
kubeconfig exists it also runs ./jeannie rebuild-cluster.
Set HOMELAB_ACTION_COMMAND=apps or HOMELAB_ACTION_COMMAND=rebuild-cluster
on the runner to force one path.
./jeannie bootstrap-gitea-repo also registers the Debian host SSH public key
with the Gitea repository and switches the Debian working copy's gitea remote
to ssh://git@192.168.100.73:32222/jv/my-homelab-configs.git. The default key
is /home/jv/.ssh/id_ed25519.pub; set LAB_GITEA_REPO_SSH_KEY_PATH to use a
different Debian-host key, or LAB_GITEA_REPO_SSH_BOOTSTRAP=false to leave SSH
access unchanged. The Actions deploy job uses the checked-out Actions workspace
as the source commit, updates the first available persistent checkout from
HOMELAB_DEPLOY_DIR, /home/jv/my-homelab-configs, or
/home/jv/repos/my-homelab-configs, and otherwise deploys directly from the
Actions workspace. It does not need SSH read access back to Gitea.
Enable Actions for the repository in Gitea, then create a repository-level runner token from:
https://lab2025.duckdns.org/git/jv/my-homelab-configs/settings/actions/runners
Register and start the Debian runner from the Debian server:
cd ~/my-homelab-configs
GITEA_RUNNER_REGISTRATION_TOKEN='<repo-runner-token>' ./jeannie install-gitea-runner
The runner is installed as homelab-gitea-runner.service, runs as user jv, and
uses a host label instead of a Docker job container because deployment needs the
Debian host's Docker, OpenTofu, kubeconfig, SSH keys, and local state.
The deployment job is non-interactive. User jv must be able to run sudo -n true on the Debian host for deployment commands that require sudo.
Useful checks:
systemctl status homelab-gitea-runner.service
journalctl -u homelab-gitea-runner.service -n 100 --no-pager
Renovate
renovate.json defines dependency update rules for Dockerfiles, OpenTofu
providers, Helm chart versions, and the pinned tools used by the Gitea Actions
workflow. Renovate should open reviewable update branches or PRs only; it must
not auto-merge infrastructure changes. Keep app-only dependency updates on the
normal Gitea Actions path, and run ./jeannie up manually on the Debian server
for platform or provisioning updates.
Destructive Rebuilds
./jeannie nuke resets kubeadm, containerd runtime state, CNI files, Calico
links, iptables rules, and local OpenTofu state. It does not delete retained data
under /data/openebs/local.
For multi-node labs, set WORKER_SSH_TARGETS to a space-separated list of SSH
targets. It defaults to an empty string so worker nodes are not cleaned unless
you explicitly include them.
Website App
The website is a PHP app under apps/website. It includes a home page, CV page,
blog page, and demos page, plus a lightweight translation flow backed by Redis,
n8n, and Ollama.
Static language files live in apps/website/lang; en.php and nah.php are
curated source files, with the Nahuatl home page intentionally biased toward as
many Nahuatl words as possible while keeping technical terms understandable.
Unsupported browser languages use the same-origin /translate.php endpoint,
which calls Ollama server-side through OLLAMA_HOST and OLLAMA_MODEL; the
browser never calls the private Ollama IP directly. Redis caches per-string
translation results in the website-production namespace, while n8n is
available for translation prewarming and batch workflow jobs. The default model
is the custom website-translator Ollama model defined in
apps/website/ollama/Modelfile. Generated runtime language JSON is saved
through save_lang.php on the website PVC, and translate.php emits structured
logs for cache hits, misses, Ollama latency, JSON parse failures, and timeouts.
Create or refresh the Ollama model on the Debian server before deploying a website image that points at it:
./jeannie website-translation-model
Ollama must also listen on the Debian host LAN address so Kubernetes pods on
other nodes can reach OLLAMA_HOST=http://192.168.100.73:11434:
./jeannie website-ollama-listen
The Debian host bootstrap also manages Ollama when debian_pc_install_ollama
is true: it installs the service, stores models under /data/ollama/models,
binds the API to the configured LAN listener, and pulls the lightweight
ai_gateway.model. On an existing host, apply just this setup with:
./jeannie ollama-setup
jeannie also has a backstage local AI helper for doctor commands.
It is not a separate CLI action; when ai_gateway.enabled is true in
homelab.yml, doctor commands try the configured Ollama model and silently
skip the helper if Ollama is down. The default lightweight model is:
ollama pull qwen2.5:0.5b
Disable it for low-CPU sessions with:
LAB_BACKSTAGE_BRAIN_ENABLED=false ./jeannie doctor-edge
Build the local homelab knowledge index separately from the main deployment:
./jeannie ai-index
./jeannie ai-check
./jeannie ask "how do I check edge to Gitea?"
The index is built from the non-secret source list in
infra/ai/knowledge-sources.txt, skips paths in
infra/ai/knowledge-excludes.txt, and defaults to /data/homelab-ai/index.
Doctor commands use it as extra context when it exists, but infrastructure
deployment does not depend on it.
Use ask as the runbook and command finder. It returns the closest indexed
docs/scripts by default; set LAB_AI_ASK_LLM=true to ask Ollama after
retrieval.
The CV page has two client-side presentation modes:
Elegant: dark, minimal, terminal-inspired styling with a square profile image and light green console text.Fancy: centered circular profile image, cursive orbit text, and a cursor-following portrait rotation effect.
The Demos page is a catalog in the PHP website. The actual demo applications are
served from a separate demos-static artifact under apps/demos-static and are
published through the demos-static Argo CD application. Public traffic reaches
them through the edge path at /demo-apps/.
./jeannie up builds and pushes two independent images:
- a content-hash
php-websitetag generated byjeannieand passed to Argo CD as a Kustomize image override demos-static:latestfromapps/demos-static- Cosign signatures and signed SPDX SBOM attestations for both pushed images
The website manifest keeps the stable base image name php-website:bootstrap.
During bootstrap, jeannie hashes apps/website, builds
<registry>/php-website:src-<hash>, exports that exact reference through
TF_VAR_website_image_ref, and the Argo CD Application applies it through
Kustomize. This keeps the GitOps source generic while the deployed image remains
immutable. The Kyverno supply-chain policy mutates admitted pods to the verified
image digest, so the workload runs the same digest that was signed and attested.
After ./jeannie apps, the live deployment image should be a content-hash tag,
for example 192.168.100.73:30500/php-website:src-.... If it still shows
php-website:latest, Argo CD has not rendered the current Application source.
Check the website-production Application source, sync status, and repository
access before restarting pods.
The first demo, The Client-Side Media Cruncher (Wasm + TS), currently performs
private, browser-only image compression and conversion using native Canvas APIs.
Heavier video conversion, such as MP4 to WebM, should use a Rust core compiled
to WebAssembly with a TypeScript UI so the codec work stays fast and still
avoids backend uploads.
The demos are designed to be local-first so the current cluster can serve them
from any Linux app node without turning either pod into an application server.
The website pod serves the portfolio shell and the demos-static pod serves
static demo bundles; CPU-heavy work runs in the visitor's browser. Because the
deployments can run on either Debian or ARM workers, avoid bundling large ML
models, server-side WebSocket probes, or backend video transcoders into either
image. If those demos become production-grade, lazy load model assets in the
browser or move backend workers to a larger node, such as VMs on the Orange Pi 5
Plus.
Current demo inventory:
- Client-side media cruncher: image conversion/compression with Canvas; future Rust/Wasm codec path for video.
- Internet quality visualizer: live Canvas graph for latency, jitter, and stability using same-origin browser probes; a dedicated WebSocket echo endpoint would be the production version.
- Local log and JSON toolbelt: JSON formatting, JWT decoding, URL parsing, and local text-log filtering.
- Architecture simulator: click-driven load, crash, and auto-scale simulation.
- Offline traveler converter: PWA shell with timezone, currency, and GB/GiB conversions.
- Privacy-first redactor: local image redaction prototype; future onnxruntime-web plus quantized YOLO or face model path.
- Local sentiment sandbox: lightweight local sentiment, keyword, and summary prototype; future Transformers.js/ONNX path.
- Model drift simulator: visual MLOps playground for spikes, corrupted inputs, and retraining.
The Kubernetes deployment uses apps/website/web-app.yaml as a Kustomize base.
Keep TF_VAR_registry_endpoint aligned with the local registry endpoint used by
the app image build and with the image globs in
apps/supply-chain-policy/local-registry-image-policy.yaml.
Keep the .terraform.lock.hcl files committed. They pin provider selections and
make bootstrap behavior reproducible across nodes and rebuilds.
Storage
OpenEBS provides the platform storage provisioner. Stateful Kubernetes apps use
retained local PV paths such as /data/openebs/local/registry; these paths are
intentionally outside kubeadm reset paths so data can survive cluster
destroy/create cycles. Those critical volumes are declared explicitly as
retained local PVs so a rebuilt cluster binds back to the same host paths
instead of creating fresh directories.
For the current lab, the HP Debian laptop's root filesystem is on NVMe, so the
standard Docker root /var/lib/docker is acceptable. OpenEBS retained hostpath
data still lives under /data/openebs/local, and larger service data such as
Gitea, Ollama models, and app volumes should stay under /data.
Gitea
Gitea is external bootstrap infrastructure. It runs on the Debian host as an
always-on Docker Compose service from infra/gitea/docker-compose.yml, not as a
Kubernetes workload. This keeps Git available when the Kubernetes cluster is
destroyed and rebuilt.
The default data path is /data/homelab-gitea/data on the HP laptop NVMe.
Docker may use the standard /var/lib/docker root because / is also on NVMe.
Public source browsing stays available through
https://lab2025.duckdns.org/git/. Registration is disabled and anonymous users
can view public repositories, so the blog can link to code read-only while
writes still require an authenticated Gitea account.
Heimdall
Heimdall is external bootstrap infrastructure. It runs on the Debian host as an
always-on Docker Compose service from infra/heimdall/docker-compose.yml, not
as a Kubernetes workload. This keeps the lab navigation page available when the
cluster is destroyed, rebuilding, or unhealthy.
Deploy or refresh it from the Debian server with:
./jeannie deploy-heimdall
./jeannie up also runs this step during the independent host
services stage unless LAB_HEIMDALL_DEPLOY=false is set. The default data path
is /data/homelab-heimdall/data/config, and the local HTTP port is 8082.
Dashboard links are seeded from infra/heimdall/links.json by a sidecar
container that upserts repo-managed tiles without deleting manually added ones.
The Debian bare repo remains the GitOps mirror:
/home/jv/git-server/my-homelab-configs.git
Argo CD consumes that Debian mirror through the default gitops_repo_url.
Gitea Actions pushes the main commit into the mirror before running the
selected deploy command.
The platform bootstrap registers the Argo CD repository secret and the SSH host
key for the Debian GitOps mirror. If Argo CD reports
knownhosts: key is unknown after the Debian host was rebuilt or its SSH host
key changed, refresh argocd-ssh-known-hosts-cm in the argocd namespace,
restart argocd-repo-server, and hard-refresh the affected Application.
Deploy or refresh the external Gitea container from the Debian host with:
./jeannie deploy-gitea
Gitea Backups
./jeannie up installs a Debian-host systemd timer named
homelab-gitea-backup.timer. The timer runs daily, SSHes to the configured
Gitea host, executes gitea dump inside the Gitea Docker container, copies the
dump back to Debian, and stores it under /home/jv/backups/gitea. The default
retention is 30 days.
The same install step also creates homelab-gitea-restore-drill.timer. The
monthly drill is non-destructive: it verifies the latest backup ZIP, extracts it
to a temporary directory, records a report under
/home/jv/backups/gitea-restore-drills, and removes the temporary extract. It
does not write into the live Gitea data directory.
Run a manual backup from the Debian server with:
./jeannie backup-gitea
Run the restore drill manually with:
./jeannie drill-gitea-restore
./jeannie drill-restore
./jeannie drill-pihole-restore
drill-restore validates the latest Gitea backup archive, Pi-hole repo-managed
config inputs, the latest OpenTofu state backup archive when present, and the
GitOps rebuild path. It does not replace running services or mutate Kubernetes.
Useful checks:
./jeannie backup-status
systemctl list-timers homelab-gitea-backup.timer
systemctl list-timers homelab-gitea-restore-drill.timer
sudo systemctl start homelab-gitea-backup.service
ls -lh /home/jv/backups/gitea
ls -lh /home/jv/backups/gitea-restore-drills
Gitea Actions
This repo includes a Gitea Actions workflow at
.gitea/workflows/homelab-main.yml. It runs validation on pushes to dev and
main, and deploys only from main. That keeps the promotion path simple:
dev -> ./jeannie validate in Gitea Actions -> main -> Argo CD sync
The workflow targets a repository-scoped Debian host runner with the label
homelab-debian.
Run the same validation locally before promoting:
./jeannie validate
Identity and access audit
Run a read-only access inventory from the Debian server with:
./jeannie access-audit
The audit reports configured SSH targets and key file modes, Git/Gitea remotes, Tailscale ACL policy validation, current Kubernetes authorization, broad cluster-admin bindings, automation service accounts, and Gitea runner status. It does not create users, tokens, kubeconfigs, or keys. Use it to move toward separate identities:
- admin kubeconfig only on the Debian control host
- read-only kubeconfig for dashboards and audits
- separate service accounts for automation
- explicit SSH inventory for Debian, RPi, Pimox, and OCI edge
- repo-managed Tailscale ACLs
The read-only Kubernetes identity is managed in apps/access-control. After
that app syncs, generate a separate read-only kubeconfig on the Debian host:
./jeannie kubeconfig-readonly
The workflow only blocks automatic deploy for external Gitea service
changes: files under infra/gitea/, or edits inside the deploy_gitea,
install_gitea_backup_timer, backup_gitea, or drill_gitea_restore
functions in jeannie. Other changes use HOMELAB_ACTION_COMMAND=auto by
default: Actions runs ./jeannie doctor-versions when the Debian runner already
has a cluster kubeconfig. If node versions are aligned it runs ./jeannie apps;
if kubelet minor drift is detected it runs ./jeannie rebuild-cluster; if no
kubeconfig exists it also runs ./jeannie rebuild-cluster.
Set HOMELAB_ACTION_COMMAND=apps or HOMELAB_ACTION_COMMAND=rebuild-cluster
on the runner to force one path.
./jeannie bootstrap-gitea-repo also registers the Debian host SSH public key
with the Gitea repository and switches the Debian working copy's gitea remote
to ssh://git@192.168.100.73:32222/jv/my-homelab-configs.git. The default key
is /home/jv/.ssh/id_ed25519.pub; set LAB_GITEA_REPO_SSH_KEY_PATH to use a
different Debian-host key, or LAB_GITEA_REPO_SSH_BOOTSTRAP=false to leave SSH
access unchanged. The Actions deploy job uses the checked-out Actions workspace
as the source commit, updates the first available persistent checkout from
HOMELAB_DEPLOY_DIR, /home/jv/my-homelab-configs, or
/home/jv/repos/my-homelab-configs, and otherwise deploys directly from the
Actions workspace. It does not need SSH read access back to Gitea.
Enable Actions for the repository in Gitea, then create a repository-level runner token from:
https://lab2025.duckdns.org/git/jv/my-homelab-configs/settings/actions/runners
Register and start the Debian runner from the Debian server:
cd ~/my-homelab-configs
GITEA_RUNNER_REGISTRATION_TOKEN='<repo-runner-token>' ./jeannie install-gitea-runner
The runner is installed as homelab-gitea-runner.service, runs as user jv, and
uses a host label instead of a Docker job container because deployment needs the
Debian host's Docker, OpenTofu, kubeconfig, SSH keys, and local state.
The deployment job is non-interactive. User jv must be able to run sudo -n true on the Debian host for deployment commands that require sudo.
Useful checks:
systemctl status homelab-gitea-runner.service
journalctl -u homelab-gitea-runner.service -n 100 --no-pager
Renovate
renovate.json defines dependency update rules for Dockerfiles, OpenTofu
providers, Helm chart versions, and the pinned tools used by the Gitea Actions
workflow. Renovate should open reviewable update branches or PRs only; it must
not auto-merge infrastructure changes. Keep app-only dependency updates on the
normal Gitea Actions path, and run ./jeannie up manually on the Debian server
for platform or provisioning updates.
Destructive Rebuilds
./jeannie nuke resets kubeadm, containerd runtime state, CNI files, Calico
links, iptables rules, and local OpenTofu state. It does not delete retained data
under /data/openebs/local.
For multi-node labs, set WORKER_SSH_TARGETS to a space-separated list of SSH
targets. It defaults to an empty string so worker nodes are not cleaned unless
you explicitly include them.
Website App
The website is a PHP app under apps/website. It includes a home page, CV page,
blog page, and demos page, plus a lightweight translation flow backed by Redis,
n8n, and Ollama.
Static language files live in apps/website/lang; en.php and nah.php are
curated source files, with the Nahuatl home page intentionally biased toward as
many Nahuatl words as possible while keeping technical terms understandable.
Unsupported browser languages use the same-origin /translate.php endpoint,
which calls Ollama server-side through OLLAMA_HOST and OLLAMA_MODEL; the
browser never calls the private Ollama IP directly. Redis caches per-string
translation results in the website-production namespace, while n8n is
available for translation prewarming and batch workflow jobs. The default model
is the custom website-translator Ollama model defined in
apps/website/ollama/Modelfile. Generated runtime language JSON is saved
through save_lang.php on the website PVC, and translate.php emits structured
logs for cache hits, misses, Ollama latency, JSON parse failures, and timeouts.
Create or refresh the Ollama model on the Debian server before deploying a website image that points at it:
./jeannie website-translation-model
Ollama must also listen on the Debian host LAN address so Kubernetes pods on
other nodes can reach OLLAMA_HOST=http://192.168.100.73:11434:
./jeannie website-ollama-listen
The Debian host bootstrap also manages Ollama when debian_pc_install_ollama
is true: it installs the service, stores models under /data/ollama/models,
binds the API to the configured LAN listener, and pulls the lightweight
ai_gateway.model. On an existing host, apply just this setup with:
./jeannie ollama-setup
jeannie also has a backstage local AI helper for doctor commands.
It is not a separate CLI action; when ai_gateway.enabled is true in
homelab.yml, doctor commands try the configured Ollama model and silently
skip the helper if Ollama is down. The default lightweight model is:
ollama pull qwen2.5:0.5b
Disable it for low-CPU sessions with:
LAB_BACKSTAGE_BRAIN_ENABLED=false ./jeannie doctor-edge
Build the local homelab knowledge index separately from the main deployment:
./jeannie ai-index
./jeannie ai-check
./jeannie ask "how do I check edge to Gitea?"
The index is built from the non-secret source list in
infra/ai/knowledge-sources.txt, skips paths in
infra/ai/knowledge-excludes.txt, and defaults to /data/homelab-ai/index.
Doctor commands use it as extra context when it exists, but infrastructure
deployment does not depend on it.
Use ask as the runbook and command finder. It returns the closest indexed
docs/scripts by default; set LAB_AI_ASK_LLM=true to ask Ollama after
retrieval.
The CV page has two client-side presentation modes:
Elegant: dark, minimal, terminal-inspired styling with a square profile image and light green console text.Fancy: centered circular profile image, cursive orbit text, and a cursor-following portrait rotation effect.
The Demos page is a catalog in the PHP website. The actual demo applications are
served from a separate demos-static artifact under apps/demos-static and are
published through the demos-static Argo CD application. Public traffic reaches
them through the edge path at /demo-apps/.
./jeannie up builds and pushes two independent images:
- a content-hash
php-websitetag generated byjeannieand passed to Argo CD as a Kustomize image override demos-static:latestfromapps/demos-static- Cosign signatures and signed SPDX SBOM attestations for both pushed images
The website manifest keeps the stable base image name php-website:bootstrap.
During bootstrap, jeannie hashes apps/website, builds
<registry>/php-website:src-<hash>, exports that exact reference through
TF_VAR_website_image_ref, and the Argo CD Application applies it through
Kustomize. This keeps the GitOps source generic while the deployed image remains
immutable. The Kyverno supply-chain policy mutates admitted pods to the verified
image digest, so the workload runs the same digest that was signed and attested.
After ./jeannie apps, the live deployment image should be a content-hash tag,
for example 192.168.100.73:30500/php-website:src-.... If it still shows
php-website:latest, Argo CD has not rendered the current Application source.
Check the website-production Application source, sync status, and repository
access before restarting pods.
The first demo, The Client-Side Media Cruncher (Wasm + TS), currently performs
private, browser-only image compression and conversion using native Canvas APIs.
Heavier video conversion, such as MP4 to WebM, should use a Rust core compiled
to WebAssembly with a TypeScript UI so the codec work stays fast and still
avoids backend uploads.
The demos are designed to be local-first so the current cluster can serve them
from any Linux app node without turning either pod into an application server.
The website pod serves the portfolio shell and the demos-static pod serves
static demo bundles; CPU-heavy work runs in the visitor's browser. Because the
deployments can run on either Debian or ARM workers, avoid bundling large ML
models, server-side WebSocket probes, or backend video transcoders into either
image. If those demos become production-grade, lazy load model assets in the
browser or move backend workers to a larger node, such as VMs on the Orange Pi 5
Plus.
Current demo inventory:
- Client-side media cruncher: image conversion/compression with Canvas; future Rust/Wasm codec path for video.
- Internet quality visualizer: live Canvas graph for latency, jitter, and stability using same-origin browser probes; a dedicated WebSocket echo endpoint would be the production version.
- Local log and JSON toolbelt: JSON formatting, JWT decoding, URL parsing, and local text-log filtering.
- Architecture simulator: click-driven load, crash, and auto-scale simulation.
- Offline traveler converter: PWA shell with timezone, currency, and GB/GiB conversions.
- Privacy-first redactor: local image redaction prototype; future onnxruntime-web plus quantized YOLO or face model path.
- Local sentiment sandbox: lightweight local sentiment, keyword, and summary prototype; future Transformers.js/ONNX path.
- Model drift simulator: visual MLOps playground for spikes, corrupted inputs, and retraining.
The Kubernetes deployment uses apps/website/web-app.yaml as a Kustomize base.
Keep TF_VAR_registry_endpoint aligned with the local registry endpoint used by
the app image build and with the image globs in
apps/supply-chain-policy/local-registry-image-policy.yaml.
Keep the .terraform.lock.hcl files committed. They pin provider selections and
make bootstrap behavior reproducible across nodes and rebuilds.
Gitea
Gitea is external bootstrap infrastructure. It runs on the Debian host as an
always-on Docker Compose service from infra/gitea/docker-compose.yml, not as a
Kubernetes workload. This keeps Git available when the Kubernetes cluster is
destroyed and rebuilt.
The default data path is /data/homelab-gitea/data on the HP laptop NVMe.
Docker may use the standard /var/lib/docker root because / is also on NVMe.
Public source browsing stays available through
https://lab2025.duckdns.org/git/. Registration is disabled and anonymous users
can view public repositories, so the blog can link to code read-only while
writes still require an authenticated Gitea account.
Heimdall
Heimdall is external bootstrap infrastructure. It runs on the Debian host as an
always-on Docker Compose service from infra/heimdall/docker-compose.yml, not
as a Kubernetes workload. This keeps the lab navigation page available when the
cluster is destroyed, rebuilding, or unhealthy.
Deploy or refresh it from the Debian server with:
./jeannie deploy-heimdall
./jeannie up also runs this step during the independent host
services stage unless LAB_HEIMDALL_DEPLOY=false is set. The default data path
is /data/homelab-heimdall/data/config, and the local HTTP port is 8082.
Dashboard links are seeded from infra/heimdall/links.json by a sidecar
container that upserts repo-managed tiles without deleting manually added ones.
The Debian bare repo remains the GitOps mirror:
/home/jv/git-server/my-homelab-configs.git
Argo CD consumes that Debian mirror through the default gitops_repo_url.
Gitea Actions pushes the main commit into the mirror before running the
selected deploy command.
The platform bootstrap registers the Argo CD repository secret and the SSH host
key for the Debian GitOps mirror. If Argo CD reports
knownhosts: key is unknown after the Debian host was rebuilt or its SSH host
key changed, refresh argocd-ssh-known-hosts-cm in the argocd namespace,
restart argocd-repo-server, and hard-refresh the affected Application.
Deploy or refresh the external Gitea container from the Debian host with:
./jeannie deploy-gitea
Gitea Backups
./jeannie up installs a Debian-host systemd timer named
homelab-gitea-backup.timer. The timer runs daily, SSHes to the configured
Gitea host, executes gitea dump inside the Gitea Docker container, copies the
dump back to Debian, and stores it under /home/jv/backups/gitea. The default
retention is 30 days.
The same install step also creates homelab-gitea-restore-drill.timer. The
monthly drill is non-destructive: it verifies the latest backup ZIP, extracts it
to a temporary directory, records a report under
/home/jv/backups/gitea-restore-drills, and removes the temporary extract. It
does not write into the live Gitea data directory.
Run a manual backup from the Debian server with:
./jeannie backup-gitea
Run the restore drill manually with:
./jeannie drill-gitea-restore
./jeannie drill-restore
./jeannie drill-pihole-restore
drill-restore validates the latest Gitea backup archive, Pi-hole repo-managed
config inputs, the latest OpenTofu state backup archive when present, and the
GitOps rebuild path. It does not replace running services or mutate Kubernetes.
Useful checks:
./jeannie backup-status
systemctl list-timers homelab-gitea-backup.timer
systemctl list-timers homelab-gitea-restore-drill.timer
sudo systemctl start homelab-gitea-backup.service
ls -lh /home/jv/backups/gitea
ls -lh /home/jv/backups/gitea-restore-drills
Gitea Actions
This repo includes a Gitea Actions workflow at
.gitea/workflows/homelab-main.yml. It runs validation on pushes to dev and
main, and deploys only from main. That keeps the promotion path simple:
dev -> ./jeannie validate in Gitea Actions -> main -> Argo CD sync
The workflow targets a repository-scoped Debian host runner with the label
homelab-debian.
Run the same validation locally before promoting:
./jeannie validate
Identity and access audit
Run a read-only access inventory from the Debian server with:
./jeannie access-audit
The audit reports configured SSH targets and key file modes, Git/Gitea remotes, Tailscale ACL policy validation, current Kubernetes authorization, broad cluster-admin bindings, automation service accounts, and Gitea runner status. It does not create users, tokens, kubeconfigs, or keys. Use it to move toward separate identities:
- admin kubeconfig only on the Debian control host
- read-only kubeconfig for dashboards and audits
- separate service accounts for automation
- explicit SSH inventory for Debian, RPi, Pimox, and OCI edge
- repo-managed Tailscale ACLs
The read-only Kubernetes identity is managed in apps/access-control. After
that app syncs, generate a separate read-only kubeconfig on the Debian host:
./jeannie kubeconfig-readonly
The workflow only blocks automatic deploy for external Gitea service
changes: files under infra/gitea/, or edits inside the deploy_gitea,
install_gitea_backup_timer, backup_gitea, or drill_gitea_restore
functions in jeannie. Other changes use HOMELAB_ACTION_COMMAND=auto by
default: Actions runs ./jeannie doctor-versions when the Debian runner already
has a cluster kubeconfig. If node versions are aligned it runs ./jeannie apps;
if kubelet minor drift is detected it runs ./jeannie rebuild-cluster; if no
kubeconfig exists it also runs ./jeannie rebuild-cluster.
Set HOMELAB_ACTION_COMMAND=apps or HOMELAB_ACTION_COMMAND=rebuild-cluster
on the runner to force one path.
./jeannie bootstrap-gitea-repo also registers the Debian host SSH public key
with the Gitea repository and switches the Debian working copy's gitea remote
to ssh://git@192.168.100.73:32222/jv/my-homelab-configs.git. The default key
is /home/jv/.ssh/id_ed25519.pub; set LAB_GITEA_REPO_SSH_KEY_PATH to use a
different Debian-host key, or LAB_GITEA_REPO_SSH_BOOTSTRAP=false to leave SSH
access unchanged. The Actions deploy job uses the checked-out Actions workspace
as the source commit, updates the first available persistent checkout from
HOMELAB_DEPLOY_DIR, /home/jv/my-homelab-configs, or
/home/jv/repos/my-homelab-configs, and otherwise deploys directly from the
Actions workspace. It does not need SSH read access back to Gitea.
Enable Actions for the repository in Gitea, then create a repository-level runner token from:
https://lab2025.duckdns.org/git/jv/my-homelab-configs/settings/actions/runners
Register and start the Debian runner from the Debian server:
cd ~/my-homelab-configs
GITEA_RUNNER_REGISTRATION_TOKEN='<repo-runner-token>' ./jeannie install-gitea-runner
The runner is installed as homelab-gitea-runner.service, runs as user jv, and
uses a host label instead of a Docker job container because deployment needs the
Debian host's Docker, OpenTofu, kubeconfig, SSH keys, and local state.
The deployment job is non-interactive. User jv must be able to run sudo -n true on the Debian host for deployment commands that require sudo.
Useful checks:
systemctl status homelab-gitea-runner.service
journalctl -u homelab-gitea-runner.service -n 100 --no-pager
Renovate
renovate.json defines dependency update rules for Dockerfiles, OpenTofu
providers, Helm chart versions, and the pinned tools used by the Gitea Actions
workflow. Renovate should open reviewable update branches or PRs only; it must
not auto-merge infrastructure changes. Keep app-only dependency updates on the
normal Gitea Actions path, and run ./jeannie up manually on the Debian server
for platform or provisioning updates.
Destructive Rebuilds
./jeannie nuke resets kubeadm, containerd runtime state, CNI files, Calico
links, iptables rules, and local OpenTofu state. It does not delete retained data
under /data/openebs/local.
For multi-node labs, set WORKER_SSH_TARGETS to a space-separated list of SSH
targets. It defaults to an empty string so worker nodes are not cleaned unless
you explicitly include them.
Website App
The website is a PHP app under apps/website. It includes a home page, CV page,
blog page, and demos page, plus a lightweight translation flow backed by Redis,
n8n, and Ollama.
Static language files live in apps/website/lang; en.php and nah.php are
curated source files, with the Nahuatl home page intentionally biased toward as
many Nahuatl words as possible while keeping technical terms understandable.
Unsupported browser languages use the same-origin /translate.php endpoint,
which calls Ollama server-side through OLLAMA_HOST and OLLAMA_MODEL; the
browser never calls the private Ollama IP directly. Redis caches per-string
translation results in the website-production namespace, while n8n is
available for translation prewarming and batch workflow jobs. The default model
is the custom website-translator Ollama model defined in
apps/website/ollama/Modelfile. Generated runtime language JSON is saved
through save_lang.php on the website PVC, and translate.php emits structured
logs for cache hits, misses, Ollama latency, JSON parse failures, and timeouts.
Create or refresh the Ollama model on the Debian server before deploying a website image that points at it:
./jeannie website-translation-model
Ollama must also listen on the Debian host LAN address so Kubernetes pods on
other nodes can reach OLLAMA_HOST=http://192.168.100.73:11434:
./jeannie website-ollama-listen
The Debian host bootstrap also manages Ollama when debian_pc_install_ollama
is true: it installs the service, stores models under /data/ollama/models,
binds the API to the configured LAN listener, and pulls the lightweight
ai_gateway.model. On an existing host, apply just this setup with:
./jeannie ollama-setup
jeannie also has a backstage local AI helper for doctor commands.
It is not a separate CLI action; when ai_gateway.enabled is true in
homelab.yml, doctor commands try the configured Ollama model and silently
skip the helper if Ollama is down. The default lightweight model is:
ollama pull qwen2.5:0.5b
Disable it for low-CPU sessions with:
LAB_BACKSTAGE_BRAIN_ENABLED=false ./jeannie doctor-edge
Build the local homelab knowledge index separately from the main deployment:
./jeannie ai-index
./jeannie ai-check
./jeannie ask "how do I check edge to Gitea?"
The index is built from the non-secret source list in
infra/ai/knowledge-sources.txt, skips paths in
infra/ai/knowledge-excludes.txt, and defaults to /data/homelab-ai/index.
Doctor commands use it as extra context when it exists, but infrastructure
deployment does not depend on it.
Use ask as the runbook and command finder. It returns the closest indexed
docs/scripts by default; set LAB_AI_ASK_LLM=true to ask Ollama after
retrieval.
The CV page has two client-side presentation modes:
Elegant: dark, minimal, terminal-inspired styling with a square profile image and light green console text.Fancy: centered circular profile image, cursive orbit text, and a cursor-following portrait rotation effect.
The Demos page is a catalog in the PHP website. The actual demo applications are
served from a separate demos-static artifact under apps/demos-static and are
published through the demos-static Argo CD application. Public traffic reaches
them through the edge path at /demo-apps/.
./jeannie up builds and pushes two independent images:
- a content-hash
php-websitetag generated byjeannieand passed to Argo CD as a Kustomize image override demos-static:latestfromapps/demos-static- Cosign signatures and signed SPDX SBOM attestations for both pushed images
The website manifest keeps the stable base image name php-website:bootstrap.
During bootstrap, jeannie hashes apps/website, builds
<registry>/php-website:src-<hash>, exports that exact reference through
TF_VAR_website_image_ref, and the Argo CD Application applies it through
Kustomize. This keeps the GitOps source generic while the deployed image remains
immutable. The Kyverno supply-chain policy mutates admitted pods to the verified
image digest, so the workload runs the same digest that was signed and attested.
After ./jeannie apps, the live deployment image should be a content-hash tag,
for example 192.168.100.73:30500/php-website:src-.... If it still shows
php-website:latest, Argo CD has not rendered the current Application source.
Check the website-production Application source, sync status, and repository
access before restarting pods.
The first demo, The Client-Side Media Cruncher (Wasm + TS), currently performs
private, browser-only image compression and conversion using native Canvas APIs.
Heavier video conversion, such as MP4 to WebM, should use a Rust core compiled
to WebAssembly with a TypeScript UI so the codec work stays fast and still
avoids backend uploads.
The demos are designed to be local-first so the current cluster can serve them
from any Linux app node without turning either pod into an application server.
The website pod serves the portfolio shell and the demos-static pod serves
static demo bundles; CPU-heavy work runs in the visitor's browser. Because the
deployments can run on either Debian or ARM workers, avoid bundling large ML
models, server-side WebSocket probes, or backend video transcoders into either
image. If those demos become production-grade, lazy load model assets in the
browser or move backend workers to a larger node, such as VMs on the Orange Pi 5
Plus.
Current demo inventory:
- Client-side media cruncher: image conversion/compression with Canvas; future Rust/Wasm codec path for video.
- Internet quality visualizer: live Canvas graph for latency, jitter, and stability using same-origin browser probes; a dedicated WebSocket echo endpoint would be the production version.
- Local log and JSON toolbelt: JSON formatting, JWT decoding, URL parsing, and local text-log filtering.
- Architecture simulator: click-driven load, crash, and auto-scale simulation.
- Offline traveler converter: PWA shell with timezone, currency, and GB/GiB conversions.
- Privacy-first redactor: local image redaction prototype; future onnxruntime-web plus quantized YOLO or face model path.
- Local sentiment sandbox: lightweight local sentiment, keyword, and summary prototype; future Transformers.js/ONNX path.
- Model drift simulator: visual MLOps playground for spikes, corrupted inputs, and retraining.
The Kubernetes deployment uses apps/website/web-app.yaml as a Kustomize base.
Keep TF_VAR_registry_endpoint aligned with the local registry endpoint used by
the app image build and with the image globs in
apps/supply-chain-policy/local-registry-image-policy.yaml.
Keep the .terraform.lock.hcl files committed. They pin provider selections and
make bootstrap behavior reproducible across nodes and rebuilds.
Heimdall
Heimdall is external bootstrap infrastructure. It runs on the Debian host as an
always-on Docker Compose service from infra/heimdall/docker-compose.yml, not
as a Kubernetes workload. This keeps the lab navigation page available when the
cluster is destroyed, rebuilding, or unhealthy.
Deploy or refresh it from the Debian server with:
./jeannie deploy-heimdall
./jeannie up also runs this step during the independent host
services stage unless LAB_HEIMDALL_DEPLOY=false is set. The default data path
is /data/homelab-heimdall/data/config, and the local HTTP port is 8082.
Dashboard links are seeded from infra/heimdall/links.json by a sidecar
container that upserts repo-managed tiles without deleting manually added ones.
The Debian bare repo remains the GitOps mirror:
/home/jv/git-server/my-homelab-configs.git
Argo CD consumes that Debian mirror through the default gitops_repo_url.
Gitea Actions pushes the main commit into the mirror before running the
selected deploy command.
The platform bootstrap registers the Argo CD repository secret and the SSH host
key for the Debian GitOps mirror. If Argo CD reports
knownhosts: key is unknown after the Debian host was rebuilt or its SSH host
key changed, refresh argocd-ssh-known-hosts-cm in the argocd namespace,
restart argocd-repo-server, and hard-refresh the affected Application.
Deploy or refresh the external Gitea container from the Debian host with:
./jeannie deploy-gitea
Gitea Backups
./jeannie up installs a Debian-host systemd timer named
homelab-gitea-backup.timer. The timer runs daily, SSHes to the configured
Gitea host, executes gitea dump inside the Gitea Docker container, copies the
dump back to Debian, and stores it under /home/jv/backups/gitea. The default
retention is 30 days.
The same install step also creates homelab-gitea-restore-drill.timer. The
monthly drill is non-destructive: it verifies the latest backup ZIP, extracts it
to a temporary directory, records a report under
/home/jv/backups/gitea-restore-drills, and removes the temporary extract. It
does not write into the live Gitea data directory.
Run a manual backup from the Debian server with:
./jeannie backup-gitea
Run the restore drill manually with:
./jeannie drill-gitea-restore
./jeannie drill-restore
./jeannie drill-pihole-restore
drill-restore validates the latest Gitea backup archive, Pi-hole repo-managed
config inputs, the latest OpenTofu state backup archive when present, and the
GitOps rebuild path. It does not replace running services or mutate Kubernetes.
Useful checks:
./jeannie backup-status
systemctl list-timers homelab-gitea-backup.timer
systemctl list-timers homelab-gitea-restore-drill.timer
sudo systemctl start homelab-gitea-backup.service
ls -lh /home/jv/backups/gitea
ls -lh /home/jv/backups/gitea-restore-drills
Gitea Actions
This repo includes a Gitea Actions workflow at
.gitea/workflows/homelab-main.yml. It runs validation on pushes to dev and
main, and deploys only from main. That keeps the promotion path simple:
dev -> ./jeannie validate in Gitea Actions -> main -> Argo CD sync
The workflow targets a repository-scoped Debian host runner with the label
homelab-debian.
Run the same validation locally before promoting:
./jeannie validate
Identity and access audit
Run a read-only access inventory from the Debian server with:
./jeannie access-audit
The audit reports configured SSH targets and key file modes, Git/Gitea remotes, Tailscale ACL policy validation, current Kubernetes authorization, broad cluster-admin bindings, automation service accounts, and Gitea runner status. It does not create users, tokens, kubeconfigs, or keys. Use it to move toward separate identities:
- admin kubeconfig only on the Debian control host
- read-only kubeconfig for dashboards and audits
- separate service accounts for automation
- explicit SSH inventory for Debian, RPi, Pimox, and OCI edge
- repo-managed Tailscale ACLs
The read-only Kubernetes identity is managed in apps/access-control. After
that app syncs, generate a separate read-only kubeconfig on the Debian host:
./jeannie kubeconfig-readonly
The workflow only blocks automatic deploy for external Gitea service
changes: files under infra/gitea/, or edits inside the deploy_gitea,
install_gitea_backup_timer, backup_gitea, or drill_gitea_restore
functions in jeannie. Other changes use HOMELAB_ACTION_COMMAND=auto by
default: Actions runs ./jeannie doctor-versions when the Debian runner already
has a cluster kubeconfig. If node versions are aligned it runs ./jeannie apps;
if kubelet minor drift is detected it runs ./jeannie rebuild-cluster; if no
kubeconfig exists it also runs ./jeannie rebuild-cluster.
Set HOMELAB_ACTION_COMMAND=apps or HOMELAB_ACTION_COMMAND=rebuild-cluster
on the runner to force one path.
./jeannie bootstrap-gitea-repo also registers the Debian host SSH public key
with the Gitea repository and switches the Debian working copy's gitea remote
to ssh://git@192.168.100.73:32222/jv/my-homelab-configs.git. The default key
is /home/jv/.ssh/id_ed25519.pub; set LAB_GITEA_REPO_SSH_KEY_PATH to use a
different Debian-host key, or LAB_GITEA_REPO_SSH_BOOTSTRAP=false to leave SSH
access unchanged. The Actions deploy job uses the checked-out Actions workspace
as the source commit, updates the first available persistent checkout from
HOMELAB_DEPLOY_DIR, /home/jv/my-homelab-configs, or
/home/jv/repos/my-homelab-configs, and otherwise deploys directly from the
Actions workspace. It does not need SSH read access back to Gitea.
Enable Actions for the repository in Gitea, then create a repository-level runner token from:
https://lab2025.duckdns.org/git/jv/my-homelab-configs/settings/actions/runners
Register and start the Debian runner from the Debian server:
cd ~/my-homelab-configs
GITEA_RUNNER_REGISTRATION_TOKEN='<repo-runner-token>' ./jeannie install-gitea-runner
The runner is installed as homelab-gitea-runner.service, runs as user jv, and
uses a host label instead of a Docker job container because deployment needs the
Debian host's Docker, OpenTofu, kubeconfig, SSH keys, and local state.
The deployment job is non-interactive. User jv must be able to run sudo -n true on the Debian host for deployment commands that require sudo.
Useful checks:
systemctl status homelab-gitea-runner.service
journalctl -u homelab-gitea-runner.service -n 100 --no-pager
Renovate
renovate.json defines dependency update rules for Dockerfiles, OpenTofu
providers, Helm chart versions, and the pinned tools used by the Gitea Actions
workflow. Renovate should open reviewable update branches or PRs only; it must
not auto-merge infrastructure changes. Keep app-only dependency updates on the
normal Gitea Actions path, and run ./jeannie up manually on the Debian server
for platform or provisioning updates.
Destructive Rebuilds
./jeannie nuke resets kubeadm, containerd runtime state, CNI files, Calico
links, iptables rules, and local OpenTofu state. It does not delete retained data
under /data/openebs/local.
For multi-node labs, set WORKER_SSH_TARGETS to a space-separated list of SSH
targets. It defaults to an empty string so worker nodes are not cleaned unless
you explicitly include them.
Website App
The website is a PHP app under apps/website. It includes a home page, CV page,
blog page, and demos page, plus a lightweight translation flow backed by Redis,
n8n, and Ollama.
Static language files live in apps/website/lang; en.php and nah.php are
curated source files, with the Nahuatl home page intentionally biased toward as
many Nahuatl words as possible while keeping technical terms understandable.
Unsupported browser languages use the same-origin /translate.php endpoint,
which calls Ollama server-side through OLLAMA_HOST and OLLAMA_MODEL; the
browser never calls the private Ollama IP directly. Redis caches per-string
translation results in the website-production namespace, while n8n is
available for translation prewarming and batch workflow jobs. The default model
is the custom website-translator Ollama model defined in
apps/website/ollama/Modelfile. Generated runtime language JSON is saved
through save_lang.php on the website PVC, and translate.php emits structured
logs for cache hits, misses, Ollama latency, JSON parse failures, and timeouts.
Create or refresh the Ollama model on the Debian server before deploying a website image that points at it:
./jeannie website-translation-model
Ollama must also listen on the Debian host LAN address so Kubernetes pods on
other nodes can reach OLLAMA_HOST=http://192.168.100.73:11434:
./jeannie website-ollama-listen
The Debian host bootstrap also manages Ollama when debian_pc_install_ollama
is true: it installs the service, stores models under /data/ollama/models,
binds the API to the configured LAN listener, and pulls the lightweight
ai_gateway.model. On an existing host, apply just this setup with:
./jeannie ollama-setup
jeannie also has a backstage local AI helper for doctor commands.
It is not a separate CLI action; when ai_gateway.enabled is true in
homelab.yml, doctor commands try the configured Ollama model and silently
skip the helper if Ollama is down. The default lightweight model is:
ollama pull qwen2.5:0.5b
Disable it for low-CPU sessions with:
LAB_BACKSTAGE_BRAIN_ENABLED=false ./jeannie doctor-edge
Build the local homelab knowledge index separately from the main deployment:
./jeannie ai-index
./jeannie ai-check
./jeannie ask "how do I check edge to Gitea?"
The index is built from the non-secret source list in
infra/ai/knowledge-sources.txt, skips paths in
infra/ai/knowledge-excludes.txt, and defaults to /data/homelab-ai/index.
Doctor commands use it as extra context when it exists, but infrastructure
deployment does not depend on it.
Use ask as the runbook and command finder. It returns the closest indexed
docs/scripts by default; set LAB_AI_ASK_LLM=true to ask Ollama after
retrieval.
The CV page has two client-side presentation modes:
Elegant: dark, minimal, terminal-inspired styling with a square profile image and light green console text.Fancy: centered circular profile image, cursive orbit text, and a cursor-following portrait rotation effect.
The Demos page is a catalog in the PHP website. The actual demo applications are
served from a separate demos-static artifact under apps/demos-static and are
published through the demos-static Argo CD application. Public traffic reaches
them through the edge path at /demo-apps/.
./jeannie up builds and pushes two independent images:
- a content-hash
php-websitetag generated byjeannieand passed to Argo CD as a Kustomize image override demos-static:latestfromapps/demos-static- Cosign signatures and signed SPDX SBOM attestations for both pushed images
The website manifest keeps the stable base image name php-website:bootstrap.
During bootstrap, jeannie hashes apps/website, builds
<registry>/php-website:src-<hash>, exports that exact reference through
TF_VAR_website_image_ref, and the Argo CD Application applies it through
Kustomize. This keeps the GitOps source generic while the deployed image remains
immutable. The Kyverno supply-chain policy mutates admitted pods to the verified
image digest, so the workload runs the same digest that was signed and attested.
After ./jeannie apps, the live deployment image should be a content-hash tag,
for example 192.168.100.73:30500/php-website:src-.... If it still shows
php-website:latest, Argo CD has not rendered the current Application source.
Check the website-production Application source, sync status, and repository
access before restarting pods.
The first demo, The Client-Side Media Cruncher (Wasm + TS), currently performs
private, browser-only image compression and conversion using native Canvas APIs.
Heavier video conversion, such as MP4 to WebM, should use a Rust core compiled
to WebAssembly with a TypeScript UI so the codec work stays fast and still
avoids backend uploads.
The demos are designed to be local-first so the current cluster can serve them
from any Linux app node without turning either pod into an application server.
The website pod serves the portfolio shell and the demos-static pod serves
static demo bundles; CPU-heavy work runs in the visitor's browser. Because the
deployments can run on either Debian or ARM workers, avoid bundling large ML
models, server-side WebSocket probes, or backend video transcoders into either
image. If those demos become production-grade, lazy load model assets in the
browser or move backend workers to a larger node, such as VMs on the Orange Pi 5
Plus.
Current demo inventory:
- Client-side media cruncher: image conversion/compression with Canvas; future Rust/Wasm codec path for video.
- Internet quality visualizer: live Canvas graph for latency, jitter, and stability using same-origin browser probes; a dedicated WebSocket echo endpoint would be the production version.
- Local log and JSON toolbelt: JSON formatting, JWT decoding, URL parsing, and local text-log filtering.
- Architecture simulator: click-driven load, crash, and auto-scale simulation.
- Offline traveler converter: PWA shell with timezone, currency, and GB/GiB conversions.
- Privacy-first redactor: local image redaction prototype; future onnxruntime-web plus quantized YOLO or face model path.
- Local sentiment sandbox: lightweight local sentiment, keyword, and summary prototype; future Transformers.js/ONNX path.
- Model drift simulator: visual MLOps playground for spikes, corrupted inputs, and retraining.
The Kubernetes deployment uses apps/website/web-app.yaml as a Kustomize base.
Keep TF_VAR_registry_endpoint aligned with the local registry endpoint used by
the app image build and with the image globs in
apps/supply-chain-policy/local-registry-image-policy.yaml.
Keep the .terraform.lock.hcl files committed. They pin provider selections and
make bootstrap behavior reproducible across nodes and rebuilds.
Gitea Backups
./jeannie up installs a Debian-host systemd timer named
homelab-gitea-backup.timer. The timer runs daily, SSHes to the configured
Gitea host, executes gitea dump inside the Gitea Docker container, copies the
dump back to Debian, and stores it under /home/jv/backups/gitea. The default
retention is 30 days.
The same install step also creates homelab-gitea-restore-drill.timer. The
monthly drill is non-destructive: it verifies the latest backup ZIP, extracts it
to a temporary directory, records a report under
/home/jv/backups/gitea-restore-drills, and removes the temporary extract. It
does not write into the live Gitea data directory.
Run a manual backup from the Debian server with:
./jeannie backup-gitea
Run the restore drill manually with:
./jeannie drill-gitea-restore
./jeannie drill-restore
./jeannie drill-pihole-restore
drill-restore validates the latest Gitea backup archive, Pi-hole repo-managed
config inputs, the latest OpenTofu state backup archive when present, and the
GitOps rebuild path. It does not replace running services or mutate Kubernetes.
Useful checks:
./jeannie backup-status
systemctl list-timers homelab-gitea-backup.timer
systemctl list-timers homelab-gitea-restore-drill.timer
sudo systemctl start homelab-gitea-backup.service
ls -lh /home/jv/backups/gitea
ls -lh /home/jv/backups/gitea-restore-drills
Gitea Actions
This repo includes a Gitea Actions workflow at
.gitea/workflows/homelab-main.yml. It runs validation on pushes to dev and
main, and deploys only from main. That keeps the promotion path simple:
dev -> ./jeannie validate in Gitea Actions -> main -> Argo CD sync
The workflow targets a repository-scoped Debian host runner with the label
homelab-debian.
Run the same validation locally before promoting:
./jeannie validate
Identity and access audit
Run a read-only access inventory from the Debian server with:
./jeannie access-audit
The audit reports configured SSH targets and key file modes, Git/Gitea remotes, Tailscale ACL policy validation, current Kubernetes authorization, broad cluster-admin bindings, automation service accounts, and Gitea runner status. It does not create users, tokens, kubeconfigs, or keys. Use it to move toward separate identities:
- admin kubeconfig only on the Debian control host
- read-only kubeconfig for dashboards and audits
- separate service accounts for automation
- explicit SSH inventory for Debian, RPi, Pimox, and OCI edge
- repo-managed Tailscale ACLs
The read-only Kubernetes identity is managed in apps/access-control. After
that app syncs, generate a separate read-only kubeconfig on the Debian host:
./jeannie kubeconfig-readonly
The workflow only blocks automatic deploy for external Gitea service
changes: files under infra/gitea/, or edits inside the deploy_gitea,
install_gitea_backup_timer, backup_gitea, or drill_gitea_restore
functions in jeannie. Other changes use HOMELAB_ACTION_COMMAND=auto by
default: Actions runs ./jeannie doctor-versions when the Debian runner already
has a cluster kubeconfig. If node versions are aligned it runs ./jeannie apps;
if kubelet minor drift is detected it runs ./jeannie rebuild-cluster; if no
kubeconfig exists it also runs ./jeannie rebuild-cluster.
Set HOMELAB_ACTION_COMMAND=apps or HOMELAB_ACTION_COMMAND=rebuild-cluster
on the runner to force one path.
./jeannie bootstrap-gitea-repo also registers the Debian host SSH public key
with the Gitea repository and switches the Debian working copy's gitea remote
to ssh://git@192.168.100.73:32222/jv/my-homelab-configs.git. The default key
is /home/jv/.ssh/id_ed25519.pub; set LAB_GITEA_REPO_SSH_KEY_PATH to use a
different Debian-host key, or LAB_GITEA_REPO_SSH_BOOTSTRAP=false to leave SSH
access unchanged. The Actions deploy job uses the checked-out Actions workspace
as the source commit, updates the first available persistent checkout from
HOMELAB_DEPLOY_DIR, /home/jv/my-homelab-configs, or
/home/jv/repos/my-homelab-configs, and otherwise deploys directly from the
Actions workspace. It does not need SSH read access back to Gitea.
Enable Actions for the repository in Gitea, then create a repository-level runner token from:
https://lab2025.duckdns.org/git/jv/my-homelab-configs/settings/actions/runners
Register and start the Debian runner from the Debian server:
cd ~/my-homelab-configs
GITEA_RUNNER_REGISTRATION_TOKEN='<repo-runner-token>' ./jeannie install-gitea-runner
The runner is installed as homelab-gitea-runner.service, runs as user jv, and
uses a host label instead of a Docker job container because deployment needs the
Debian host's Docker, OpenTofu, kubeconfig, SSH keys, and local state.
The deployment job is non-interactive. User jv must be able to run sudo -n true on the Debian host for deployment commands that require sudo.
Useful checks:
systemctl status homelab-gitea-runner.service
journalctl -u homelab-gitea-runner.service -n 100 --no-pager
Renovate
renovate.json defines dependency update rules for Dockerfiles, OpenTofu
providers, Helm chart versions, and the pinned tools used by the Gitea Actions
workflow. Renovate should open reviewable update branches or PRs only; it must
not auto-merge infrastructure changes. Keep app-only dependency updates on the
normal Gitea Actions path, and run ./jeannie up manually on the Debian server
for platform or provisioning updates.
Destructive Rebuilds
./jeannie nuke resets kubeadm, containerd runtime state, CNI files, Calico
links, iptables rules, and local OpenTofu state. It does not delete retained data
under /data/openebs/local.
For multi-node labs, set WORKER_SSH_TARGETS to a space-separated list of SSH
targets. It defaults to an empty string so worker nodes are not cleaned unless
you explicitly include them.
Website App
The website is a PHP app under apps/website. It includes a home page, CV page,
blog page, and demos page, plus a lightweight translation flow backed by Redis,
n8n, and Ollama.
Static language files live in apps/website/lang; en.php and nah.php are
curated source files, with the Nahuatl home page intentionally biased toward as
many Nahuatl words as possible while keeping technical terms understandable.
Unsupported browser languages use the same-origin /translate.php endpoint,
which calls Ollama server-side through OLLAMA_HOST and OLLAMA_MODEL; the
browser never calls the private Ollama IP directly. Redis caches per-string
translation results in the website-production namespace, while n8n is
available for translation prewarming and batch workflow jobs. The default model
is the custom website-translator Ollama model defined in
apps/website/ollama/Modelfile. Generated runtime language JSON is saved
through save_lang.php on the website PVC, and translate.php emits structured
logs for cache hits, misses, Ollama latency, JSON parse failures, and timeouts.
Create or refresh the Ollama model on the Debian server before deploying a website image that points at it:
./jeannie website-translation-model
Ollama must also listen on the Debian host LAN address so Kubernetes pods on
other nodes can reach OLLAMA_HOST=http://192.168.100.73:11434:
./jeannie website-ollama-listen
The Debian host bootstrap also manages Ollama when debian_pc_install_ollama
is true: it installs the service, stores models under /data/ollama/models,
binds the API to the configured LAN listener, and pulls the lightweight
ai_gateway.model. On an existing host, apply just this setup with:
./jeannie ollama-setup
jeannie also has a backstage local AI helper for doctor commands.
It is not a separate CLI action; when ai_gateway.enabled is true in
homelab.yml, doctor commands try the configured Ollama model and silently
skip the helper if Ollama is down. The default lightweight model is:
ollama pull qwen2.5:0.5b
Disable it for low-CPU sessions with:
LAB_BACKSTAGE_BRAIN_ENABLED=false ./jeannie doctor-edge
Build the local homelab knowledge index separately from the main deployment:
./jeannie ai-index
./jeannie ai-check
./jeannie ask "how do I check edge to Gitea?"
The index is built from the non-secret source list in
infra/ai/knowledge-sources.txt, skips paths in
infra/ai/knowledge-excludes.txt, and defaults to /data/homelab-ai/index.
Doctor commands use it as extra context when it exists, but infrastructure
deployment does not depend on it.
Use ask as the runbook and command finder. It returns the closest indexed
docs/scripts by default; set LAB_AI_ASK_LLM=true to ask Ollama after
retrieval.
The CV page has two client-side presentation modes:
Elegant: dark, minimal, terminal-inspired styling with a square profile image and light green console text.Fancy: centered circular profile image, cursive orbit text, and a cursor-following portrait rotation effect.
The Demos page is a catalog in the PHP website. The actual demo applications are
served from a separate demos-static artifact under apps/demos-static and are
published through the demos-static Argo CD application. Public traffic reaches
them through the edge path at /demo-apps/.
./jeannie up builds and pushes two independent images:
- a content-hash
php-websitetag generated byjeannieand passed to Argo CD as a Kustomize image override demos-static:latestfromapps/demos-static- Cosign signatures and signed SPDX SBOM attestations for both pushed images
The website manifest keeps the stable base image name php-website:bootstrap.
During bootstrap, jeannie hashes apps/website, builds
<registry>/php-website:src-<hash>, exports that exact reference through
TF_VAR_website_image_ref, and the Argo CD Application applies it through
Kustomize. This keeps the GitOps source generic while the deployed image remains
immutable. The Kyverno supply-chain policy mutates admitted pods to the verified
image digest, so the workload runs the same digest that was signed and attested.
After ./jeannie apps, the live deployment image should be a content-hash tag,
for example 192.168.100.73:30500/php-website:src-.... If it still shows
php-website:latest, Argo CD has not rendered the current Application source.
Check the website-production Application source, sync status, and repository
access before restarting pods.
The first demo, The Client-Side Media Cruncher (Wasm + TS), currently performs
private, browser-only image compression and conversion using native Canvas APIs.
Heavier video conversion, such as MP4 to WebM, should use a Rust core compiled
to WebAssembly with a TypeScript UI so the codec work stays fast and still
avoids backend uploads.
The demos are designed to be local-first so the current cluster can serve them
from any Linux app node without turning either pod into an application server.
The website pod serves the portfolio shell and the demos-static pod serves
static demo bundles; CPU-heavy work runs in the visitor's browser. Because the
deployments can run on either Debian or ARM workers, avoid bundling large ML
models, server-side WebSocket probes, or backend video transcoders into either
image. If those demos become production-grade, lazy load model assets in the
browser or move backend workers to a larger node, such as VMs on the Orange Pi 5
Plus.
Current demo inventory:
- Client-side media cruncher: image conversion/compression with Canvas; future Rust/Wasm codec path for video.
- Internet quality visualizer: live Canvas graph for latency, jitter, and stability using same-origin browser probes; a dedicated WebSocket echo endpoint would be the production version.
- Local log and JSON toolbelt: JSON formatting, JWT decoding, URL parsing, and local text-log filtering.
- Architecture simulator: click-driven load, crash, and auto-scale simulation.
- Offline traveler converter: PWA shell with timezone, currency, and GB/GiB conversions.
- Privacy-first redactor: local image redaction prototype; future onnxruntime-web plus quantized YOLO or face model path.
- Local sentiment sandbox: lightweight local sentiment, keyword, and summary prototype; future Transformers.js/ONNX path.
- Model drift simulator: visual MLOps playground for spikes, corrupted inputs, and retraining.
The Kubernetes deployment uses apps/website/web-app.yaml as a Kustomize base.
Keep TF_VAR_registry_endpoint aligned with the local registry endpoint used by
the app image build and with the image globs in
apps/supply-chain-policy/local-registry-image-policy.yaml.
Keep the .terraform.lock.hcl files committed. They pin provider selections and
make bootstrap behavior reproducible across nodes and rebuilds.
Gitea Actions
This repo includes a Gitea Actions workflow at
.gitea/workflows/homelab-main.yml. It runs validation on pushes to dev and
main, and deploys only from main. That keeps the promotion path simple:
dev -> ./jeannie validate in Gitea Actions -> main -> Argo CD sync
The workflow targets a repository-scoped Debian host runner with the label
homelab-debian.
Run the same validation locally before promoting:
./jeannie validate
Identity and access audit
Run a read-only access inventory from the Debian server with:
./jeannie access-audit
The audit reports configured SSH targets and key file modes, Git/Gitea remotes, Tailscale ACL policy validation, current Kubernetes authorization, broad cluster-admin bindings, automation service accounts, and Gitea runner status. It does not create users, tokens, kubeconfigs, or keys. Use it to move toward separate identities:
- admin kubeconfig only on the Debian control host
- read-only kubeconfig for dashboards and audits
- separate service accounts for automation
- explicit SSH inventory for Debian, RPi, Pimox, and OCI edge
- repo-managed Tailscale ACLs
The read-only Kubernetes identity is managed in apps/access-control. After
that app syncs, generate a separate read-only kubeconfig on the Debian host:
./jeannie kubeconfig-readonly
The workflow only blocks automatic deploy for external Gitea service
changes: files under infra/gitea/, or edits inside the deploy_gitea,
install_gitea_backup_timer, backup_gitea, or drill_gitea_restore
functions in jeannie. Other changes use HOMELAB_ACTION_COMMAND=auto by
default: Actions runs ./jeannie doctor-versions when the Debian runner already
has a cluster kubeconfig. If node versions are aligned it runs ./jeannie apps;
if kubelet minor drift is detected it runs ./jeannie rebuild-cluster; if no
kubeconfig exists it also runs ./jeannie rebuild-cluster.
Set HOMELAB_ACTION_COMMAND=apps or HOMELAB_ACTION_COMMAND=rebuild-cluster
on the runner to force one path.
./jeannie bootstrap-gitea-repo also registers the Debian host SSH public key
with the Gitea repository and switches the Debian working copy's gitea remote
to ssh://git@192.168.100.73:32222/jv/my-homelab-configs.git. The default key
is /home/jv/.ssh/id_ed25519.pub; set LAB_GITEA_REPO_SSH_KEY_PATH to use a
different Debian-host key, or LAB_GITEA_REPO_SSH_BOOTSTRAP=false to leave SSH
access unchanged. The Actions deploy job uses the checked-out Actions workspace
as the source commit, updates the first available persistent checkout from
HOMELAB_DEPLOY_DIR, /home/jv/my-homelab-configs, or
/home/jv/repos/my-homelab-configs, and otherwise deploys directly from the
Actions workspace. It does not need SSH read access back to Gitea.
Enable Actions for the repository in Gitea, then create a repository-level runner token from:
https://lab2025.duckdns.org/git/jv/my-homelab-configs/settings/actions/runners
Register and start the Debian runner from the Debian server:
cd ~/my-homelab-configs
GITEA_RUNNER_REGISTRATION_TOKEN='<repo-runner-token>' ./jeannie install-gitea-runner
The runner is installed as homelab-gitea-runner.service, runs as user jv, and
uses a host label instead of a Docker job container because deployment needs the
Debian host's Docker, OpenTofu, kubeconfig, SSH keys, and local state.
The deployment job is non-interactive. User jv must be able to run sudo -n true on the Debian host for deployment commands that require sudo.
Useful checks:
systemctl status homelab-gitea-runner.service
journalctl -u homelab-gitea-runner.service -n 100 --no-pager
Renovate
renovate.json defines dependency update rules for Dockerfiles, OpenTofu
providers, Helm chart versions, and the pinned tools used by the Gitea Actions
workflow. Renovate should open reviewable update branches or PRs only; it must
not auto-merge infrastructure changes. Keep app-only dependency updates on the
normal Gitea Actions path, and run ./jeannie up manually on the Debian server
for platform or provisioning updates.
Destructive Rebuilds
./jeannie nuke resets kubeadm, containerd runtime state, CNI files, Calico
links, iptables rules, and local OpenTofu state. It does not delete retained data
under /data/openebs/local.
For multi-node labs, set WORKER_SSH_TARGETS to a space-separated list of SSH
targets. It defaults to an empty string so worker nodes are not cleaned unless
you explicitly include them.
Website App
The website is a PHP app under apps/website. It includes a home page, CV page,
blog page, and demos page, plus a lightweight translation flow backed by Redis,
n8n, and Ollama.
Static language files live in apps/website/lang; en.php and nah.php are
curated source files, with the Nahuatl home page intentionally biased toward as
many Nahuatl words as possible while keeping technical terms understandable.
Unsupported browser languages use the same-origin /translate.php endpoint,
which calls Ollama server-side through OLLAMA_HOST and OLLAMA_MODEL; the
browser never calls the private Ollama IP directly. Redis caches per-string
translation results in the website-production namespace, while n8n is
available for translation prewarming and batch workflow jobs. The default model
is the custom website-translator Ollama model defined in
apps/website/ollama/Modelfile. Generated runtime language JSON is saved
through save_lang.php on the website PVC, and translate.php emits structured
logs for cache hits, misses, Ollama latency, JSON parse failures, and timeouts.
Create or refresh the Ollama model on the Debian server before deploying a website image that points at it:
./jeannie website-translation-model
Ollama must also listen on the Debian host LAN address so Kubernetes pods on
other nodes can reach OLLAMA_HOST=http://192.168.100.73:11434:
./jeannie website-ollama-listen
The Debian host bootstrap also manages Ollama when debian_pc_install_ollama
is true: it installs the service, stores models under /data/ollama/models,
binds the API to the configured LAN listener, and pulls the lightweight
ai_gateway.model. On an existing host, apply just this setup with:
./jeannie ollama-setup
jeannie also has a backstage local AI helper for doctor commands.
It is not a separate CLI action; when ai_gateway.enabled is true in
homelab.yml, doctor commands try the configured Ollama model and silently
skip the helper if Ollama is down. The default lightweight model is:
ollama pull qwen2.5:0.5b
Disable it for low-CPU sessions with:
LAB_BACKSTAGE_BRAIN_ENABLED=false ./jeannie doctor-edge
Build the local homelab knowledge index separately from the main deployment:
./jeannie ai-index
./jeannie ai-check
./jeannie ask "how do I check edge to Gitea?"
The index is built from the non-secret source list in
infra/ai/knowledge-sources.txt, skips paths in
infra/ai/knowledge-excludes.txt, and defaults to /data/homelab-ai/index.
Doctor commands use it as extra context when it exists, but infrastructure
deployment does not depend on it.
Use ask as the runbook and command finder. It returns the closest indexed
docs/scripts by default; set LAB_AI_ASK_LLM=true to ask Ollama after
retrieval.
The CV page has two client-side presentation modes:
Elegant: dark, minimal, terminal-inspired styling with a square profile image and light green console text.Fancy: centered circular profile image, cursive orbit text, and a cursor-following portrait rotation effect.
The Demos page is a catalog in the PHP website. The actual demo applications are
served from a separate demos-static artifact under apps/demos-static and are
published through the demos-static Argo CD application. Public traffic reaches
them through the edge path at /demo-apps/.
./jeannie up builds and pushes two independent images:
- a content-hash
php-websitetag generated byjeannieand passed to Argo CD as a Kustomize image override demos-static:latestfromapps/demos-static- Cosign signatures and signed SPDX SBOM attestations for both pushed images
The website manifest keeps the stable base image name php-website:bootstrap.
During bootstrap, jeannie hashes apps/website, builds
<registry>/php-website:src-<hash>, exports that exact reference through
TF_VAR_website_image_ref, and the Argo CD Application applies it through
Kustomize. This keeps the GitOps source generic while the deployed image remains
immutable. The Kyverno supply-chain policy mutates admitted pods to the verified
image digest, so the workload runs the same digest that was signed and attested.
After ./jeannie apps, the live deployment image should be a content-hash tag,
for example 192.168.100.73:30500/php-website:src-.... If it still shows
php-website:latest, Argo CD has not rendered the current Application source.
Check the website-production Application source, sync status, and repository
access before restarting pods.
The first demo, The Client-Side Media Cruncher (Wasm + TS), currently performs
private, browser-only image compression and conversion using native Canvas APIs.
Heavier video conversion, such as MP4 to WebM, should use a Rust core compiled
to WebAssembly with a TypeScript UI so the codec work stays fast and still
avoids backend uploads.
The demos are designed to be local-first so the current cluster can serve them
from any Linux app node without turning either pod into an application server.
The website pod serves the portfolio shell and the demos-static pod serves
static demo bundles; CPU-heavy work runs in the visitor's browser. Because the
deployments can run on either Debian or ARM workers, avoid bundling large ML
models, server-side WebSocket probes, or backend video transcoders into either
image. If those demos become production-grade, lazy load model assets in the
browser or move backend workers to a larger node, such as VMs on the Orange Pi 5
Plus.
Current demo inventory:
- Client-side media cruncher: image conversion/compression with Canvas; future Rust/Wasm codec path for video.
- Internet quality visualizer: live Canvas graph for latency, jitter, and stability using same-origin browser probes; a dedicated WebSocket echo endpoint would be the production version.
- Local log and JSON toolbelt: JSON formatting, JWT decoding, URL parsing, and local text-log filtering.
- Architecture simulator: click-driven load, crash, and auto-scale simulation.
- Offline traveler converter: PWA shell with timezone, currency, and GB/GiB conversions.
- Privacy-first redactor: local image redaction prototype; future onnxruntime-web plus quantized YOLO or face model path.
- Local sentiment sandbox: lightweight local sentiment, keyword, and summary prototype; future Transformers.js/ONNX path.
- Model drift simulator: visual MLOps playground for spikes, corrupted inputs, and retraining.
The Kubernetes deployment uses apps/website/web-app.yaml as a Kustomize base.
Keep TF_VAR_registry_endpoint aligned with the local registry endpoint used by
the app image build and with the image globs in
apps/supply-chain-policy/local-registry-image-policy.yaml.
Keep the .terraform.lock.hcl files committed. They pin provider selections and
make bootstrap behavior reproducible across nodes and rebuilds.
Identity and access audit
Run a read-only access inventory from the Debian server with:
./jeannie access-audit
The audit reports configured SSH targets and key file modes, Git/Gitea remotes, Tailscale ACL policy validation, current Kubernetes authorization, broad cluster-admin bindings, automation service accounts, and Gitea runner status. It does not create users, tokens, kubeconfigs, or keys. Use it to move toward separate identities:
- admin kubeconfig only on the Debian control host
- read-only kubeconfig for dashboards and audits
- separate service accounts for automation
- explicit SSH inventory for Debian, RPi, Pimox, and OCI edge
- repo-managed Tailscale ACLs
The read-only Kubernetes identity is managed in apps/access-control. After
that app syncs, generate a separate read-only kubeconfig on the Debian host:
./jeannie kubeconfig-readonly
The workflow only blocks automatic deploy for external Gitea service
changes: files under infra/gitea/, or edits inside the deploy_gitea,
install_gitea_backup_timer, backup_gitea, or drill_gitea_restore
functions in jeannie. Other changes use HOMELAB_ACTION_COMMAND=auto by
default: Actions runs ./jeannie doctor-versions when the Debian runner already
has a cluster kubeconfig. If node versions are aligned it runs ./jeannie apps;
if kubelet minor drift is detected it runs ./jeannie rebuild-cluster; if no
kubeconfig exists it also runs ./jeannie rebuild-cluster.
Set HOMELAB_ACTION_COMMAND=apps or HOMELAB_ACTION_COMMAND=rebuild-cluster
on the runner to force one path.
./jeannie bootstrap-gitea-repo also registers the Debian host SSH public key
with the Gitea repository and switches the Debian working copy's gitea remote
to ssh://git@192.168.100.73:32222/jv/my-homelab-configs.git. The default key
is /home/jv/.ssh/id_ed25519.pub; set LAB_GITEA_REPO_SSH_KEY_PATH to use a
different Debian-host key, or LAB_GITEA_REPO_SSH_BOOTSTRAP=false to leave SSH
access unchanged. The Actions deploy job uses the checked-out Actions workspace
as the source commit, updates the first available persistent checkout from
HOMELAB_DEPLOY_DIR, /home/jv/my-homelab-configs, or
/home/jv/repos/my-homelab-configs, and otherwise deploys directly from the
Actions workspace. It does not need SSH read access back to Gitea.
Enable Actions for the repository in Gitea, then create a repository-level runner token from:
https://lab2025.duckdns.org/git/jv/my-homelab-configs/settings/actions/runners
Register and start the Debian runner from the Debian server:
cd ~/my-homelab-configs
GITEA_RUNNER_REGISTRATION_TOKEN='<repo-runner-token>' ./jeannie install-gitea-runner
The runner is installed as homelab-gitea-runner.service, runs as user jv, and
uses a host label instead of a Docker job container because deployment needs the
Debian host's Docker, OpenTofu, kubeconfig, SSH keys, and local state.
The deployment job is non-interactive. User jv must be able to run sudo -n true on the Debian host for deployment commands that require sudo.
Useful checks:
systemctl status homelab-gitea-runner.service
journalctl -u homelab-gitea-runner.service -n 100 --no-pager
Renovate
renovate.json defines dependency update rules for Dockerfiles, OpenTofu
providers, Helm chart versions, and the pinned tools used by the Gitea Actions
workflow. Renovate should open reviewable update branches or PRs only; it must
not auto-merge infrastructure changes. Keep app-only dependency updates on the
normal Gitea Actions path, and run ./jeannie up manually on the Debian server
for platform or provisioning updates.
Destructive Rebuilds
./jeannie nuke resets kubeadm, containerd runtime state, CNI files, Calico
links, iptables rules, and local OpenTofu state. It does not delete retained data
under /data/openebs/local.
For multi-node labs, set WORKER_SSH_TARGETS to a space-separated list of SSH
targets. It defaults to an empty string so worker nodes are not cleaned unless
you explicitly include them.
Website App
The website is a PHP app under apps/website. It includes a home page, CV page,
blog page, and demos page, plus a lightweight translation flow backed by Redis,
n8n, and Ollama.
Static language files live in apps/website/lang; en.php and nah.php are
curated source files, with the Nahuatl home page intentionally biased toward as
many Nahuatl words as possible while keeping technical terms understandable.
Unsupported browser languages use the same-origin /translate.php endpoint,
which calls Ollama server-side through OLLAMA_HOST and OLLAMA_MODEL; the
browser never calls the private Ollama IP directly. Redis caches per-string
translation results in the website-production namespace, while n8n is
available for translation prewarming and batch workflow jobs. The default model
is the custom website-translator Ollama model defined in
apps/website/ollama/Modelfile. Generated runtime language JSON is saved
through save_lang.php on the website PVC, and translate.php emits structured
logs for cache hits, misses, Ollama latency, JSON parse failures, and timeouts.
Create or refresh the Ollama model on the Debian server before deploying a website image that points at it:
./jeannie website-translation-model
Ollama must also listen on the Debian host LAN address so Kubernetes pods on
other nodes can reach OLLAMA_HOST=http://192.168.100.73:11434:
./jeannie website-ollama-listen
The Debian host bootstrap also manages Ollama when debian_pc_install_ollama
is true: it installs the service, stores models under /data/ollama/models,
binds the API to the configured LAN listener, and pulls the lightweight
ai_gateway.model. On an existing host, apply just this setup with:
./jeannie ollama-setup
jeannie also has a backstage local AI helper for doctor commands.
It is not a separate CLI action; when ai_gateway.enabled is true in
homelab.yml, doctor commands try the configured Ollama model and silently
skip the helper if Ollama is down. The default lightweight model is:
ollama pull qwen2.5:0.5b
Disable it for low-CPU sessions with:
LAB_BACKSTAGE_BRAIN_ENABLED=false ./jeannie doctor-edge
Build the local homelab knowledge index separately from the main deployment:
./jeannie ai-index
./jeannie ai-check
./jeannie ask "how do I check edge to Gitea?"
The index is built from the non-secret source list in
infra/ai/knowledge-sources.txt, skips paths in
infra/ai/knowledge-excludes.txt, and defaults to /data/homelab-ai/index.
Doctor commands use it as extra context when it exists, but infrastructure
deployment does not depend on it.
Use ask as the runbook and command finder. It returns the closest indexed
docs/scripts by default; set LAB_AI_ASK_LLM=true to ask Ollama after
retrieval.
The CV page has two client-side presentation modes:
Elegant: dark, minimal, terminal-inspired styling with a square profile image and light green console text.Fancy: centered circular profile image, cursive orbit text, and a cursor-following portrait rotation effect.
The Demos page is a catalog in the PHP website. The actual demo applications are
served from a separate demos-static artifact under apps/demos-static and are
published through the demos-static Argo CD application. Public traffic reaches
them through the edge path at /demo-apps/.
./jeannie up builds and pushes two independent images:
- a content-hash
php-websitetag generated byjeannieand passed to Argo CD as a Kustomize image override demos-static:latestfromapps/demos-static- Cosign signatures and signed SPDX SBOM attestations for both pushed images
The website manifest keeps the stable base image name php-website:bootstrap.
During bootstrap, jeannie hashes apps/website, builds
<registry>/php-website:src-<hash>, exports that exact reference through
TF_VAR_website_image_ref, and the Argo CD Application applies it through
Kustomize. This keeps the GitOps source generic while the deployed image remains
immutable. The Kyverno supply-chain policy mutates admitted pods to the verified
image digest, so the workload runs the same digest that was signed and attested.
After ./jeannie apps, the live deployment image should be a content-hash tag,
for example 192.168.100.73:30500/php-website:src-.... If it still shows
php-website:latest, Argo CD has not rendered the current Application source.
Check the website-production Application source, sync status, and repository
access before restarting pods.
The first demo, The Client-Side Media Cruncher (Wasm + TS), currently performs
private, browser-only image compression and conversion using native Canvas APIs.
Heavier video conversion, such as MP4 to WebM, should use a Rust core compiled
to WebAssembly with a TypeScript UI so the codec work stays fast and still
avoids backend uploads.
The demos are designed to be local-first so the current cluster can serve them
from any Linux app node without turning either pod into an application server.
The website pod serves the portfolio shell and the demos-static pod serves
static demo bundles; CPU-heavy work runs in the visitor's browser. Because the
deployments can run on either Debian or ARM workers, avoid bundling large ML
models, server-side WebSocket probes, or backend video transcoders into either
image. If those demos become production-grade, lazy load model assets in the
browser or move backend workers to a larger node, such as VMs on the Orange Pi 5
Plus.
Current demo inventory:
- Client-side media cruncher: image conversion/compression with Canvas; future Rust/Wasm codec path for video.
- Internet quality visualizer: live Canvas graph for latency, jitter, and stability using same-origin browser probes; a dedicated WebSocket echo endpoint would be the production version.
- Local log and JSON toolbelt: JSON formatting, JWT decoding, URL parsing, and local text-log filtering.
- Architecture simulator: click-driven load, crash, and auto-scale simulation.
- Offline traveler converter: PWA shell with timezone, currency, and GB/GiB conversions.
- Privacy-first redactor: local image redaction prototype; future onnxruntime-web plus quantized YOLO or face model path.
- Local sentiment sandbox: lightweight local sentiment, keyword, and summary prototype; future Transformers.js/ONNX path.
- Model drift simulator: visual MLOps playground for spikes, corrupted inputs, and retraining.
The Kubernetes deployment uses apps/website/web-app.yaml as a Kustomize base.
Keep TF_VAR_registry_endpoint aligned with the local registry endpoint used by
the app image build and with the image globs in
apps/supply-chain-policy/local-registry-image-policy.yaml.
Keep the .terraform.lock.hcl files committed. They pin provider selections and
make bootstrap behavior reproducible across nodes and rebuilds.
Renovate
renovate.json defines dependency update rules for Dockerfiles, OpenTofu
providers, Helm chart versions, and the pinned tools used by the Gitea Actions
workflow. Renovate should open reviewable update branches or PRs only; it must
not auto-merge infrastructure changes. Keep app-only dependency updates on the
normal Gitea Actions path, and run ./jeannie up manually on the Debian server
for platform or provisioning updates.
Destructive Rebuilds
./jeannie nuke resets kubeadm, containerd runtime state, CNI files, Calico
links, iptables rules, and local OpenTofu state. It does not delete retained data
under /data/openebs/local.
For multi-node labs, set WORKER_SSH_TARGETS to a space-separated list of SSH
targets. It defaults to an empty string so worker nodes are not cleaned unless
you explicitly include them.
Website App
The website is a PHP app under apps/website. It includes a home page, CV page,
blog page, and demos page, plus a lightweight translation flow backed by Redis,
n8n, and Ollama.
Static language files live in apps/website/lang; en.php and nah.php are
curated source files, with the Nahuatl home page intentionally biased toward as
many Nahuatl words as possible while keeping technical terms understandable.
Unsupported browser languages use the same-origin /translate.php endpoint,
which calls Ollama server-side through OLLAMA_HOST and OLLAMA_MODEL; the
browser never calls the private Ollama IP directly. Redis caches per-string
translation results in the website-production namespace, while n8n is
available for translation prewarming and batch workflow jobs. The default model
is the custom website-translator Ollama model defined in
apps/website/ollama/Modelfile. Generated runtime language JSON is saved
through save_lang.php on the website PVC, and translate.php emits structured
logs for cache hits, misses, Ollama latency, JSON parse failures, and timeouts.
Create or refresh the Ollama model on the Debian server before deploying a website image that points at it:
./jeannie website-translation-model
Ollama must also listen on the Debian host LAN address so Kubernetes pods on
other nodes can reach OLLAMA_HOST=http://192.168.100.73:11434:
./jeannie website-ollama-listen
The Debian host bootstrap also manages Ollama when debian_pc_install_ollama
is true: it installs the service, stores models under /data/ollama/models,
binds the API to the configured LAN listener, and pulls the lightweight
ai_gateway.model. On an existing host, apply just this setup with:
./jeannie ollama-setup
jeannie also has a backstage local AI helper for doctor commands.
It is not a separate CLI action; when ai_gateway.enabled is true in
homelab.yml, doctor commands try the configured Ollama model and silently
skip the helper if Ollama is down. The default lightweight model is:
ollama pull qwen2.5:0.5b
Disable it for low-CPU sessions with:
LAB_BACKSTAGE_BRAIN_ENABLED=false ./jeannie doctor-edge
Build the local homelab knowledge index separately from the main deployment:
./jeannie ai-index
./jeannie ai-check
./jeannie ask "how do I check edge to Gitea?"
The index is built from the non-secret source list in
infra/ai/knowledge-sources.txt, skips paths in
infra/ai/knowledge-excludes.txt, and defaults to /data/homelab-ai/index.
Doctor commands use it as extra context when it exists, but infrastructure
deployment does not depend on it.
Use ask as the runbook and command finder. It returns the closest indexed
docs/scripts by default; set LAB_AI_ASK_LLM=true to ask Ollama after
retrieval.
The CV page has two client-side presentation modes:
Elegant: dark, minimal, terminal-inspired styling with a square profile image and light green console text.Fancy: centered circular profile image, cursive orbit text, and a cursor-following portrait rotation effect.
The Demos page is a catalog in the PHP website. The actual demo applications are
served from a separate demos-static artifact under apps/demos-static and are
published through the demos-static Argo CD application. Public traffic reaches
them through the edge path at /demo-apps/.
./jeannie up builds and pushes two independent images:
- a content-hash
php-websitetag generated byjeannieand passed to Argo CD as a Kustomize image override demos-static:latestfromapps/demos-static- Cosign signatures and signed SPDX SBOM attestations for both pushed images
The website manifest keeps the stable base image name php-website:bootstrap.
During bootstrap, jeannie hashes apps/website, builds
<registry>/php-website:src-<hash>, exports that exact reference through
TF_VAR_website_image_ref, and the Argo CD Application applies it through
Kustomize. This keeps the GitOps source generic while the deployed image remains
immutable. The Kyverno supply-chain policy mutates admitted pods to the verified
image digest, so the workload runs the same digest that was signed and attested.
After ./jeannie apps, the live deployment image should be a content-hash tag,
for example 192.168.100.73:30500/php-website:src-.... If it still shows
php-website:latest, Argo CD has not rendered the current Application source.
Check the website-production Application source, sync status, and repository
access before restarting pods.
The first demo, The Client-Side Media Cruncher (Wasm + TS), currently performs
private, browser-only image compression and conversion using native Canvas APIs.
Heavier video conversion, such as MP4 to WebM, should use a Rust core compiled
to WebAssembly with a TypeScript UI so the codec work stays fast and still
avoids backend uploads.
The demos are designed to be local-first so the current cluster can serve them
from any Linux app node without turning either pod into an application server.
The website pod serves the portfolio shell and the demos-static pod serves
static demo bundles; CPU-heavy work runs in the visitor's browser. Because the
deployments can run on either Debian or ARM workers, avoid bundling large ML
models, server-side WebSocket probes, or backend video transcoders into either
image. If those demos become production-grade, lazy load model assets in the
browser or move backend workers to a larger node, such as VMs on the Orange Pi 5
Plus.
Current demo inventory:
- Client-side media cruncher: image conversion/compression with Canvas; future Rust/Wasm codec path for video.
- Internet quality visualizer: live Canvas graph for latency, jitter, and stability using same-origin browser probes; a dedicated WebSocket echo endpoint would be the production version.
- Local log and JSON toolbelt: JSON formatting, JWT decoding, URL parsing, and local text-log filtering.
- Architecture simulator: click-driven load, crash, and auto-scale simulation.
- Offline traveler converter: PWA shell with timezone, currency, and GB/GiB conversions.
- Privacy-first redactor: local image redaction prototype; future onnxruntime-web plus quantized YOLO or face model path.
- Local sentiment sandbox: lightweight local sentiment, keyword, and summary prototype; future Transformers.js/ONNX path.
- Model drift simulator: visual MLOps playground for spikes, corrupted inputs, and retraining.
The Kubernetes deployment uses apps/website/web-app.yaml as a Kustomize base.
Keep TF_VAR_registry_endpoint aligned with the local registry endpoint used by
the app image build and with the image globs in
apps/supply-chain-policy/local-registry-image-policy.yaml.
Keep the .terraform.lock.hcl files committed. They pin provider selections and
make bootstrap behavior reproducible across nodes and rebuilds.
Website App
The website is a PHP app under apps/website. It includes a home page, CV page,
blog page, and demos page, plus a lightweight translation flow backed by Redis,
n8n, and Ollama.
Static language files live in apps/website/lang; en.php and nah.php are
curated source files, with the Nahuatl home page intentionally biased toward as
many Nahuatl words as possible while keeping technical terms understandable.
Unsupported browser languages use the same-origin /translate.php endpoint,
which calls Ollama server-side through OLLAMA_HOST and OLLAMA_MODEL; the
browser never calls the private Ollama IP directly. Redis caches per-string
translation results in the website-production namespace, while n8n is
available for translation prewarming and batch workflow jobs. The default model
is the custom website-translator Ollama model defined in
apps/website/ollama/Modelfile. Generated runtime language JSON is saved
through save_lang.php on the website PVC, and translate.php emits structured
logs for cache hits, misses, Ollama latency, JSON parse failures, and timeouts.
Create or refresh the Ollama model on the Debian server before deploying a website image that points at it:
./jeannie website-translation-model
Ollama must also listen on the Debian host LAN address so Kubernetes pods on
other nodes can reach OLLAMA_HOST=http://192.168.100.73:11434:
./jeannie website-ollama-listen
The Debian host bootstrap also manages Ollama when debian_pc_install_ollama
is true: it installs the service, stores models under /data/ollama/models,
binds the API to the configured LAN listener, and pulls the lightweight
ai_gateway.model. On an existing host, apply just this setup with:
./jeannie ollama-setup
jeannie also has a backstage local AI helper for doctor commands.
It is not a separate CLI action; when ai_gateway.enabled is true in
homelab.yml, doctor commands try the configured Ollama model and silently
skip the helper if Ollama is down. The default lightweight model is:
ollama pull qwen2.5:0.5b
Disable it for low-CPU sessions with:
LAB_BACKSTAGE_BRAIN_ENABLED=false ./jeannie doctor-edge
Build the local homelab knowledge index separately from the main deployment:
./jeannie ai-index
./jeannie ai-check
./jeannie ask "how do I check edge to Gitea?"
The index is built from the non-secret source list in
infra/ai/knowledge-sources.txt, skips paths in
infra/ai/knowledge-excludes.txt, and defaults to /data/homelab-ai/index.
Doctor commands use it as extra context when it exists, but infrastructure
deployment does not depend on it.
Use ask as the runbook and command finder. It returns the closest indexed
docs/scripts by default; set LAB_AI_ASK_LLM=true to ask Ollama after
retrieval.
The CV page has two client-side presentation modes:
Elegant: dark, minimal, terminal-inspired styling with a square profile image and light green console text.Fancy: centered circular profile image, cursive orbit text, and a cursor-following portrait rotation effect.
The Demos page is a catalog in the PHP website. The actual demo applications are
served from a separate demos-static artifact under apps/demos-static and are
published through the demos-static Argo CD application. Public traffic reaches
them through the edge path at /demo-apps/.
./jeannie up builds and pushes two independent images:
- a content-hash
php-websitetag generated byjeannieand passed to Argo CD as a Kustomize image override demos-static:latestfromapps/demos-static- Cosign signatures and signed SPDX SBOM attestations for both pushed images
The website manifest keeps the stable base image name php-website:bootstrap.
During bootstrap, jeannie hashes apps/website, builds
<registry>/php-website:src-<hash>, exports that exact reference through
TF_VAR_website_image_ref, and the Argo CD Application applies it through
Kustomize. This keeps the GitOps source generic while the deployed image remains
immutable. The Kyverno supply-chain policy mutates admitted pods to the verified
image digest, so the workload runs the same digest that was signed and attested.
After ./jeannie apps, the live deployment image should be a content-hash tag,
for example 192.168.100.73:30500/php-website:src-.... If it still shows
php-website:latest, Argo CD has not rendered the current Application source.
Check the website-production Application source, sync status, and repository
access before restarting pods.
The first demo, The Client-Side Media Cruncher (Wasm + TS), currently performs
private, browser-only image compression and conversion using native Canvas APIs.
Heavier video conversion, such as MP4 to WebM, should use a Rust core compiled
to WebAssembly with a TypeScript UI so the codec work stays fast and still
avoids backend uploads.
The demos are designed to be local-first so the current cluster can serve them
from any Linux app node without turning either pod into an application server.
The website pod serves the portfolio shell and the demos-static pod serves
static demo bundles; CPU-heavy work runs in the visitor's browser. Because the
deployments can run on either Debian or ARM workers, avoid bundling large ML
models, server-side WebSocket probes, or backend video transcoders into either
image. If those demos become production-grade, lazy load model assets in the
browser or move backend workers to a larger node, such as VMs on the Orange Pi 5
Plus.
Current demo inventory:
- Client-side media cruncher: image conversion/compression with Canvas; future Rust/Wasm codec path for video.
- Internet quality visualizer: live Canvas graph for latency, jitter, and stability using same-origin browser probes; a dedicated WebSocket echo endpoint would be the production version.
- Local log and JSON toolbelt: JSON formatting, JWT decoding, URL parsing, and local text-log filtering.
- Architecture simulator: click-driven load, crash, and auto-scale simulation.
- Offline traveler converter: PWA shell with timezone, currency, and GB/GiB conversions.
- Privacy-first redactor: local image redaction prototype; future onnxruntime-web plus quantized YOLO or face model path.
- Local sentiment sandbox: lightweight local sentiment, keyword, and summary prototype; future Transformers.js/ONNX path.
- Model drift simulator: visual MLOps playground for spikes, corrupted inputs, and retraining.
The Kubernetes deployment uses apps/website/web-app.yaml as a Kustomize base.
Keep TF_VAR_registry_endpoint aligned with the local registry endpoint used by
the app image build and with the image globs in
apps/supply-chain-policy/local-registry-image-policy.yaml.
Keep the .terraform.lock.hcl files committed. They pin provider selections and
make bootstrap behavior reproducible across nodes and rebuilds.