Files
wild-directory/ADDING-APPS.md

36 KiB

Adding Wild Cloud Apps

This guide is for contributors and maintainers who want to create or modify Wild Cloud apps. If you're looking to use existing apps, see README.md.

Overview

Wild Cloud apps are Kubernetes applications packaged as Kustomize configurations with standardized conventions for configuration management, secrets handling, and deployment.

Directory Structure

Each app has a two-level structure: an app.yaml meta file at the root, and version-specific files inside versions/. Version directories are named by slot (typically the major version), not by the full version string. The actual version lives in manifest.yaml inside the slot.

myapp/
├── app.yaml               # App identity, latest slot pointer, upgrade routing
└── 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. A second appears only when a waypoint is needed for upgrade routing.

Required Files

Each app directory must contain:

  1. app.yaml - App identity, latest slot pointer, and upgrade routing rules
  2. versions/{slot}/manifest.yaml - Version-specific configuration schema
  3. versions/{slot}/kustomization.yaml - Kustomize configuration with Wild Cloud labels
  4. versions/{slot}/*.yaml - Kubernetes resource templates

App Meta (app.yaml)

The app.yaml file at the app root defines identity, display info, and upgrade routing. These fields are version-independent.

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"

App Meta Fields

Field Required Description
name Yes App identifier (must match directory name)
is Yes Unique id for this app. Used for requires mapping
description Yes Brief app description shown in listings
icon No URL to app icon for UI display
category No Category (e.g., infrastructure)
latest Yes Slot name -- directory name under versions/ (not a version string)
upgrade No Upgrade routing rules (see Upgrade Metadata below)

Version Manifest (versions/{slot}/manifest.yaml)

Each version slot contains a manifest.yaml with version-specific installation details: dependencies, configuration schema, and secret requirements.

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: # Configuration can be nested
    host: "{{ .apps.pg.host }}" # Can reference 'requires' app configurations
    name: immich
    user: immich
  redis:
    host: "{{ .apps.redis.host }}"
defaultSecrets:
  - key: password      # Random value will be generated if empty
  - key: dbUrl
    default: "postgresql://{{ .app.db.user }}:{{ .secrets.dbPassword }}@{{ .app.db.host }}:{{ .app.db.port }}/{{ .app.db.name }}?pool=30"
requiredSecrets:
  - db.password   # References postgres app via 'db' alias
  - redis.auth    # References redis app via 'redis' name (no alias)

Version Manifest Fields

Field Required Description
version Yes App version (see Versioning Convention below)
requires No List of dependency apps with optional aliases
defaultConfig Yes Default configuration values merged into operator's config.yaml
defaultSecrets No This app's secrets (no 'default' = auto-generated)
requiredSecrets No List of secrets from dependency apps (format: <app-ref>.<key>)

Versioning Convention

Wild Cloud uses a two-part version scheme inspired by Debian packaging: <upstream>-<revision>.

  • Upstream version tracks the third-party software version (e.g., v4.0.18, 1.120.2)
  • Packaging revision tracks Wild Cloud packaging changes (template fixes, manifest cleanup, config restructuring) that don't change the upstream software version

Examples:

  • v4.0.18 — initial packaging of upstream v4.0.18
  • v4.0.18-1 — first packaging fix (no upstream change)
  • v4.0.18-2 — second packaging fix
  • v4.0.19 — upstream version bump, revision resets

When to bump the packaging revision: Any change to the app package that doesn't correspond to an upstream software update — manifest field changes, template improvements, kustomize restructuring, security context fixes, label corrections, etc.

When to bump the upstream version: When updating the container image tag or deploying a new version of the third-party software.

The web UI uses version comparison to detect available updates. If the deployed version differs from the wild-directory version, operators see an update indicator and can apply it from the app detail panel.

Slot Naming Convention

Version directory names are slot names, not version strings. The slot is a stable label; the actual version lives in manifest.yaml inside the slot.

Rules:

  • Use the major version as the slot name (e.g., 1, 2, 5, v3)
  • Preserve the v prefix if the upstream project uses it (e.g., v1 for cert-manager)
  • Never put packaging revisions (-1, -2) in directory names
  • Never put minor/patch versions in directory names unless creating a waypoint that needs to be distinct from another slot at the same major version

Examples:

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
Traefik v3.4 v3 v3.4

When bumping versions (upstream or packaging), update files inside the existing slot. Only create a new directory when you need a new waypoint.

Upgrade Metadata

Most apps can upgrade from any version to any other version directly — no special metadata is needed. The upgrade field is optional and only required when an app has breaking changes that need controlled upgrade paths.

When you don't need upgrade: Simple apps (Ghost, Redis, most stateless apps) where any version can safely replace any other version. This is the 90% case — just bump the version and the system handles it as a single-step update.

When you need upgrade: Apps with breaking database schema changes, incompatible config formats, or upstream requirements for sequential version upgrades (e.g., Discourse requires stepping through major versions).

The upgrade block in app.yaml

Upgrade routing rules live in app.yaml, centralized for all versions. The system iteratively re-evaluates these rules after each waypoint step.

# app.yaml
name: myapp
latest: "3"
upgrade:
  from:
    - version: ">=3.5.0"       # Can upgrade directly from 3.5.x
    - version: ">=3.4.0"
      via: "2"                  # Must pass through slot "2" first (a waypoint)
    - version: "<3.4.0"
      blocked: true
      notes: "Requires sequential major upgrades. See upstream docs."
  preUpgrade:
    backup: required            # "none", "recommended", or "required"

Note: latest and via are slot names (directory names), not version strings. The system reads the actual version from the manifest inside each slot.

Version-specific upgrade behavior (migrations, configMigrations) lives in the version's manifest.yaml:

# versions/3/manifest.yaml
version: 3.6.0
upgrade:
  migrations:
    pre:
      - migrations/pre-deploy.yaml   # K8s Job YAML paths relative to version dir
    post:
      - migrations/post-deploy.yaml
  configMigrations:
    oldKeyName: newKeyName      # Renames config keys automatically

app.yaml upgrade fields:

Field Description
from List of version constraint rules, evaluated in order (first match wins)
from[].version Version constraint: >=, >, <=, <, =, or >0 (matches any)
from[].via Waypoint slot name in versions/ — upgrade must pass through this slot first
from[].blocked If true, upgrade is blocked with an error message
from[].notes Human-readable message shown when blocked or as context
preUpgrade.backup Backup requirement: "required" blocks upgrade until backup is done, "recommended" shows a warning

Version manifest.yaml upgrade fields:

Field Description
migrations.pre K8s Job YAMLs to run before deploying this version step
migrations.post K8s Job YAMLs to run after deploying this version step
configMigrations Map of old config key → new config key for automatic renaming

Waypoint versions

When an upgrade requires passing through an intermediate version, add that version's files as a new slot in the versions/ directory alongside the latest:

myapp/
├── app.yaml               # Routing rules + latest pointer
└── versions/
    ├── 3/                  # Latest slot (version: 3.6.0)
    │   ├── manifest.yaml
    │   ├── kustomization.yaml
    │   └── *.yaml
    └── 2/                  # Waypoint slot (version: 2.8.0)
        ├── manifest.yaml
        ├── kustomization.yaml
        └── *.yaml

Each waypoint is a complete app package. The system computes a chain automatically — for example, upgrading from 2.3.0 to 3.6.0 might produce: 2.3.0 → 2.8.0 (slot "2") → 3.6.0 (slot "3").

Creating a waypoint: The current latest slot becomes the waypoint (leave it in place), then create a new slot for the new major version:

# Current slot "2" (with version 2.8.0) stays as a waypoint
# Create the new slot for the next major version
mkdir -p wild-directory/myapp/versions/3
# ... add manifest.yaml, kustomization.yaml, *.yaml for 3.0.0 ...
# Update app.yaml: set latest to "3", add upgrade routing rules with via: "2"

Migration jobs

Migration jobs are K8s Job manifests that run database migrations or other one-time tasks during an upgrade step. They must be idempotent (safe to re-run) since a failed upgrade might be retried.

Place migration job files in the version slot directory and reference them from that version's 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"]

Each migration step belongs to the version that introduces the breaking change. If version 3.6.0 requires a schema migration, the migration lives in the slot 3/ directory.

Example: simple app with waypoint

# myapp/app.yaml
name: myapp
latest: "2"
upgrade:
  from:
    - version: ">=1.0.0"
      via: "1"
    - version: "<1.0.0"
      blocked: true
      notes: "Versions before 1.0.0 are not supported"
  preUpgrade:
    backup: recommended

This creates a 2-step upgrade path: 1.x → slot "1" (e.g., version 1.0.0-1) → slot "2" (e.g., version 2.0.0). The waypoint at versions/1/ is a complete app package used as an intermediate step.

Adding a New Version

When an upstream app releases a new version, you update the Wild Directory package to track it. The process depends on whether the new version has breaking changes.

Simple version bump (no breaking changes)

Most version updates are simple — update the container image tag, adjust any changed config, and update the version in manifest.yaml. No directory rename or app.yaml change needed.

# 1. Update files inside the existing slot
#    - Bump version in manifest.yaml (e.g., 1.2.0 → 1.3.0)
#    - Update container image tags in deployment YAMLs
#    - Adjust defaultConfig if the new version adds/changes config
vi wild-directory/myapp/versions/1/manifest.yaml
vi wild-directory/myapp/versions/1/deployment.yaml

# 2. app.yaml doesn't change — latest still points to slot "1"

# 3. Test
wild app add myapp && wild app deploy myapp

The directory structure stays the same:

myapp/
├── app.yaml               # latest: "1" (unchanged)
└── versions/
    └── 1/
        ├── manifest.yaml   # version: 1.3.0 (bumped)
        └── *.yaml

Version bump with breaking changes (waypoint required)

When the new version can't safely upgrade from all previous versions — e.g., a database schema change requires stepping through an intermediate version — create a new slot for the new major version, keep the old slot as a waypoint, and add routing rules.

# 1. The current slot (2/) becomes a waypoint — leave it in place
# 2. Create a new slot for the new major version
mkdir -p wild-directory/myapp/versions/3
# ... add new version files (manifest.yaml, kustomization.yaml, *.yaml) ...

# 3. Update app.yaml: point latest to new slot, add upgrade routing rules
# app.yaml
name: myapp
latest: "3"
upgrade:
  from:
    - version: ">=2.5.0"            # 2.5.x can upgrade directly
    - version: ">=2.0.0"
      via: "2"                       # Older 2.x must pass through slot 2 first
    - version: "<2.0.0"
      blocked: true
      notes: "Upgrade to 2.x first. See upstream migration guide."
  preUpgrade:
    backup: recommended

The resulting directory:

myapp/
├── app.yaml               # latest: "3", upgrade routing rules
└── versions/
    ├── 3/                  # New latest (manifest.yaml has version: 3.0.0)
    │   ├── manifest.yaml
    │   └── *.yaml
    └── 2/                  # Waypoint (manifest.yaml has version: 2.5.0)
        ├── manifest.yaml
        └── *.yaml

Version bump with database migrations

When the new version requires a schema migration (e.g., ALTER TABLE, new indexes, data transformations), add migration job files to the slot directory and reference them from the version's manifest.yaml. Since this is a minor/patch update within the same major version, update files in-place in the existing slot.

# 1. Update files inside the existing slot
#    - Bump version in manifest.yaml (e.g., 2.0.0 → 2.1.0)
#    - Update container image tags in deployment YAMLs
vi wild-directory/myapp/versions/2/manifest.yaml
vi wild-directory/myapp/versions/2/deployment.yaml

# 2. Add migration job files
mkdir -p wild-directory/myapp/versions/2/migrations

Create the migration job:

# versions/2/migrations/pre-deploy.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: myapp-migrate-2-1-0
  namespace: myapp
spec:
  backoffLimit: 3
  template:
    spec:
      restartPolicy: OnFailure
      securityContext:
        runAsNonRoot: true
        runAsUser: 999
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: migrate
          image: myapp:2.1.0
          command: ["bundle", "exec", "rake", "db:migrate"]
          securityContext:
            allowPrivilegeEscalation: false
            capabilities:
              drop: [ALL]
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: myapp-secrets
                  key: dbUrl

Reference the migration in the version manifest:

# versions/2/manifest.yaml
version: 2.1.0
upgrade:
  migrations:
    pre:
      - migrations/pre-deploy.yaml
defaultConfig:
  # ...

app.yaml doesn't change — latest still points to slot "2".

Migration jobs must be idempotent — safe to re-run if an upgrade is retried after a partial failure. Use CREATE IF NOT EXISTS, ALTER TABLE IF NOT EXISTS, etc.

Pre vs post migrations:

  • pre — runs before deploying the new version's manifests (schema changes that the new code needs)
  • post — runs after deploying (data backfills, cleanup that the old code didn't need)

Version bump with config key renames

When a version renames config keys (e.g., dbHostdb.host), use configMigrations to automatically rename them during upgrade:

# versions/2/manifest.yaml
version: 2.1.0
upgrade:
  configMigrations:
    dbHost: db.host
    dbPort: db.port
    dbName: db.name
defaultConfig:
  db:
    host: "{{ .apps.pg.host }}"
    port: "5432"
    name: myapp

The system renames the keys in the instance's config.yaml before recompiling templates with the new version.

Dependency Configuration

  • Each dependency in requires can have:
    • name: The app name to depend on (any app with a matching is field can satisfy this requirement)
    • alias: Optional reference name for templates (defaults to name)

Manifest Template Variables (configuration and secrets)

Manifest Template Variable Sources

  1. Standard Wild Cloud variables: {{ .cloud.* }}, {{ .cluster.* }}, {{ .operator.* }}
  2. App-specific variables: {{ .app.* }} - resolved from current app's config
  3. Dependency variables: {{ .apps.<ref>.* }} - resolved using app reference mapping
  4. App-specific secrets (in 'defaultSecrets' ONLY): {{ secrets.* }}

Available Configuration Variiables

Here's a comprehensive rundown of all config variables that get set during cluster and service setup in config.yaml:

operator (Set during initial setup)
  • operator.email - Email for cluster operator/admin
cloud (Infrastructure-level settings)
DNS Configuration:
  • cloud.dnsmasq.ip - IP address of the DNS server (Wild Central)
  • cluster.internalDns.externalResolver - External DNS resolver (e.g., 1.1.1.1, 8.8.8.8)
Network Configuration:
  • cloud.router.ip - Router gateway IP
  • cloud.router.dynamicDns - Dynamic DNS hostname (optional)
  • cloud.dhcpRange - DHCP range for the network (e.g., "192.168.8.34,192.168.8.79")
  • cloud.dnsmasq.interface - Network interface for dnsmasq
Domain Configuration:
  • cloud.baseDomain - Base domain for the cloud (e.g., "payne.io")
  • cloud.domain - Full cloud domain (e.g., "cloud2.payne.io")
  • cloud.internalDomain - Internal cluster domain (e.g., "internal.cloud2.payne.io")
Storage Configuration (NFS Service):
  • cloud.nfs.host - NFS server hostname/IP
  • cloud.nfs.mediaPath - NFS export path for media storage
  • cloud.nfs.storageCapacity - NFS storage capacity (e.g., "50Gi", "1Ti")
Registry Configuration (Docker Registry Service):
  • cloud.dockerRegistryHost - Docker registry hostname (e.g., "registry.internal.cloud2.payne.io")
Backup Configuration:
  • cloud.backup.root - Root path for backups
cluster (Kubernetes cluster settings)
Basic Cluster Info:
  • cluster.name - Cluster name identifier
  • cluster.hostnamePrefix - Prefix for node hostnames
Node Configuration:
  • cluster.nodes.talos.version - Talos Linux version (e.g., "v1.11.5")
  • cluster.nodes.talos.schematicId - Talos Image Factory schematic ID
  • cluster.nodes.control.vip - Virtual IP for control plane
  • cluster.nodes.active.* - Individual node configurations with:
    • role - "controlplane" or "worker"
    • interface - Network interface name
    • disk - Disk device path
    • currentIp - Current IP address
    • targetIp - Target IP address
    • configured - Configuration status
    • applied - Applied status
    • maintenance - Maintenance mode
    • schematicId - Node-specific schematic ID
    • version - Node-specific Talos version
MetalLB Service:
  • cluster.ipAddressPool - IP range for MetalLB (e.g., "192.168.8.80-192.168.8.89")
  • cluster.loadBalancerIp - Primary load balancer IP (e.g., "192.168.8.80")
Cert-Manager Service:
  • cluster.certManager.cloudflare.domain - Cloudflare domain for DNS-01 challenge
  • cluster.certManager.cloudflare.zoneID - Cloudflare zone ID
ExternalDNS Service:
  • cluster.externalDns.ownerId - Unique identifier for this cluster's DNS records
Docker Registry Service:
  • cluster.dockerRegistry.storage - Storage size for registry (e.g., "10Gi")
apps (Application configurations)

Each app added to the cluster gets its own section under apps. with app-specific configuration from the app's manifest. Common patterns include:

Standard app fields:

  • apps..namespace - Kubernetes namespace
  • apps..domain - App domain (e.g., "ghost.cloud2.payne.io")
  • apps..externalDnsDomain - Domain for external DNS
  • apps..tlsSecretName - TLS certificate secret name
  • apps..image - Container image
  • apps..port - Service port
  • apps..storage - Persistent volume size
  • apps..timezone - Timezone setting

Database-dependent apps:

  • apps..dbHost / dbHostname - Database hostname
  • apps..dbPort - Database port
  • apps..dbName - Database name
  • apps..dbUser / dbUsername - Database user

SMTP-enabled apps:

  • apps..smtp.host - SMTP server
  • apps..smtp.port - SMTP port
  • apps..smtp.user - SMTP username
  • apps..smtp.from - From address
  • apps..smtp.tls - TLS enabled
  • apps..smtp.startTls - STARTTLS enabled

Configuration Flow

  1. Initial Setup: operator.email, basic cloud.* settings
  2. Cluster Bootstrap: cluster.name, cluster.nodes.* settings
  3. Infrastructure Services: Each service prompts for its serviceConfig from its manifest
    • MetalLB → cluster.ipAddressPool, cluster.loadBalancerIp
    • Cert-Manager → cluster.certManager.*
    • ExternalDNS → cluster.externalDns.ownerId
    • NFS → cloud.nfs.*
    • Docker Registry → cloud.dockerRegistryHost, cluster.dockerRegistry.storage
  4. Apps: Each app adds its configuration under apps..* based on its manifest (including SMTP as an infrastructure app at apps.smtp.*)

Manifest App Reference Resolution:

When you use {{ .apps.<ref>.* }} in templates:

  1. System checks if <ref> matches any dependency's alias field
  2. If no alias match, checks if <ref> matches any dependency's name field
  3. Uses the installedAs value (automatically added when the app is added) to find actual app configuration in config.yaml

All manifest template variables must be defined in one of these locations.

Important: In the rest of the app templates, ALL configuration keys referenced in templates (via {{ .key }}) must be defined in defaultConfig. Only the app config is available to app templates.

Kustomization (kustomization.yaml)

The kustomization file defines how Kubernetes resources are built and applies Wild Cloud's standard labels.

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
  - deployment-machine-learning.yaml
  - deployment-microservices.yaml
  - ingress.yaml
  - namespace.yaml
  - pvc.yaml
  - service.yaml
  - db-init-job.yaml

Kustomization Requirements

  • Namespace: Must match the app name
  • Labels: Must include standard Wild Cloud labels with includeSelectors: true
  • Resources: List all Kubernetes manifest files

Labeling Strategy

Wild Cloud uses Kustomize's includeSelectors: true feature to automatically apply standard labels to all resources AND their selectors:

labels:
  - includeSelectors: true
    pairs:
      app: myapp              # App name (matches directory)
      managedBy: kustomize
      partOf: wild-cloud

This means individual resources can use simple, component-specific selectors like component: web, and Kustomize will automatically expand them to include all Wild Cloud labels.

Do NOT use Helm-style labels (app.kubernetes.io/name, app.kubernetes.io/instance). Use simple component labels (component: web, component: worker, etc.) instead.

Configuration Templates

Gomplate Templating

Resource files in this repository are templates that get compiled when users add apps via the web app, CLI, or API. Only variables defined in the manifest file's 'defaultConfig' section are available to the resource templates. Use gomplate syntax to reference configuration:

External DNS

Ingress resources should include external-dns annotations for automatic DNS management:

annotations:
  external-dns.alpha.kubernetes.io/target: {{ .domain }}
  external-dns.alpha.kubernetes.io/cloudflare-proxied: "false"

Note: 'domain' must be defined in the app manifest's 'defaultConfig' section.

This creates a CNAME from the app subdomain to the cluster domain (e.g., myapp.cloud.example.comcloud.example.com).

App Dependencies and Reference Mapping

How Dependency References Work

When an app depends on other apps, the reference system allows flexibility in naming while maintaining clear relationships:

  1. Define dependencies in your manifest with optional aliases:
requires:
  - name: postgres      # Actual app to depend on
    alias: db          # Optional: how to reference it in templates
  - name: redis        # No alias means use 'redis' as reference
  1. At installation time, the system:
    • Prompts user to map dependencies to actual installed apps
    • Sets installedAs field in the local app manifest to track the mapping
    • Example: User might have postgres-primary installed, mapped to the db dependency

Example: Multiple Database Instances

If a user has multiple PostgreSQL instances:

# User's config.yaml
apps:
  postgres-primary:
    hostname: primary.postgres.svc.cluster.local
  postgres-analytics:
    hostname: analytics.postgres.svc.cluster.local

When adding an app that requires postgres, they can choose which instance to use, and the system tracks this in the manifest's installedAs field.

Database Patterns

Database Initialization Jobs

Apps requiring PostgreSQL or MySQL should include a database initialization job (db-init-job.yaml):

Purpose:

  • Creates the application database (if it doesn't exist)
  • Creates/updates the application user with proper credentials
  • Grants necessary permissions
  • Installs required database extensions (e.g., PostgreSQL's vector, cube, earthdistance)

Implementation requirements:

  • Use restartPolicy: OnFailure
  • Include in kustomization.yaml resources
  • Use appropriate security context (e.g., runAsUser: 999 for PostgreSQL)

Example apps: immich, gitea, openproject, discourse

Database URL Configuration

When apps need database URLs with embedded credentials, always use a dedicated dbUrl secret.

Wrong - Kustomize cannot process runtime env var substitution:

- name: DB_URL
  value: "postgresql://user:$(DB_PASSWORD)@host/db"  # This won't work!

Correct - Use a dedicated secret:

- name: DB_URL
  valueFrom:
    secretKeyRef:
      name: myapp-secrets
      key: apps.myapp.dbUrl

Add apps.myapp.dbUrl to your manifest's defaultSecrets, and the system will generate the complete URL with embedded credentials automatically when the app is added.

Backup/Restore Database Name Conventions

Wild Cloud's backup/restore system uses blue-green deployments. During restore, a standby copy of the app is created with a colored database name (e.g., myapp_green). The system automatically patches env vars in your Kubernetes resources to point to the standby database.

How it works: The restore system compiles your kustomize resources, finds env vars whose values match the original database name, and generates kustomize JSON patches to replace them with the standby database name. It uses env var naming conventions to distinguish database name fields from username fields (since both often have the same value).

Env var naming guidelines for database-related fields:

  • Database name env vars should contain one of: DATABASE, DB_NAME, DBNAME, or __DATABASE in the env var name (e.g., LISTMONK_db__database, DB_NAME, POSTGRES_DB)
  • Database URL env vars are detected by containing :// in the value (e.g., postgresql://user:pass@host/dbname)
  • Username env vars should contain USER in the name (e.g., DB_USER, LISTMONK_db__user) — these will NOT be patched even if the value matches the database name
  • Avoid env var names that are ambiguous about whether they hold a database name or username

Example — correct naming:

env:
  - name: DB_NAME           # Will be patched (contains "DB_NAME")
    value: myapp
  - name: DB_USER           # Will NOT be patched (contains "USER")
    value: myapp
  - name: DATABASE_URL      # Will be patched (contains "://")
    value: "postgresql://myapp:secret@postgres/myapp"

Deployment Strategy

Apps using ReadWriteOnce (RWO) persistent volumes must set strategy: type: Recreate on their Deployment. RWO volumes can only be attached to one pod at a time, so the default RollingUpdate strategy will cause Multi-Attach errors during updates (the new pod can't mount the volume while the old pod still holds it).

spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      component: web

Security Requirements

Security Contexts

All pods must comply with Pod Security Standards. Include security contexts at both pod and container levels:

spec:
  template:
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 999        # Use appropriate non-root UID
        runAsGroup: 999       # Use appropriate GID
        seccompProfile:
          type: RuntimeDefault
      containers:
      - name: container-name
        securityContext:
          allowPrivilegeEscalation: false
          capabilities:
            drop: [ALL]
          readOnlyRootFilesystem: false  # Set to true when possible

Common user IDs:

  • PostgreSQL: runAsUser: 999
  • Redis: runAsUser: 999
  • MySQL: Consult the container image documentation

Secrets Management

Secrets are managed through two mechanisms: default secrets for the app itself and required secrets from dependencies.

In manifest:

defaultSecrets:
  key: dbPassword      # This app's database password
  key: apiKey          # This app's API key
requiredSecrets:
  - db.password       # Password from postgres dependency (aliased as 'db')
  - redis.auth        # Auth from redis dependency

In resources:

env:
  - name: DB_PASSWORD
    valueFrom:
      secretKeyRef:
        name: myapp-secrets
        key: dbPassword    # Points to the default secret
  - name: POSTGRES_PASSWORD
    valueFrom:
      secretKeyRef:
        name: myapp-secrets
        key: db.password    # Points to the required secret

Secret workflow:

  1. Define app's own secrets in defaultSecrets (key, default mappings)
  2. Reference dependency secrets in requiredSecrets (list)
  3. When adding an app, the system:
    • Generates random values for empty defaultSecrets
    • Copies referenced secrets from dependencies
    • Stores all in the instance's secrets.yaml
  4. When deploying, creates a Kubernetes Secret named <app-name>-secrets containing:
    • All defaultSecrets with key format: <key>
    • All requiredSecrets with key format: <app-ref>.<key>

Key collision handling: If the same key exists in both defaultSecrets and requiredSecrets, the requiredSecrets value takes precedence. Authors should ensure their local secrets don't collide with their required secrets.

Important: Never commit secrets.yaml to Git. Templates should only reference secrets, never contain actual secret values.

Converting from Helm Charts

Wild Cloud prefers Kustomize over Helm for simplicity and Git-friendliness. When an official Helm chart exists, convert it rather than creating manifests from scratch.

Conversion Process

  1. Extract and render the Helm chart:
helm fetch --untar --untardir charts repo/chart-name
helm template --output-dir base --namespace myapp --values values.yaml myapp charts/chart-name
cd base/chart-name
  1. Add namespace manifest:
cat <<EOF > namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: myapp
EOF
  1. Create kustomization:
kustomize create --autodetect
  1. Convert to Wild Cloud format:
    • Create manifest.yaml with app metadata
    • Replace hardcoded values with gomplate variables (e.g., {{ .cloud.domain }})
    • Update secrets to use dotted-path convention
    • Replace Helm labels with Wild Cloud standard labels
    • Add includeSelectors: true to kustomization
    • Use simple component labels (component: web, not app.kubernetes.io/name)
    • Add security contexts to all pods
    • Add external-dns annotations to ingresses

Example Label Migration

Helm style:

labels:
  app.kubernetes.io/name: myapp
  app.kubernetes.io/instance: release-name
  app.kubernetes.io/component: server

Wild Cloud style:

# In kustomization.yaml (applied automatically)
labels:
  - includeSelectors: true
    pairs:
      app: myapp
      managedBy: kustomize
      partOf: wild-cloud

# In individual resources
labels:
  component: server  # Simple component label

Validation Checklist

Before submitting a new or modified app, verify:

  • App Meta (app.yaml)

    • name matches directory name
    • latest points to a valid version in versions/
    • description present
    • upgrade rules correct (if applicable)
  • Version Manifest (versions/{slot}/manifest.yaml)

    • version field present with full version string (e.g., 1.135.3-1)
    • Slot directory follows naming convention (major version, e.g., 1, v1)
    • All required fields present (version, defaultConfig)
    • All template variables defined in defaultConfig
    • defaultSecrets uses maps with 'key' and 'default' attributes
    • requiredSecrets references use <app-ref>.<key> format
    • Dependencies listed in requires with optional alias fields
    • Manifest template references match dependency aliases or names
  • Kustomization

    • Includes standard Wild Cloud labels with includeSelectors: true
    • Namespace matches app name
    • All resource files listed under resources:
  • Resources

    • Security contexts on all pods (both pod-level and container-level)
    • strategy: type: Recreate on deployments with ReadWriteOnce PVCs
    • Simple component labels, no Helm-style labels
    • Ingresses include external-dns annotations
    • Database apps include init jobs (if applicable)
  • Testing

    • Templates compile successfully with sample config
    • App deploys without errors in test cluster
    • All dependencies work correctly

Contributing

Contributions are welcome! To contribute:

  1. Fork the repository
  2. Create a new app directory following the structure above
  3. Test your app thoroughly
  4. Submit a pull request with:
    • Description of the app and its purpose
    • Any special configuration notes
    • Dependencies required

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.

See individual deployment directories for upstream project links and container sources.