Skip to content

Day-2 apps

Platform is waves 0–9 plus observability at wave 10. Your apps start at wave 11 or higher so they do not share a wave with kube-prometheus-stack.

Validation

Do not add a workload Ingress until first app already showed Certificate Ready, dig = ingress VIP, and a browser padlock. A second hostname on a broken path is not a test.

Do not add workload charts to this starter repo. Add a new Application in your template copy.

Pattern

Most day-2 apps are chart + values (two sources) or manifests only. Add a third path: …/manifests source when the chart will never own the object: a CR (pool, issuer, ObjectStore), a SealedSecret next to a chart-owned Secret, or an extra Ingress (Argo webhook). Full table: Application sources.

applications/my-app.yaml:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-app
  namespace: argocd
  annotations:
    argocd.argoproj.io/sync-wave: "11"
spec:
  project: default
  sources:
    - repoURL: https://github.com/YOUR_GITHUB/homelab-gitops-starter.git
      targetRevision: main
      ref: values
    - repoURL: https://example.chart.repo
      chart: some-chart
      targetRevision: "1.2.3"
      helm:
        releaseName: my-app
        valueFiles:
          - $values/values/my-app/values.yaml
  destination:
    server: https://kubernetes.default.svc
    namespace: my-app
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
      - RespectIgnoreDifferences=true

Add a Namespace in values/namespaces/ if you want PSS labels before the app syncs (wave 0).

Internal Ingress (LAN + Step-CA)

Hostname in k8s.home.example.com. DNS: local DNS. TLS: Step-CA.

cert-manager.io/cluster-issuer: step-issuer is wrong. StepClusterIssuer is not a cert-manager ClusterIssuer.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-app
  annotations:
    cert-manager.io/issuer: step-issuer
    cert-manager.io/issuer-kind: StepClusterIssuer
    cert-manager.io/issuer-group: certmanager.step.sm
spec:
  ingressClassName: nginx
  tls:
    - hosts: ["my-app.k8s.home.example.com"]
      secretName: my-app-tls
  rules:
    - host: my-app.k8s.home.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: my-app
                port:
                  number: 80

After sync: dig +short my-app.k8s.home.example.com from a laptop should be the ingress VIP, and kubectl get certificate -n my-app should be Ready.

Public Ingress (Let's Encrypt)

metadata:
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
    acme.cert-manager.io/http01-edit-in-place: "true"
    cert-manager.io/issue-temporary-certificate: "true"
spec:
  ingressClassName: nginx
  tls:
    - hosts: ["app.k8s.example.com"]
      secretName: my-app-public-tls
  rules:
    - host: app.k8s.example.com
      # ...

http01-edit-in-place is required on this stack (cert-manager Ingress annotations). Without it, cert-manager creates a second Ingress for /.well-known/acme-challenge. F5 nginx does not merge two Ingresses for the same host the way you need, and that solver object is not the Service that owns the pinned MetalLB /32. Let's Encrypt's HTTP-01 then never hits the ingress VIP. Edit-in-place adds the challenge path to this Ingress so port 80 on .30 serves it.

issue-temporary-certificate gives nginx a self-signed secret so the Ingress is accepted while ACME runs. Drop it and the controller may ignore the host until tls.secretName exists — which never happens, because the challenge never ran.

selfHeal: true will strip the ACME path cert-manager adds under spec.rules. Ignore that Ingress on the Application (same as Argo webhook). RespectIgnoreDifferences=true is already on the example Application above.

ignoreDifferences:
  - group: networking.k8s.io
    kind: Ingress
    name: my-app   # metadata.name of the public Ingress
    jsonPointers:
      - /spec/rules

The name must resolve on the internet to whatever faces port 80 (WAN DNAT to the ingress VIP). Staging first. Step-CA Ingresses do not need these two annotations or this ignore.

Argo’s GitHub webhook is the path-only version of this: a second public Ingress on /api/webhook Exact, UI stays on the LAN. Wave 7.

LAN hosts that are not Pods

Unraid, the router, a Pi, or any other box on the LAN is an Ingress → Service → EndpointSlice (not the deprecated Endpoints object). nginx still terminates TLS. Full walkthrough: LAN apps behind Ingress.

Optional CNPG Cluster

After wave 8, a one-instance Cluster is enough for a homelab app:

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: my-app-db
spec:
  instances: 1
  storage:
    size: 8Gi
    storageClass: longhorn   # not nfs, not csi-s3 — see storage wave

Seal the owner password (bootstrap.initdb.secret.name). Do not enable HA (instances: 2+) until you have disk and a reason.

App pods get a user/database from that Cluster (or a Role / extra Database CR in current CNPG). Point the app at my-app-db-rw in the same namespace. Do not put the Superuser password in the app Deployment.

CNPG backup to MinIO

Wave 9 installs the Barman Cloud plugin. Per database, in the app namespace (third source or a manifests-only Application):

apiVersion: barmancloud.cnpg.io/v1
kind: ObjectStore
metadata:
  name: my-app-minio
  namespace: my-app
spec:
  retentionPolicy: "7d"
  configuration:
    destinationPath: s3://cnpg-backups/my-app
    endpointURL: http://10.0.0.2:9000
    s3Credentials:
      accessKeyId:
        name: cnpg-barman-s3
        key: ACCESS_KEY_ID
      secretAccessKey:
        name: cnpg-barman-s3
        key: ACCESS_SECRET_KEY
    wal:
      compression: gzip
    data:
      compression: gzip
# on the Cluster
spec:
  instances: 1
  plugins:
    - name: barman-cloud.cloudnative-pg.io
      isWALArchiver: true
      parameters:
        barmanObjectName: my-app-minio
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
  name: my-app-db-daily
  namespace: my-app
spec:
  immediate: true
  schedule: "0 0 3 * * *"   # CNPG is six-field cron (seconds first)
  backupOwnerReference: self
  method: plugin
  pluginConfiguration:
    name: barman-cloud.cloudnative-pg.io
  cluster:
    name: my-app-db

Seal cnpg-barman-s3 in my-app. Restore is a new Cluster with bootstrap.recovery pointing at that ObjectStore — follow current plugin usage, it moves between versions. Test recovery once on a dummy Cluster before the disk you care about dies.