# Runbook: Adding a New ArgoCD-Driven App to the Cluster Step-by-step procedure for deploying a new application to the homelab Kubernetes cluster via ArgoCD GitOps. Derived from deploying Octopus Deploy (2026-03-13) and existing apps (Authelia, Homepage, Email Relay). ## Decision: In-repo vs Separate Repo | Approach | When to use | Example | |----------|-------------|---------| | **In cluster-bootstrap** (`platform//`) | Tightly coupled to cluster lifecycle, simple apps | Authelia, Homepage, Email Relay | | **Separate Gitea repo** | Independent lifecycle, external contributors, large/complex apps | Octopus Deploy | Most apps go in cluster-bootstrap under `platform/`. Use a separate repo only when there's a clear reason. ## Step 1: Create Manifests ### Directory structure (in-repo) ``` platform// ├── kustomization.yaml ├── namespace.yaml ├── statefulset.yaml or deployment.yaml ├── service.yaml ├── ingressroute.yaml # If externally accessible ├── ksops-generator.yaml # If app has secrets └── -secret.sops.yaml # SOPS-encrypted secrets ``` ### Directory structure (separate repo) ``` / ├── .sops.yaml # SOPS encryption rules (same age key as cluster-bootstrap) ├── .gitignore # local_secrets/ ├── kustomization.yaml ├── namespace.yaml ├── / # Subdirectories per component │ ├── statefulset.yaml │ └── service.yaml ├── ksops-generator.yaml ├── *-secret.sops.yaml ├── local_secrets/ # Gitignored plaintext secrets for setup └── scripts/ └── setup.sh # Secret generation + SOPS encryption ``` ### Kustomization pattern ```yaml apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - namespace.yaml - /statefulset.yaml - /service.yaml - ingressroute.yaml generators: - ksops-generator.yaml # Only if secrets exist ``` ### KSOPS generator pattern ```yaml apiVersion: viaduct.ai/v1 kind: ksops metadata: name: -secret-generator annotations: config.kubernetes.io/function: | exec: path: ksops files: - ./-secret.sops.yaml ``` ### SOPS config (separate repo only) ```yaml creation_rules: - path_regex: .*secret.*\.yaml$ encrypted_regex: "^(data|stringData)$" age: >- age1edc9agzzs8cngd2rsvfhm8aeucnlq2clmj36jh0rrkwuj073fyssr0u4x9 ``` In-repo apps inherit from the cluster-bootstrap root `.sops.yaml`. ## Step 2: Namespace Checklist ```yaml apiVersion: v1 kind: Namespace metadata: name: labels: # Only if app needs privileged containers (DinD, host networking, etc): pod-security.kubernetes.io/enforce: privileged pod-security.kubernetes.io/audit: privileged pod-security.kubernetes.io/warn: privileged ``` **Always check:** Does any container need `securityContext.privileged: true` or host-level access? If yes, add PodSecurity labels. Forgetting this causes silent pod creation failures. ## Step 3: Secrets 1. Create plaintext secret YAML in `local_secrets/` (gitignored) 2. Encrypt with SOPS: `sops --encrypt local_secrets/-secret.yaml > -secret.sops.yaml` 3. Reference in `ksops-generator.yaml` 4. Secret filenames **must** contain `secret` (triggers SOPS rules) 5. Non-secret files must **not** contain `secret` in their name ## Step 4: Traefik IngressRoute (if externally accessible) ```yaml apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: namespace: spec: entryPoints: - websecure routes: - match: Host(`.oreillyit.nz`) kind: Rule services: - name: port: 80 ``` If the app needs Authelia protection, add a middleware reference. If the app handles its own auth, omit it. ## Step 5: ArgoCD Application Create `bootstrap/apps/.yaml` in cluster-bootstrap: ```yaml apiVersion: argoproj.io/v1alpha1 kind: Application metadata: name: namespace: argocd finalizers: - resources-finalizer.argocd.argoproj.io spec: project: default source: # In-repo: repoURL: http://gitea.bootstrap.homelab.internal/homelab/cluster-bootstrap.git targetRevision: main path: platform/ # Separate repo: # repoURL: http://gitea.bootstrap.homelab.internal/homelab/.git # targetRevision: main # path: . destination: server: https://kubernetes.default.svc namespace: syncPolicy: automated: prune: true selfHeal: true syncOptions: - ServerSideApply=true - CreateNamespace=true ``` After pushing, the root app may not pick it up immediately. Force refresh: ```bash kubectl annotate application root -n argocd argocd.argoproj.io/refresh=normal --overwrite ``` ## Step 6: Caddy Reverse Proxy (if externally accessible) Add to `external/vps/inventory.yml` under `caddy_sites`: ```yaml - domain: ".oreillyit.nz" type: public upstream: "https://10.111.1.100:443" # Traefik VIP upstream_tls_insecure: true # Traefik uses self-signed certs comment: "" ``` Deploy: ```bash ansible-playbook -i external/vps/inventory.yml external/vps/playbook.yml --tags compose ``` Caddy automatically obtains a Let's Encrypt certificate on first request. ## Step 7: DNS Add a Cloudflare A record for `.oreillyit.nz` pointing to the VPS IP (`43.224.182.153`). Use orange cloud (proxied) for most services. ## Step 8: Verify ```bash # ArgoCD status kubectl get application -n argocd # Pod health kubectl get pods -n # PVC status (if applicable) kubectl get pvc -n # External access curl -sk -o /dev/null -w "%{http_code}" https://.oreillyit.nz/ ``` ## Common Pitfalls | Pitfall | Symptom | Prevention | |---------|---------|------------| | Missing PodSecurity labels | `violates PodSecurity "baseline"` in StatefulSet events | Always check if any container needs privileged mode | | Container runs as non-root | Permission denied on writable dirs | Check image docs / `docker inspect` before writing manifests | | ArgoCD polling delay | New app doesn't appear after push | Annotate root app with `refresh=normal` | | StatefulSet CrashLoopBackOff | Updated spec not applied to pod | Delete the crashlooping pod manually | | Gitea repo permissions | `not authorized to write` on push | Grant collaborator access before pushing | | Proxmox CSI lock contention | PVC provisioning failures with lock timeout | Transient — CSI retries automatically | | Caddy not configured | Connection refused or SSL error on domain | Add site to inventory.yml and deploy with Ansible |