Where it lives

Yes — it runs in my basement

The whole platform is a k3s cluster on Proxmox, hosted on a single mini PC. Four worker nodes carry the load — two virtual (Proxmox VMs) and two bare-metal: a "retired" gaming desktop that now pulls duty as the GPU node, and a Raspberry Pi 5 that used to be my Home Assistant box. Mixed-arch, self-healing, and shipping to production on every push.

k3s on Proxmoxmini-PC host2 virtual + 2 bare-metal workers amd64 + arm64retired gaming rig → GPU nodeRaspberry Pi 5
Illustration of the basement k3s home lab — a mini PC, a repurposed gaming desktop, and a Raspberry Pi
Proxmox + k3s, humming away in the basement.

DevOps & Platform Engineering

A home lab that ships
like a real platform.

my-apps-platform is a self-hosted, single-tenant PaaS running ~16 independent web apps on a home Kubernetes cluster — provisioned, shipped, and secured entirely through GitOps and Infrastructure-as-Code. One git push goes from commit to production.

~0
independent apps in one monorepo
1 push
git → production, fully automated
0
node mixed-arch k3s cluster
0
open inbound ports at home
The stack

Built on boring, production-grade tools

Every layer is something you'd find in a real production environment — just scaled down to a cluster of mini-PCs and a Raspberry Pi. No bespoke glue; the only coupling between apps is a shared pipeline.

Delivery

GitHub ActionsDocker Buildx Docker HubArgoCDConventional Commits

Runtime

k3s / KubernetesPostgreSQL 16Prisma HelmPrometheusGrafana

Edge & IaC

TerraformCloudflare Tunnel Zero Trust AccessR2 remote state

Apps run four ways

Node · Express + EJSTypeScript SPA + Express API (ESM) React + ViteAstrostatic / nginx Playwright E2EVitest

How it fits together

Architecture

Code flows down the centre — commit to cluster. Traffic and infrastructure sit on the right: Cloudflare fronts every app through an outbound-only tunnel, and Terraform manages the whole edge. Hover any node to focus it.

