| name | add-app |
| description | Scaffold a new Kubernetes app in the home-ops monorepo. Prompts for app name, namespace, helm chart type, and optional features (kopiur, cnpg, auth, zeroscaler, gatus, ESO, configMapGenerator, ingress). Generates all manifests following repo conventions. |
Add App Skill
Interactive scaffold for adding a new Flux-managed Kubernetes app to this monorepo.
Workflow
Phase 1 โ Gather Inputs
Use AskUserQuestion to collect the following. Ask up to 4 questions per call, batch logically.
Important: Always ask all batches. Never infer or skip a batch based on arguments passed to the skill โ the user must explicitly confirm each choice.
Batch 1 โ Identity:
- App name (free text via "Other"): The kebab-case name used for directory, metadata, and YAML anchors. Offer 2-3 common suggestions if the user mentioned an app, otherwise present two placeholder options and let them type via Other.
- Namespace: Which namespace? Options:
default, media, home-automation, observability, network. Let them type Other for a new one.
Batch 2 โ Helm & Ingress:
- Helm chart type:
app-template (Recommended) โ bjw-s app-template (most apps use this)
Custom chart โ third-party Helm chart (provide OCI URL + chart name)
- Ingress exposure:
Internal only (Recommended) โ envoy-internal gateway, LAN access only
External โ envoy-external gateway, accessible via Cloudflare tunnel
None โ no HTTP route (headless service, CLI tool, background worker)
Batch 3 โ Features (multi-select, always ask):
- Which optional features does this app need? (multiSelect: true)
Kopiur backups โ PVC backup/restore via kopiur (needs KOPIUR_CAPACITY)
CNPG PostgreSQL โ shared PostgreSQL database via cnpg component
Zeroscaler (truenas) โ native HPA scale to zero when truenas.internal:2049 is down (default ZEROSCALER_JOB_NAME=nfs_probe)
Zeroscaler (clonenas backup) โ native HPA scale to zero when clonenas.internal:2049 is down (sets ZEROSCALER_JOB_NAME=nfs_bkup_probe)
Batch 4 โ Auth & Config (conditional โ only ask if ingress is NOT "None"):
- Authentication method:
None โ app handles its own auth or no auth needed
OIDC (native) โ app supports OIDC natively; just needs ESO secrets for Authentik
Forward auth (internal) โ ext-auth-internal component (Authentik, LAN only)
Forward auth (external) โ ext-auth-external component (Authentik, public)
Batch 5 โ Gatus & Config details (conditional โ only ask if ingress is NOT "None"):
-
Gatus subdomain: What subdomain should this app be accessible at?
Same as app name (Recommended) โ e.g. {APP_NAME}.domain.com
- Other โ let them type a custom/shortened subdomain
-
Health check path: What path should be used for liveness probes and Gatus?
/ (default) โ root path
- Other โ let them type a custom path (e.g.
/healthz, /api/v1/ping)
-
Config style (only if app-template):
Inline env vars only (Recommended) โ all config via env vars in HelmRelease
ESO env vars โ ExternalSecret that maps aKeyless keys to env vars injected via envFrom secretRef (no configMap needed)
ConfigMapGenerator + ESO โ external config file templated with secrets (like immich pattern: configMapGenerator in kustomization.yaml + kustomizeconfig.yaml + ExternalSecret templateFrom)
Phase 2 โ Research Before Generating
Before writing any files, gather the following from the repo:
- app-template version:
grep -h 'tag:' kubernetes/apps/default/homebox/app/ocirepository.yaml
- postgres-init version (if CNPG selected):
grep -rh 'postgres-init' kubernetes/apps/ --include='*.yaml' -A1 | grep 'tag:' | head -1
- Existing secret patterns (if the user mentioned any shared/external service โ e.g. Amazon SES, SMTP, S3, Cloudflare): grep the repo for an existing app known to use that service. For example, if SES/email is requested, check
kubernetes/apps/default/mealie/app/externalsecret.yaml for the exact aKeyless key names and env var mappings already in use. Use matching key names in the new ExternalSecret โ do not invent new names.
Phase 3 โ Generate Manifests
After collecting inputs and completing research, generate all files. Write each file using the Write tool. Do not return file contents inline.
Directory structure
kubernetes/apps/{namespace}/{app-name}/
โโโ ks.yaml
โโโ app/
โโโ kustomization.yaml
โโโ helmrelease.yaml
โโโ ocirepository.yaml
โโโ externalsecret.yaml # only if app needs secrets
# If configMapGenerator pattern:
โโโ resources/
โโโ {app}-config.yaml # config template
โโโ kustomizeconfig.yaml # nameReference for ESO templateFrom
File templates
Use these exact patterns. All templates use YAML anchors (&app / *app) per repo convention.
No extra blank lines between YAML sections โ keep output compact.
ks.yaml
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: &app { APP_NAME }
spec:
components:
- ../../../../components/cnpg
- ../../../../components/kopiur/backup
- ../../../../components/auth/internal
- ../../../../components/auth/external
- ../../../../components/zeroscaler
dependsOn:
- name: secret-stores
namespace: external-secrets
- name: cnpg-cluster
namespace: database
- name: secret-stores
namespace: external-secrets
- name: kopiur
namespace: kopiur-system
interval: 1h
path: ./kubernetes/apps/{NAMESPACE}/{APP_NAME}/app
postBuild:
substitute:
APP: *app
GATUS_SUBDOMAIN: { SUBDOMAIN }
CNPG_NAME: &postgresAppName pgsql-cluster
KOPIUR_CAPACITY: { CAPACITY }
ZEROSCALER_JOB_NAME: nfs_bkup_probe
substituteFrom:
- kind: Secret
name: cluster-secrets
healthChecks:
- apiVersion: &postgresVersion postgresql.cnpg.io/v1
kind: &postgresKind Cluster
name: *postgresAppName
namespace: database
healthCheckExprs:
- apiVersion: *postgresVersion
kind: *postgresKind
failed: status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'False')
current: status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'True')
prune: true
sourceRef:
kind: GitRepository
name: flux-system
namespace: flux-system
targetNamespace: { NAMESPACE }
wait: false
Rules:
- Only include
components, dependsOn, healthChecks, healthCheckExprs, and postBuild.substitute entries that are relevant to the selected features.
- If GATUS_SUBDOMAIN equals the app name, omit it (the HR will use
{{ .Release.Name }}.${SECRET_DOMAIN}).
- If GATUS_SUBDOMAIN differs from app name, include it and use
${GATUS_SUBDOMAIN}.${SECRET_DOMAIN} in the HR route.
wait: false for apps, wait: true only for infrastructure dependencies.
app/ocirepository.yaml
App-template:
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
metadata:
name: &app { APP_NAME }
spec:
interval: 15m
layerSelector:
mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip
operation: copy
ref:
tag: { CURRENT_APP_TEMPLATE_TAG }
url: oci://ghcr.io/bjw-s-labs/helm/app-template
Custom chart:
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
metadata:
name: { APP_NAME }
spec:
interval: 1h
layerSelector:
mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip
operation: copy
ref:
tag: { CHART_VERSION }
url: oci://{CHART_OCI_URL}
app/kustomization.yaml
Standard (no configMapGenerator):
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ./externalsecret.yaml
- ./helmrelease.yaml
- ./ocirepository.yaml
With configMapGenerator (immich pattern):
---
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
resources:
- ./externalsecret.yaml
- ./helmrelease.yaml
- ./ocirepository.yaml
configMapGenerator:
- name: {APP_NAME}-config-tpl
files:
- ./resources/{APP_NAME}-config.yaml
configurations:
- ./resources/kustomizeconfig.yaml
generatorOptions:
disableNameSuffixHash: true
Important: When using configMapGenerator, the kustomization kind changes from Kustomization to Component and the apiVersion changes to v1alpha1.
app/helmrelease.yaml (app-template)
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: &app { APP_NAME }
spec:
chartRef:
kind: OCIRepository
name: *app
interval: 1h
values:
controllers:
*app :
annotations:
reloader.stakater.com/auto: "true"
initContainers:
01-init-db:
image:
repository: ghcr.io/home-operations/postgres-init
tag: { CURRENT_POSTGRES_INIT_TAG }
envFrom:
- secretRef:
name: "{{ .Release.Name }}-initdb-secret"
containers:
app:
image:
repository: { IMAGE_REPO }
tag: latest
env:
TZ: ${TIMEZONE:-UTC}
envFrom:
- secretRef:
name: "{{ .Release.Name }}-secret"
resources:
requests:
cpu: 20m
memory: 128Mi
limits:
memory: 512Mi
probes:
liveness: &probes
enabled: true
custom: true
spec: &probeSpec
httpGet:
path: { HEALTH_PATH }
port: &port { APP_PORT }
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 5
readiness:
<<: *probes
spec:
<<: *probeSpec
periodSeconds: 10
startup:
enabled: true
spec:
failureThreshold: 30
periodSeconds: 10
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
defaultPodOptions:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
service:
app:
controller: *app
ports:
http:
port: *port
route:
app:
hostnames: ["{{ .Release.Name }}.${SECRET_DOMAIN}"]
parentRefs:
- name: envoy-internal
namespace: network
rules:
- backendRefs:
- identifier: app
port: *port
persistence:
config:
existingClaim: *app
globalMounts:
- path: /data
tmp:
type: emptyDir
Rules for the HelmRelease:
- Only include
initContainers block if CNPG is selected.
- Only include
envFrom secretRef if the app has an ExternalSecret (i.e. config style is "ESO env vars", "ConfigMapGenerator + ESO", or any feature that requires secrets like CNPG or OIDC).
- Only include
route section if ingress is not "None".
- Use
envoy-internal or envoy-external based on ingress selection.
- If GATUS_SUBDOMAIN is custom (differs from app name): use
${GATUS_SUBDOMAIN}.${SECRET_DOMAIN} in hostnames.
- If GATUS_SUBDOMAIN equals app name: use
{{ .Release.Name }}.${SECRET_DOMAIN} in hostnames and omit GATUS_SUBDOMAIN from ks.yaml substitute.
- Gatus + ext-auth: When the app uses ext-auth (forward auth), gatus probes are blocked by the auth layer. Move the gatus annotation to the service (with
enabled: "true") and add gatus.home-operations.com/enabled: "false" to the route annotations. See bazarr for the reference pattern.
- Gatus without ext-auth: No action needed, envoy handles the annotations.
- Image tags: Leave
{IMAGE_REPO} and a TODO tag as placeholders โ the user will fill these in.
- Security context: Default to restrictive (
readOnlyRootFilesystem: true, drop ALL capabilities). If an app fails to start due to security context, that is follow-up troubleshooting โ do not pre-emptively add comments or relaxed settings.
- No extra blank lines between YAML sections.
app/externalsecret.yaml
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: &name {APP_NAME}-secret
spec:
secretStoreRef:
kind: ClusterSecretStore
name: akeyless-secret-store
target:
name: *name
template:
data: {}
dataFrom:
- extract:
key: /{AKEYLESS_PATH}
If OIDC auth is selected, add Authentik-specific entries:
target:
name: *name
template:
data:
OIDC_ISSUER_URL: "https://{{ .AUTHENTIK_SSO_SUBDOMAIN }}.${SECRET_DOMAIN}/application/o/${APP}/"
OIDC_CLIENT_ID: "{{ .{APP_UPPER}_OIDC_CLIENT_ID }}"
OIDC_CLIENT_SECRET: "{{ .{APP_UPPER}_OIDC_SECRET }}"
dataFrom:
- extract:
key: /authentik
- extract:
key: /{APP_NAME}
If CNPG is selected, template the DB connection vars directly in the app's ExternalSecret (homebox pattern โ preferred approach). Add /cnpg-users to dataFrom and map individual vars in target.template.data:
target:
template:
data:
DATABASE_HOST: "${CNPG_NAME:=pgsql-cluster}-rw.database.svc.cluster.local"
DATABASE_PORT: "5432"
DATABASE_NAME: "${APP}"
DATABASE_USER: "${APP}"
DATABASE_PASSWORD: "{{ .${APP}_postgres_password }}"
dataFrom:
- extract:
key: /cnpg-users
If the app supports a single connection URI (e.g. gatus DB_URI), build it in the template:
DB_URI: "postgres://{{ .${APP}_postgres_password }}@${CNPG_NAME:=pgsql-cluster}-rw.database.svc.cluster.local:5432/${APP}?sslmode=disable"
If shared/external service secrets are requested (e.g. Amazon SES, SMTP, S3, Cloudflare): use the exact aKeyless key names and env var mappings already established in the repo. Reference was already looked up in Phase 2 research โ do not invent new key names.
app/resources/kustomizeconfig.yaml (only with configMapGenerator)
---
nameReference:
- kind: ConfigMap
version: v1
fieldSpecs:
- path: spec/target/template/templateFrom/configMap/name
kind: ExternalSecret
app/resources/{app}-config.yaml (only with configMapGenerator)
Create a placeholder config file with a TODO comment. The user will populate this with the app's actual config. This file supports Go template syntax because it's processed through the ExternalSecret templateFrom mechanism.
Phase 4 โ Register the App
After generating all manifests:
-
Add to namespace kustomization: Append - ./{APP_NAME}/ks.yaml to kubernetes/apps/{NAMESPACE}/kustomization.yaml under resources:. Keep alphabetical order.
-
Print summary: List all created files and a short description of what was configured.
-
Print next steps:
- Add secrets to aKeyless at the path referenced in externalsecret.yaml
- Fill in
{IMAGE_REPO} and {IMAGE_TAG} in helmrelease.yaml
- Fill in ExternalSecret template data mappings
- If configMapGenerator: populate the config file in resources/
- If custom chart: verify the OCI URL and chart version
Important Notes
- Never create a namespace.yaml โ namespaces already exist, managed by the namespace kustomization.
- app-template version: Always grep the repo for the current tag before generating (see Phase 2).
- postgres-init version: Always grep the repo for the current tag before generating (see Phase 2).
- No Renovate annotations: Do not add
# renovate: comments to image tags โ Renovate config handles discovery automatically.
- YAML anchors: Always use
name: &app {name} and reference as *app throughout.
- Security context: Default to restrictive (
readOnlyRootFilesystem: true, drop ALL capabilities). Do not add comments about relaxing these โ that is follow-up troubleshooting if needed.
- Storage: Default PVC storage class is
ceph-ssd (handled by kopiur component defaults). Only override for openebs-hostpath or nfs-media if needed.
- No extra blank lines: Keep YAML output compact โ no unnecessary blank lines between sections.