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.
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.
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.
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.
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
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.
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.
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.
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.
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 |
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.
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.
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.
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.
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.
kube-prometheus-stack (Prometheus + Grafana, 7-day retention) via a Helm-based ArgoCD app — detailed just below.
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.
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.
Dashboards for cluster health, node pressure and per-app traffic — exposed only behind Zero Trust at
monitor.emirpalace.ca, never public.
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.
# 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
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/**.
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.
Proxied CNAMEs and Zero Trust Access apps/policies — the email-gated front doors for admin, SSH and private apps — all declared as code.
State lives in Cloudflare R2 (S3-compatible) with native lock files, so CI and laptop share one locked state — never committed to git.
plan diff always applies on the
next push — no drift left unmanaged — and the state lock means two runs can never corrupt each other.cloudflared makes an outbound-only
connection; all ingress rides that tunnel.ssh.emirpalace.ca is reachable only through an Access-authenticated
tunnel, then still checks keys.| Where | What | How it's injected |
|---|---|---|
| Kubernetes Secrets | DB credentials, session secrets, per-app API keys | Created out-of-band; referenced via env → secretKeyRef |
| GitHub Actions Secrets | Docker Hub, ArgoCD token, Telegram, Cloudflare token, R2 keys | Masked in CI; passed as env / TF_VAR_* |
| Terraform vars | terraform.tfvars (Cloudflare token) | Gitignored; also pullable from the cluster secret |
| Terraform state | Full resource state | Private Cloudflare R2 bucket — never in git |
~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.
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.
Full-length Versant placement-test practice with real audio capture and Claude-powered AI grading. JWT + Google SSO, admin-email allowlist.
Cash-flow forecasting — reconstructs and projects your money from a few questions. Also ships a mobile build via Capacitor.
A calm, warm mortgage calculator — drag the sliders to simulate scenarios in real time.
A 90-day training and nutrition transformation, tracked day by day.
Build better habits and keep your daily streaks alive.
A friendly stock-picking competition — track your picks and watch the standings.
Mobile-first invoicing for tradespeople — from quote to paid in a single flow.
Simple online appointment booking for small businesses.
A private family records vault — gated to just two people via Cloudflare Zero Trust.
Brows, lashes and advanced skincare studio.
A neighbourhood hair salon's site.
Statement event backdrops and styling — booked, delivered and set up for you.
The landing page and catalog at emirpalace.ca — the single source of truth for what's live.
This site — the platform's own DevOps showcase, itself shipped through the same pipeline.
Private photo & video backup — a personal cloud for memories, on a GPU-dedicated node.
Adding an app, or retiring one, is a change reviewed in git — not a sequence of console clicks.
Create apps/<name> from one of three archetypes, plus a Dockerfile and a favicon.
Add the app to deploy.yml — filter, build matrix, release & deploy cases.
Add k8s/<name>-app.yml (Deployment + ClusterIP Service) and an ArgoCD Application.
Add a tunnel route + DNS entry in terraform/; the IaC pipeline applies it on push.
Add a card to the public catalog, then git push. The pipeline builds, versions and deploys it — no manual server steps.
Delete its tunnel route + DNS entry → terraform apply pulls it off the edge.
Delete the ArgoCD Application; prune tears down its pods, Service and namespace.
Remove its manifest and its wiring from deploy.yml, and drop its catalog card.
Because state is declarative, decommissioning is just deleting declarations — the platform reconciles to the smaller footprint.