feat(synapse): update ingress to use traefik ingress class and bump version feat(syncthing-discovery): introduce syncthing discovery service with deployment and ingress feat(syncthing-relay): add syncthing relay server with deployment and ingress configuration fix(taiga): update liveness and readiness probes to use tcpSocket for health checks fix(taiga): change PVC access mode to ReadWriteMany for media and static storage feat(traefik): add icon and ignore rules for traefik service docs(ushahidi): add notes for Redis configuration and Laravel startup probe adjustments feat(ushahidi): implement dedicated Redis deployment for Ushahidi fix(vllm): update deployment strategy and readiness/liveness probes for improved stability fix(writefreely): pin writefreely image version to v0.15.1 for consistency docs(zulip): add notes for TLS-terminating reverse proxy configuration and expected behavior
23 KiB
Adding Wild Cloud Apps
For contributors and maintainers creating or modifying Wild Cloud app packages. For using existing apps, see README.md.
Directory Structure
Each app has a two-level structure: an app.yaml meta file at the root, and version-specific files inside versions/. Name version directories by slot (typically the major version), not the full version string. The actual version lives in manifest.yaml inside the slot.
myapp/
├── app.yaml # App identity, latest slot pointer, upgrade routing
├── notes.md # App-specific operational notes (optional but encouraged)
└── versions/
├── 2/ # Current latest slot (manifest.yaml has version: 2.3.1)
│ ├── manifest.yaml # Version-specific config (requires, defaultConfig, etc.)
│ ├── kustomization.yaml
│ └── *.yaml # Kubernetes resource templates
└── 1/ # Waypoint slot (only if upgrade routing needs it)
├── manifest.yaml
├── kustomization.yaml
└── *.yaml
Most apps have one version directory. Add a second only when a waypoint is needed for upgrade routing.
Required Files
Every app directory must contain:
app.yaml— App identity, latest slot pointer, and upgrade routing rulesversions/{slot}/manifest.yaml— Version-specific configuration schemaversions/{slot}/kustomization.yaml— Kustomize configuration with Wild Cloud labelsversions/{slot}/*.yaml— Kubernetes resource templates
App Meta (app.yaml)
name: immich
is: immich
description: Immich is a self-hosted photo and video backup solution that allows you to store, manage, and share your media files securely.
icon: https://immich.app/assets/images/logo.png
latest: "1"
| Field | Required | Description |
|---|---|---|
name |
Yes | App identifier — must match directory name |
is |
Yes | Unique id used for requires mapping |
description |
Yes | Brief description shown in listings |
icon |
Yes | URL to app icon for UI display |
category |
Yes | Category for filtering in the App Directory |
latest |
Yes | Slot name — directory name under versions/ (not a version string) |
upgrade |
No | Upgrade routing rules (see Upgrade Metadata below) |
Categories
Use an established category when one fits. Introduce a new one only when an app clearly doesn't belong anywhere existing.
| Category | Use for |
|---|---|
services |
Wild Cloud infrastructure (traefik, metallb, cert-manager, etc.) |
database |
Database engines (postgres, mysql, redis, memcached) |
productivity |
Wikis, project management, file sharing, collaboration |
business |
ERP, CRM, accounting, forms, marketing automation |
communication |
Chat, messaging, video conferencing, email |
social |
Social networks and fediverse apps |
community |
Community organizing, civic tech, decision-making |
education |
Learning management systems |
developer |
Dev tools, code hosting, package management |
security |
VPN, password management |
ai |
AI/ML inference and interfaces |
media |
Photo and video management |
Version Manifest (versions/{slot}/manifest.yaml)
version: 1.135.3-1
requires:
- name: pg
alias: db # Use a different reference name in templates
- name: redis # 'alias' and 'installedAs' default to 'name' value
defaultConfig:
namespace: immich
externalDnsDomain: "{{ .cloud.domain }}"
storage: 250Gi
cacheStorage: 10Gi
domain: immich.{{ .cloud.domain }}
tlsSecretName: wildcard-wild-cloud-tls
db:
host: "{{ .apps.pg.host }}"
name: immich
user: immich
redis:
host: "{{ .apps.redis.host }}"
defaultSecrets:
- key: password
- key: dbUrl
default: "postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?pool=30"
requiredSecrets:
- db.password
- redis.auth
| Field | Required | Description |
|---|---|---|
version |
Yes | App version (see Versioning Convention below) |
requires |
No | Dependency apps with optional aliases |
defaultConfig |
Yes | Default configuration merged into the operator's config.yaml |
defaultSecrets |
No | This app's secrets — no default value means auto-generated |
requiredSecrets |
No | Secrets from dependency apps — format: <app-ref>.<key> [WC-DOT] |
Versioning Convention
Use the two-part scheme <upstream>-<revision>:
- Upstream version tracks the third-party software (e.g.,
1.120.2,v4.0.18) - Packaging revision tracks Wild Cloud packaging changes with no upstream change
Examples:
v4.0.18— initial packagingv4.0.18-1— first packaging fix, no upstream changev4.0.19— upstream version bump, revision resets
Bump the packaging revision for: manifest field changes, template improvements, security context fixes, label corrections, etc.
Bump the upstream version when updating the container image tag.
Slot Naming Convention
- Use the major version as the slot name (
1,2,v3) - Keep the
vprefix if upstream uses it (v1for cert-manager) - Never put packaging revisions (
-1,-2) in directory names - Never put minor/patch versions in directory names unless you need a distinct waypoint
| App | Slot name | Version in manifest |
|---|---|---|
| Ghost 5.118.1-2 | 5 |
5.118.1-2 |
| cert-manager v1.17.2 | v1 |
v1.17.2 |
| Immich 1.135.3-1 | 1 |
1.135.3-1 |
When bumping versions, update files inside the existing slot. Only create a new directory when you need a waypoint.
Upgrade Metadata
The upgrade field is optional — omit it for apps where any version can upgrade directly to any other. That covers 90% of apps.
Add upgrade: only when an app has breaking changes that need a controlled path (e.g., Discourse requires stepping through major versions).
The upgrade block in app.yaml
name: myapp
latest: "3"
upgrade:
from:
- version: ">=3.5.0"
- version: ">=3.4.0"
via: "2" # Must pass through slot "2" first
- version: "<3.4.0"
blocked: true
notes: "Requires sequential major upgrades. See upstream docs."
preUpgrade:
backup: required # "none", "recommended", or "required"
latest and via are slot names (directory names), not version strings.
| Field | Description |
|---|---|
from[].version |
Version constraint: >=, >, <=, <, =, or >0 |
from[].via |
Waypoint slot to pass through first |
from[].blocked |
Block upgrade with an error message |
from[].notes |
Message shown when blocked or as context |
preUpgrade.backup |
"required" blocks until backup is done; "recommended" warns |
Version-specific migration behavior goes in the version's manifest.yaml:
# versions/3/manifest.yaml
version: 3.6.0
upgrade:
migrations:
pre:
- migrations/pre-deploy.yaml
post:
- migrations/post-deploy.yaml
configMigrations:
oldKeyName: newKeyName
| Field | Description |
|---|---|
migrations.pre |
K8s Job YAMLs to run before deploying this version |
migrations.post |
K8s Job YAMLs to run after deploying |
configMigrations |
Map of old config key → new config key for automatic renaming |
Waypoint versions
When an upgrade requires an intermediate step, add the waypoint as a new slot alongside latest:
myapp/
├── app.yaml # latest: "3", routing rules
└── versions/
├── 3/ # Latest (version: 3.6.0)
└── 2/ # Waypoint (version: 2.8.0)
To create a waypoint: leave the current latest slot in place and create a new directory for the new major version. Update app.yaml to point latest at the new slot and add routing rules.
Migration jobs
Migration jobs are K8s Job manifests. Write them idempotent — safe to re-run if an upgrade is retried.
Place migration files in the version slot and reference them from manifest.yaml:
# versions/3/migrations/db-migrate.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: myapp-db-migrate
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: migrate
image: myapp:3.6.0
command: ["bundle", "exec", "rake", "db:migrate"]
pre— schema changes the new code needs before it startspost— backfills or cleanup after the new version is running
Config key renames
Use configMigrations to automatically rename config keys during upgrade:
# versions/2/manifest.yaml
version: 2.1.0
upgrade:
configMigrations:
dbHost: db.host
dbPort: db.port
Adding a New Version
Simple version bump (no breaking changes)
Update files inside the existing slot. Do not rename directories or change app.yaml.
vi wild-directory/myapp/versions/1/manifest.yaml # bump version
vi wild-directory/myapp/versions/1/deployment.yaml # update image tag
wild app add myapp && wild app deploy myapp
Breaking changes (waypoint required)
mkdir -p wild-directory/myapp/versions/3
# add manifest.yaml, kustomization.yaml, *.yaml for the new version
# update app.yaml: set latest: "3", add upgrade routing rules with via: "2"
Schema migrations
Add migration job files to the slot directory and reference them from manifest.yaml:
# versions/2/manifest.yaml
version: 2.1.0
upgrade:
migrations:
pre:
- migrations/pre-deploy.yaml
Migration jobs must be idempotent. Use CREATE IF NOT EXISTS, ALTER TABLE IF NOT EXISTS, etc.
Dependencies
requires:
- name: postgres
alias: db
- name: redis
name— the app to depend on (any installed app with a matchingisfield satisfies it)alias— reference name in templates (defaults toname)
At installation time, the operator maps each dependency to an actual installed app instance. The system tracks this mapping via installedAs in the local manifest.
Template Variables
All template variables referenced in resource files must be defined in defaultConfig.
Variable namespaces
{{ .cloud.* }},{{ .cluster.* }},{{ .operator.* }}— standard Wild Cloud variables{{ .app.* }}— this app's config{{ .apps.<ref>.* }}— dependency app config, resolved via alias or name{{ .secrets.* }}— this app's secrets (indefaultSecretsonly)
Available configuration variables
operator
operator.email
cloud
cloud.domain,cloud.baseDomain,cloud.internalDomaincloud.dnsmasq.ip,cloud.dnsmasq.interfacecloud.router.ip,cloud.router.dynamicDns,cloud.dhcpRangecloud.nfs.host,cloud.nfs.mediaPath,cloud.nfs.storageCapacitycloud.dockerRegistryHostcloud.backup.root
cluster
cluster.name,cluster.hostnamePrefixcluster.nodes.talos.version,cluster.nodes.talos.schematicIdcluster.nodes.control.vipcluster.ipAddressPool,cluster.loadBalancerIpcluster.certManager.cloudflare.domain,cluster.certManager.cloudflare.zoneIDcluster.externalDns.ownerIdcluster.dockerRegistry.storage
apps
Each deployed app gets a section under apps.<name>.*. Common fields:
apps.<name>.namespace,domain,externalDnsDomain,tlsSecretNameapps.<name>.db.host,db.port,db.name,db.userapps.<name>.redis.hostapps.<name>.smtp.host,smtp.port,smtp.user,smtp.from,smtp.tls,smtp.startTls
Dependency reference resolution
{{ .apps.<ref>.* }} resolves by checking alias first, then name. The system uses the installedAs value to find the actual app config.
Kustomization (kustomization.yaml)
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: immich
labels:
- includeSelectors: true
pairs:
app: immich
managedBy: kustomize
partOf: wild-cloud
resources:
- deployment-server.yaml
- ingress.yaml
- namespace.yaml
- pvc.yaml
- service.yaml
- db-init-job.yaml
- Set
namespaceto the app name[WC-NS] - Include standard Wild Cloud labels with
includeSelectors: true[WC-SEL] - List every resource file under
resources:[WC-RES]
includeSelectors: true automatically injects the standard labels into resource selectors. Individual resources can use simple component labels (component: web) — Kustomize expands them.
Do not use Helm-style labels (app.kubernetes.io/name, etc.). Use component: web, component: worker, etc. [WC-HELM]
Configuration Templates
Resource files are gomplate templates compiled when operators add apps. Only variables defined in defaultConfig are available to resource templates.
Ingress
Use this as the standard ingress template:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp
annotations:
external-dns.alpha.kubernetes.io/target: {{ .externalDnsDomain }}
external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"
external-dns.alpha.kubernetes.io/ttl: "60"
spec:
ingressClassName: traefik
rules:
- host: {{ .domain }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: myapp
port:
number: 80
tls:
- hosts:
- {{ .domain }}
secretName: {{ .tlsSecretName }}
- Always use
spec.ingressClassName: traefik— do not use the deprecatedkubernetes.io/ingress.classannotation[WC-ING-ANN][WC-ING-CLS] - Do not add
cert-manager.io/cluster-issuer— the wildcard TLS cert is pre-distributed to namespaces; this annotation overwrites it with a wrong per-app cert[WC-CERT] - Do not add Traefik redirect annotations — HTTPS redirect is handled globally
- Define
externalDnsDomain,domain, andtlsSecretNameindefaultConfig
The external-dns.alpha.kubernetes.io/target annotation creates a CNAME from the app subdomain to the cluster domain. Always set cloudflare-proxied: "false" since Wild Cloud manages its own TLS. [WC-DNS]
Database Setup
Database Initialization Jobs
Include a db-init-job.yaml for every app that uses a database. The job must:
- Create the application database if it doesn't exist
- Create/update the application user with the correct credentials
- Grant permissions
- Install any required database extensions (
vector,bloom,pg_trgm, etc.) - Use
restartPolicy: OnFailure - Be listed in
kustomization.yamlresources - Use the appropriate security context (
runAsUser: 999for PostgreSQL) - Be idempotent — safe to re-run after a redeploy
See immich, gitea, or openproject for reference implementations.
Database URLs
When apps need a database URL with embedded credentials, use a dedicated dbUrl secret — do not construct URLs in-place:
# Wrong: Kustomize cannot do runtime env var substitution
- name: DB_URL
value: "postgresql://user:$(DB_PASSWORD)@host/db"
# Correct: use a secret with the full URL
- name: DB_URL
valueFrom:
secretKeyRef:
name: myapp-secrets
key: dbUrl
Add dbUrl to defaultSecrets with the URL template as the default value.
Database Env Var Naming
Name database-related env vars so the backup system can identify them:
- Database name: include
DATABASE,DB_NAME,DBNAME, or__DATABASEin the env var name - Database URLs: the value must contain
:// - Usernames: include
USERin the name — these are not patched even if the value matches the DB name
- name: DB_NAME # patched on restore
value: myapp
- name: DB_USER # not patched (contains USER)
value: myapp
- name: DATABASE_URL # patched (value contains ://)
value: "postgresql://myapp:secret@postgres/myapp"
Deployment Strategy [WC-RWO]
Set strategy: type: Recreate on any Deployment that uses a ReadWriteOnce PVC. RWO volumes attach to one pod at a time — RollingUpdate causes Multi-Attach errors when the new pod tries to mount while the old one still holds the volume.
spec:
replicas: 1
strategy:
type: Recreate
Security Contexts [WC-SC-POD] [WC-SC-CTR]
Set security contexts at both pod and container levels on all workloads:
spec:
template:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: app
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: false
Common UIDs: PostgreSQL 999, Redis 999. Consult the image docs for others.
For images that must run as root (s6-overlay, linuxserver.io), omit runAsNonRoot from the pod context and suppress [WC-SC-POD] / [WC-SC-CTR] with ignoreRules if the upstream manifest doesn't support it.
Secrets
Define app secrets in defaultSecrets. Reference secrets from dependencies in requiredSecrets.
defaultSecrets:
- key: dbPassword
- key: apiKey
requiredSecrets:
- db.password # from postgres (aliased as 'db')
- redis.auth
Reference them in resources as:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: myapp-secrets
key: dbPassword # own secret
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: myapp-secrets
key: db.password # required secret
Never hardcode secret values in templates. Never commit secrets.yaml.
Converting Helm Charts
When an official Helm chart exists, convert it rather than writing manifests from scratch.
helm fetch --untar --untardir charts repo/chart-name
helm template --output-dir base --namespace myapp --values values.yaml myapp charts/chart-name
Then convert to Wild Cloud format:
- Replace hardcoded values with gomplate variables (
{{ .cloud.domain }}, etc.) - Replace Helm labels with Wild Cloud standard labels
- Add
includeSelectors: trueto kustomization - Use simple component labels (
component: web, notapp.kubernetes.io/name) - Add security contexts
- Add external-dns annotations to ingresses
- Update secrets to use dotted-path convention
Helm style → Wild Cloud style:
# Remove:
labels:
app.kubernetes.io/name: myapp
app.kubernetes.io/instance: release-name
# Add to kustomization.yaml:
labels:
- includeSelectors: true
pairs:
app: myapp
managedBy: kustomize
partOf: wild-cloud
# In individual resources:
labels:
component: server
Post-Deploy Scripts
Some apps require a management command after first deploy to create an admin account, register a node, or invite the first user. Package these as shell scripts in scripts/ alongside the kustomize files.
Register every script in manifest.yaml — the web UI shows a parameter form for each one:
scripts:
- name: create-superuser
path: scripts/create-superuser.sh
description: "Create the initial admin account."
params:
- name: EMAIL
required: true
- name: PASSWORD
description: Leave blank to generate a random one
Follow synapse/versions/v1/scripts/create-user.sh as the reference implementation. See docs/scripts.md for the full conventions and common commands.
Common Gotchas
Keep app-specific operational notes in a notes.md file inside the app directory.
Image availability
Before packaging, confirm the image pulls without authentication (docker pull <image>). Bitnami images moved off Docker Hub; ghcr.io images often require auth even for public repos. If no public image exists, add ignoreRules: [WC-IMG] with a comment.
After template changes
After editing any file in wild-directory, re-run wild app add <app> before deploying — compiled templates in the instance directory are not auto-updated.
PostgreSQL
If using PostgreSQL, see docs/database.md — sslmode=disable, db-init job pattern, URL construction, env var naming.
MySQL
If using MySQL, see docs/database.md — db-init idempotency (CREATE USER + ALTER USER), required secret references.
Redis
If using Redis, see docs/redis.md — authenticated URL pattern, apps that don't support passwords.
Runtime memory, startup delays, health probes
If the app uses Node.js, Python, PHP, Ruby, JVM, nginx, or linuxserver.io images, see docs/.
Hex-format secrets
If an app requires a hex secret (e.g. exactly 64 chars for Outline), derive one with crypto.SHA256:
defaultSecrets:
- key: utilsSecret
- key: secretKey
default: '{{ crypto.SHA256 .secrets.utilsSecret }}'
Referenced secrets must appear earlier in the list.
nginx proxy DNS [WC-DKRDNS]
If the app uses an nginx proxy config from Docker, see docs/nginx.md — 127.0.0.11 doesn't exist in Kubernetes.
Traefik "No available server" [WC-SEL]
If Traefik returns "No available server" after a redeploy, see docs/traefik.md — immutable selector fix.
Same-origin iframes
If the app embeds its own sub-pages in iframes and they break, see docs/traefik.md — X-Frame-Options interaction.
Validation
Run the validation script before submitting any new or modified app:
# From the wild-directory root:
admin/scripts/validate-app.sh <app-name>
# Or from inside your app directory:
../../admin/scripts/validate-app.sh
Errors must be fixed before submitting. Warnings are strong suggestions — fix them unless you have a specific documented reason not to.
For the full list of rule IDs, severities, and fix instructions:
admin/scripts/validate-apps.py --list-rules
Suppressing rules:
If your app has a legitimate reason to deviate (e.g. packaging upstream Helm-rendered manifests), add ignoreRules to app.yaml with a comment:
ignoreRules:
- WC-HELM # upstream Helm-rendered manifests
- WC-SEL # upstream manifests; selectors are set by the Helm chart
Only suppress rules when genuinely necessary.
Submission Checklist
Run the validator first — it catches most issues automatically:
admin/scripts/validate-app.sh <app-name>
Errors must be fixed. Warnings must be fixed or suppressed with a comment in ignoreRules.
Then verify the things the validator can't check:
- Docker images pull without authentication (
docker pull <image>) - App deploys and runs correctly in the
test-cloudinstance - Dependencies (database, redis, etc.) work correctly end-to-end
Contributing
- Fork the repository
- Create a new app directory following the structure above
- Test your app in
test-cloud - Submit a pull request with a description of the app and any special notes
Notice: Third-Party Software
The Kubernetes manifests and Kustomize files in this directory are designed to deploy third-party software.
Unless otherwise stated, the software deployed by these manifests is not authored or maintained by this project. All copyrights, licenses, and responsibilities for that software remain with the respective upstream authors.
These files are provided solely for convenience and automation. Users are responsible for reviewing and complying with the licenses of the software they deploy.
This project is licensed under the GNU AGPLv3 or later, but this license does not apply to the third-party software being deployed.