Adding a Workload¶
Workloads live in JanWelker/homelab-apps,
not here. The apps ApplicationSet generates one ArgoCD Application per
*/application.yaml in that repository, so adding an application is creating a
directory there and pushing it. Why the split exists is in
GitOps Strategy.
What the platform already does for you¶
| You want | You write | The platform does |
|---|---|---|
| A hostname | An HTTPRoute with hostnames: [app.k8s.wlkr.ch] |
external-dns publishes the Route53 record |
| HTTPS | Nothing | apps-gateway terminates TLS with a *.k8s.wlkr.ch wildcard |
| Storage | A PVC with storageClassName: rook-ceph-block |
Rook-Ceph replicates it three ways, one copy per host |
| A database | A CloudNativePG Cluster |
The operator generates credentials and runs failover |
| A secret from outside the cluster | An ExternalSecret |
OpenBao holds it; ESO renders a Secret |
| Metrics | A ServiceMonitor or PodMonitor |
Prometheus scrapes it |
| Logs | Nothing | Alloy ships every container's stdout to Loki |
| Backups | Nothing, for a PVC | Velero snapshots it nightly |
The rules¶
Restated in the workloads repository's CONVENTIONS.md, which is the copy to
keep current.
- Exactly one
application.yamlper directory, a complete ArgoCDApplication: the generator copies its labels, annotations, finalizers andspecthrough unchanged. project: apps. Anything else fails the whole ApplicationSet. The project, inpayload/platform/argocd-projects/projects.yaml, lists the chart repositories workloads may pull from; a new chart repository extends that list first, in this repository.- The workload owns its namespace. Ship a
namespace.yamlat sync wave-2with the three Pod Security Admission labels, annotatedPrune=false, because pruning aNamespacedeletes every PVC inside it. A namespace created byCreateNamespace=truehas no labels and enforces nothing. - PostgreSQL is a CloudNativePG
Cluster, never a chart's bundled database — see the contract. - Ship a
CiliumNetworkPolicyat sync wave-2, not a plainNetworkPolicy, which blocks health probes — see Security Policies. - Official upstream sources only: the vendor's chart or image, not a repackager's.
- Pin every version and let Renovate move it. No
latest.
Step 1: Create the directory¶
Step 2: Write the Application¶
my-app/application.yaml, for an app built from plain manifests:
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: my-app
namespace: argocd
annotations:
argocd.argoproj.io/manifest-generate-paths: .
spec:
project: apps
source:
repoURL: https://github.com/JanWelker/homelab-apps.git
targetRevision: HEAD
path: my-app
directory:
exclude: "application.yaml"
destination:
server: https://kubernetes.default.svc
namespace: my-app
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
# No CreateNamespace: namespace.yaml below creates it, with the Pod
# Security labels an implicitly created one would not have.
- ServerSideApply=true
For a Helm chart that also needs manifests of its own, use a sources list
with the chart in one entry and this repository in another;
nextcloud/application.yaml is the worked example. Workloads keep selfHeal,
unlike platform Applications under RollingSync — see
what the staging costs.
Step 3: Add the manifests¶
my-app/
├── application.yaml
├── namespace.yaml # Pod Security labels, sync wave -2
├── database.yaml # CloudNativePG Cluster, sync wave -1, if it needs one
├── deployment.yaml
├── service.yaml
├── httproute.yaml # if you want it reachable
└── networkpolicy.yaml # CiliumNetworkPolicy, sync wave -2
The namespace¶
---
apiVersion: v1
kind: Namespace
metadata:
name: my-app
labels:
pod-security.kubernetes.io/enforce: baseline
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted
annotations:
# Pruning a Namespace deletes the PVCs inside it.
argocd.argoproj.io/sync-options: Prune=false
argocd.argoproj.io/sync-wave: "-2"
Set enforce to what the image demonstrably needs (restricted if it runs
non-root, baseline if not) and leave audit and warn stricter so the gap
stays visible.
If it needs a database¶
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: my-app-db
namespace: my-app
annotations:
# Before the application: ArgoCD has a health check for this kind and
# waits for "Cluster in healthy state" before starting the next wave.
argocd.argoproj.io/sync-wave: "-1"
spec:
instances: 1
storage:
size: 10Gi
storageClass: rook-ceph-block
bootstrap:
initdb:
database: my-app
owner: my-app
That produces a Secret my-app-db-app holding username, password, host,
port, dbname and a ready-assembled uri; point the application at those
keys. The operator polls each instance on port 8000 from cnpg-system, so
the network policy below must admit it.
The network policy¶
---
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: default-ingress
namespace: my-app
annotations:
# With the namespace, ahead of the database at -1.
argocd.argoproj.io/sync-wave: "-2"
spec:
endpointSelector: {}
ingress:
# Within the namespace, and the node for health probes.
- fromEndpoints:
- {}
- fromEntities:
- host
- remote-node
- ingress
# Only if a CloudNativePG Cluster is in this namespace.
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: cnpg-system
toPorts:
- ports:
- port: "8000"
protocol: TCP
Wave -2 is load-bearing: at the default wave the policy is applied after the
Cluster at -1, which cannot go Healthy until the cnpg-system rule exists,
so the sync parks at -1 forever. Drop ingress from fromEntities if the
Gateway does not reach this workload directly (a namespace fronted by
Authentik's outpost takes its traffic from the authentik namespace).
Exposing it¶
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: my-app
namespace: my-app
spec:
parentRefs:
- name: apps-gateway
namespace: kube-system
sectionName: https
hostnames:
- "my-app.k8s.wlkr.ch"
rules:
- backendRefs:
- name: my-app
port: 80
DNS and TLS need nothing further — see Gateway API.
Putting a workload behind Authentik¶
- It speaks OIDC: the
oauth2providerblueprint lives in the workload's directory; its client credentials come fromscripts/bao-secrets.shunderkv/<app>/configplus one projected-volume source inpayload/platform/authentik/application.yaml; the discovery URI isauth.k8s.wlkr.ch. - It does not: add a
proxyprovideron the embedded outpost, point theHTTPRouteatauthentik-serverin theauthentiknamespace, and add the namespace topayload/platform/authentik/referencegrant.yaml.
Either way it is two pull requests, one here, because a workload must not be able to put itself behind or take itself out from behind the cluster's authentication layer. Details in Authentik.
Step 4: Commit and push¶
The ApplicationSet picks the directory up within about three minutes, or
immediately after Refresh on the apps ApplicationSet in the ArgoCD UI.
Step 5: Document it¶
The page goes in the workloads repository at docs/my-app.md, registered in
its zensical.toml, which publishes to
homelab-apps.wlkr.ch.
Both sites build with --strict, so an unregistered page fails CI. Say what
the application is for, which hostname it answers on, what it stores and
where, and what surprised you while deploying it.
Troubleshooting¶
| Symptom | Cause and check |
|---|---|
| The Application was never generated | Only */application.yaml, one level deep, is read, and a spec.project other than apps fails the whole set naming the file. Read the controller log (below) |
| The Application exists but will not sync | On a fresh cluster 12-workloads may not be reached yet; the apps ApplicationSet does not exist before then. Check the stage status in GitOps Strategy |
Synced, health Unknown, workload running fine |
A wave is waiting on a resource that will never go Healthy; with a database it is the missing cnpg-system rule (kubectl get cluster says 1/1, status reads Instance Status Extraction Error: HTTP communication issue). Confirm with kubectl -n argocd get application my-app -o jsonpath='{.status.operationState.message}' and the Hubble check below |
| Rule is in Git, still stuck | The policy's sync wave is wrong: the applied list has no CiliumNetworkPolicy while Cluster reads Running. Fix the wave, then argocd app terminate-op my-app; a stuck operation does not pick up a new revision |
| Pods will not start; events mention a security policy | enforce in namespace.yaml is stricter than the image needs. Loosen enforce, keep audit and warn, so violations stay in the audit log — see Pod Security Admission |
Login redirects in a loop, or every link is http:// |
Requests arrive from Cilium's Envoy inside the pod CIDR (10.244.0.0/16) with TLS terminated at the Gateway. Set the application's trusted-proxy and "overwrite protocol" settings |
# Why an Application was not generated
kubectl -n argocd logs deploy/argocd-applicationset-controller | tail -50
# Drops from the operator, on the node running the database
kubectl -n kube-system exec <cilium-pod-on-that-node> -c cilium-agent -- \
hubble observe --verdict DROPPED --from-namespace cnpg-system --last 20
# What the stuck sync has applied so far
kubectl -n argocd get application my-app -o jsonpath=\
'{range .status.operationState.syncResult.resources[*]}{.kind}{"\t"}{.hookPhase}{"\n"}{end}'