I treat security as a layered concern — every request crosses the perimeter, the host, the cluster, and the application before it touches data. Every claim below cites the file (and often the line) that implements it; the full assessments live alongside the code in the repo: security-assessment.md and linux-server-hardening.md.
Five layers of controls compose from the internet edge to the data store. A compromise at any single layer is contained by the layers below it.
Public attack surface: zero. nmap against the public IP returns no open ports. Cloudflare Tunnel is outbound-only; SSH binds only to the Tailscale interface; Ollama is firewall-fenced to docker bridges and the tailnet.
| Area | Status | Notes |
|---|---|---|
| Shift-left security in CI | Strong | Six gating jobs: Bandit, pip-audit, npm audit, gitleaks, Hadolint, CORS guardrail |
| Infrastructure-as-Code validation | Strong | kubeconform, kind dry-run, custom policy-as-code script |
| Supply chain | Adequate | Multi-stage non-root builds, private GHCR; no image signing or digest pinning |
| Secrets management | Strong | Sealed Secrets controller in-cluster (Phase 1–2 of 6 shipped); committed SealedSecret resources, full-history gitleaks, K8s secretKeyRef injection |
| Application AuthN/AuthZ | Strong | JWT + bcrypt + OAuth 2.0, httpOnly cookies, Redis token revocation, Python JWT enforcement |
| Transport security | Adequate | TLS at Cloudflare edge; no direct internet exposure; intra-cluster traffic unencrypted |
| Developer guardrails | Strong | Pre-commit hooks, preflight Makefile targets, structured branch workflow |
| Post-deploy verification | Strong | Production Playwright smoke tests + compose-smoke CI job |
| Kubernetes runtime posture | Adequate | Pod security contexts on every Deployment; NetworkPolicy and namespace PSS still gaps |
| Observability | Foundation | Prometheus + Grafana dashboards; no security alerting |
| Host / OS hardening | Strong | Debian 13: UFW, SSH Tailscale-only, narrow sudo, auditd, sysctl, lynis 77 |
JWT access and refresh tokens (HMAC-SHA256, 15-min / 7-day TTLs) with bcrypt password hashes. Both Go services explicitly validate the signing method to prevent alg-confusion attacks. Tokens are delivered as httpOnly cookies (Secure, SameSite=Lax) — the frontend never touches the JWT in JavaScript. On logout, the token hash is written to a Redis denylist with matching TTL. Python AI services share a JWT auth dependency that enforces authentication on every mutating endpoint.
Full breakdown: §5 of security-assessment.md
Cluster Secrets are migrating from live kubectl edits to committed SealedSecret resources at k8s/secrets/<namespace>/<name>.sealed.yml. The Bitnami sealed-secrets controller in kube-system decrypts each resource on apply using a controller-held private key; the encrypted file in the repo is the single source of truth, and only the in-cluster controller can decrypt it. This enables GitOps for secrets without committing plaintext.
Phase 1 — controller install, public-key export, sealing helper script (scripts/seal-from-cluster.sh). Phase 2 — four live application Secrets converted to SealedSecret files, plaintext templates removed from the repo.
Phases 3–6 queued: key-rotation runbook, audit-trail integration with the existing gitleaks gate, prod cutover for the remaining Secrets, and retiring the last hand-managed credentials.
Migration decision: secrets-management.md · Day-to-day rules: secrets-and-config-practices.md
Six dedicated security jobs in the GitHub Actions workflow gate every push: Bandit (Python SAST), pip-audit (dependency CVEs), npm audit, gitleaks (full-history secret scanning), Hadolint (Dockerfile lint), and a custom CORS guardrail that blocks wildcard origins. The deploy job declares needs: on all six — a single failure blocks production deployment.
Full breakdown: §1 of security-assessment.md
Every Deployment manifest sets runAsNonRoot: true, readOnlyRootFilesystem: true, allowPrivilegeEscalation: false, and capabilities.drop: ["ALL"]. Readiness probes on every stateful service are enforced by a custom policy-as-code script in CI. Remaining gaps: namespace-level Pod Security Standards and NetworkPolicy are documented as accepted risks.
Full breakdown: §9 of security-assessment.md
Multi-stage Docker builds on every service — the builder stage compiles code; the final image is slim/Alpine with an explicit non-root USER. Images are pushed to a private GHCR registry with imagePullSecrets on every Deployment. Tool versions in CI are pinned for reproducibility. Accepted gap: images are tagged :latest rather than pinned by digest.
Full breakdown: §3 of security-assessment.md
The production server is a hand-installed Debian 13 box with an RTX 3090. SSH binds only to the Tailscale IP — public SSH is gone entirely. UFW runs a default-deny firewall with narrow allow rules (during hardening, a pre-existing rule that silently exposed Ollama to the home LAN was discovered and removed). Privileged operations use a narrow passwordless sudo allowlist for routine ops; privilege-changing actions still require a password.
auditd runs with immutable baseline rules covering identity files, sudo/sshd configs, and privilege-escalation invocations. A sysctl drop-in hardens the kernel (kptr_restrict, ptrace_scope, unprivileged_bpf, network-stack hygiene). fail2ban watches SSH with a 1-hour ban after 3 attempts. Lynis hardening index: 77.
The most consequential finding was a silent gap in patch management: unattended-upgrades was active and configured to allow security-origin packages, but /etc/apt/sources.list was missing the security.debian.org repository since the migration. Adding it pulled 15 stale patches including a kernel update (6.12.73 → 6.12.74).
Full breakdown: linux-server-hardening.md
restricted labels to all namespaces (the per-Deployment security contexts already satisfy it):latest tags with @sha256:… digests to eliminate mutable-tag supply chain riskEvery assertion above is verifiable in the repo. Start at security-assessment.md or linux-server-hardening.md, or browse the cited source files directly on GitHub.