Developer — git push to main GitHub Actions — CI/CD deploy.yml Detect Test Build Release Deploy terraform.yml — on terraform/** changes Plan Apply Docker Hub — images immutable :<sha> + :latest release branch pinned k8s manifests ArgoCD — GitOps controller watches release · auto-sync · prune · self-heal k3s cluster — 5 nodes, mixed-arch (amd64 + arm64) App pods2 replicas each PostgreSQLshared StatefulSet Prometheus+ Grafana cloudflared (tunnel connector) — outbound-only connection to Cloudflare apps expose ClusterIP Services only — no NodePort, no k8s Ingress Express apps self-heal schema on boot · Prisma apps migrate via a PreSync Job Users 🌐 *.emirpalace.ca Cloudflare (edge) Zero Trust AccessGoogle SSO · email allow-lists Tunnelno open inbound ports DNS + TLS at edgeproxied CNAMEs route hostname → in-cluster Service Terraform (IaC) manages tunnel routes, DNS & Zero Trust Access apps remote state in Cloudflare R2 applied by terraform.yml on push
CI/CD GitOps Cluster Cloudflare edge Terraform

Hover a node to inspect it

hover / tap the diagram ↑

Every box maps to a real part of the platform. Hover — or tap on mobile — any node for what it does and a representative snippet straight from the repo.

# the pipeline, top to bottomgit push origin main      # one push — the whole thing starts here

Continuous delivery

One push to production

The whole pipeline lives in a single deploy.yml. Five jobs run in order, gated so a failure never ships a half-built release, and every stage pings Telegram. Tap a stage.

Detect · change-aware

Only what changed rebuilds

A path filter maps changed files to apps and emits two matrices — one per Docker image, one per app. A one-line fix to one app never rebuilds the other fifteen.

Test · quality gate

Playwright against a real Postgres

End-to-end tests run against a live postgres:16 service. A failure blocks that app's build — gating is opt-in per app, so it only runs where it earns its keep.

Build · parallel

One runner per image

Images build in parallel via a matrix and push to Docker Hub tagged :<git-sha> and :latest, backed by a registry build cache for fast, cheap rebuilds.

Release · deterministic

Semver straight from commit messages

The commit type sets the bump (feat → minor, fix → patch, ! → major), the manifest image is pinned to the exact sha, and it's committed & tagged on the release branch.

feat(learn): …minor
fix(bill): …patch
feat!: / BREAKING CHANGE:major
Deploy · GitOps

ArgoCD reconciles the cluster

Each app is pointed at the release branch and force-synced; the job waits for the rollout to become healthy. ArgoCD then keeps correcting any drift — the cluster is always what git declares.


Runtime & GitOps

Kubernetes, reconciled continuously

Each app is one ArgoCD Application with an automated sync policy — prune + selfHeal. The cluster's true state is always whatever is declared on the release branch.

🧩

The cluster

k3s v1.33 across 5 nodes — three amd64 workers, a GPU/dedicated node, and an arm64 Raspberry Pi: a genuinely mixed-arch, multi-tenant home lab.

🔁

Self-healing GitOps

CI triggers an immediate sync on deploy, but ArgoCD also polls and corrects drift — delete a pod by hand and it comes right back, matching git.

🗄️

Data

A shared external PostgreSQL 16 StatefulSet on a persistent volume. Express apps self-heal their schema on boot; TS/Prisma apps run migrate deploy as a PreSync Job.

📊

Observability

kube-prometheus-stack (Prometheus + Grafana, 7-day retention) via a Helm-based ArgoCD app — detailed just below.


Monitoring & observability

You can't operate what you can't see

The cluster runs the full kube-prometheus-stack — Prometheus scraping every node and workload, Grafana for dashboards — deployed as a Helm-based ArgoCD app, so the monitoring stack is itself GitOps-managed.

📈

Prometheus

Scrapes node and pod metrics with a 7-day retention window on modest home-lab resource limits. Reconciled by the same ArgoCD that runs the apps.

📊

Grafana

Dashboards for cluster health, node pressure and per-app traffic — exposed only behind Zero Trust at monitor.emirpalace.ca, never public.

🔔

Alerting

Alertmanager is off by design; instead every CI/CD stage pushes to Telegram — deploy start, tests, build, and per-app deploy success/failure — so alerts reach the maintainer's phone in real time.

What's watched

  • Cluster & nodes — CPU / memory pressure across the 5 mixed-arch nodes; disk on the SD-card Pi.
  • Workloads — pod restarts, readiness probes and rollout health per app namespace.
  • Delivery — every deploy's outcome, surfaced instantly via Telegram rather than a dashboard you have to remember to open.
# k8s/monitoring-values.yml — Helm values, GitOps-syncedgrafana:
  service:      { type: NodePort, nodePort: 30030 }
  persistence:  { enabled: true, size: 5Gi }

prometheus:
  prometheusSpec:
    retention: 7d
    resources:
      requests: { cpu: 100m, memory: 512Mi }
      limits:   { cpu: 500m, memory: 1Gi }

alertmanager:
  enabled: false   # alerts ride the CI → Telegram path

Infrastructure as Code

The edge is code, not clicks

The public edge lives in terraform/ and is the source of truth. A dedicated pipeline runs init → validate → plan → apply automatically on any push touching terraform/**.

🔀

Tunnel routes

Public hostname → in-cluster Service mappings for every app, ending in a 404 catch-all. Scoped so apply can never rotate the tunnel token or drop the connector.

🌐

DNS & Access

Proxied CNAMEs and Zero Trust Access apps/policies — the email-gated front doors for admin, SSH and private apps — all declared as code.

🔒

Remote state

State lives in Cloudflare R2 (S3-compatible) with native lock files, so CI and laptop share one locked state — never committed to git.

One workflow, two guarantees: a non-empty plan diff always applies on the next push — no drift left unmanaged — and the state lock means two runs can never corrupt each other.

Security posture

Zero exposed surface, identity at the door

🛡️

Nothing is port-forwarded

  • The home network runs no open inbound ports. cloudflared makes an outbound-only connection; all ingress rides that tunnel.
  • TLS terminates at Cloudflare's edge; origins are plain in-cluster HTTP.
  • DDoS/WAF protection is inherited from the edge.
🔑

Identity-gated access

  • Zero Trust Access (Google SSO) fronts every sensitive surface — admin dashboards, private apps and cluster SSH — via per-app email allow-lists.
  • SSH isn't public: ssh.emirpalace.ca is reachable only through an Access-authenticated tunnel, then still checks keys.
  • Least-privilege, scoped API tokens for Cloudflare and Docker Hub.
Secret management

No secret ever committed

WhereWhatHow it's injected
Kubernetes SecretsDB credentials, session secrets, per-app API keysCreated out-of-band; referenced via env → secretKeyRef
GitHub Actions SecretsDocker Hub, ArgoCD token, Telegram, Cloudflare token, R2 keysMasked in CI; passed as env / TF_VAR_*
Terraform varsterraform.tfvars (Cloudflare token)Gitignored; also pullable from the cluster secret
Terraform stateFull resource statePrivate Cloudflare R2 bucket — never in git

What runs on it

The fleet

~16 apps, four archetypes, one pipeline. Each is fully independent — its own database, domain and container — coupled only by the shared delivery path. The stack badge shows how each one is built.

Products

public web apps I build and run
📚LearnExpress/EJS

Adaptive CELPIP English practice — a 5,000-word vocabulary bank plus reading, listening, writing and speaking drills. The one app gated by Playwright E2E in CI.

🎧VEPT PrepTS + Prisma

Full-length Versant placement-test practice with real audio capture and Claude-powered AI grading. JWT + Google SSO, admin-email allowlist.

🌊TidelineTS + Prisma

Cash-flow forecasting — reconstructs and projects your money from a few questions. Also ships a mobile build via Capacitor.

🏡HearthTS + Prisma

A calm, warm mortgage calculator — drag the sliders to simulate scenarios in real time.

💪Vector90TS + Prisma

A 90-day training and nutrition transformation, tracked day by day.

Habit TrackerExpress/EJS

Build better habits and keep your daily streaks alive.

📈Stock ShowdownExpress/EJS

A friendly stock-picking competition — track your picks and watch the standings.

🧾BillExpress/EJS

Mobile-first invoicing for tradespeople — from quote to paid in a single flow.

📅BookEasyExpress/EJS

Simple online appointment booking for small businesses.

🔒FamilyExpress/EJS

A private family records vault — gated to just two people via Cloudflare Zero Trust.

Local business sites

real shops around Fergus, Ontario
Brows & Beauty by Ankitastatic / nginx

Brows, lashes and advanced skincare studio.

💇Village Hairstylingstatic / nginx

A neighbourhood hair salon's site.

🎈BloomAstro

Statement event backdrops and styling — booked, delivered and set up for you.

Platform & self-hosted

the meta layer and services on the cluster
🏠platform-webExpress/EJS

The landing page and catalog at emirpalace.ca — the single source of truth for what's live.

🛠️Portfoliostatic / nginx

This site — the platform's own DevOps showcase, itself shipped through the same pipeline.

🖼️Immichself-hosted

Private photo & video backup — a personal cloud for memories, on a GPU-dedicated node.


Day two

Provisioning & decommissioning — both declarative

Adding an app, or retiring one, is a change reviewed in git — not a sequence of console clicks.

1
Scaffold

Create apps/<name> from one of three archetypes, plus a Dockerfile and a favicon.

2
Wire CI/CD

Add the app to deploy.yml — filter, build matrix, release & deploy cases.

3
Declare the workload

Add k8s/<name>-app.yml (Deployment + ClusterIP Service) and an ArgoCD Application.

4
Expose it

Add a tunnel route + DNS entry in terraform/; the IaC pipeline applies it on push.

5
Publish & push

Add a card to the public catalog, then git push. The pipeline builds, versions and deploys it — no manual server steps.

1
Stop exposing it

Delete its tunnel route + DNS entry → terraform apply pulls it off the edge.

2
Remove the workload

Delete the ArgoCD Application; prune tears down its pods, Service and namespace.

3
Clean the repo

Remove its manifest and its wiring from deploy.yml, and drop its catalog card.

4
Done

Because state is declarative, decommissioning is just deleting declarations — the platform reconciles to the smaller footprint.