Bundles

Prerequisites: Container configuration, Containerization guide

A bundle is a marketplace listing that contains multiple independent, publicly accessible applications packaged as one. Instead of a single app with a single URL, a bundle deploys several apps, each on its own subdomain, and lets them share infrastructure where appropriate.


Bundle vs single app

FeatureSingle appBundle
Public containers12+
Subdomains1One per public app
Marketplace entryOne listingOne listing
DeploymentAll containers togetherAll containers together
Use caseOne serviceMultiple related services

Example: The "Education Kit" bundle deploys both Moodle (LMS) and Gibbon (school management) together. Users get https://myschool.clouve.dev for Moodle and https://gibbon.myschool.clouve.dev for Gibbon, all deployed from a single marketplace click.


When to create a bundle

Create a bundle when you are packaging two or more applications that are typically deployed together and complement each other, such as a CRM with a marketing tool or an LMS with school management software. Bundling makes sense when the apps can share infrastructure (databases, caches) and users would benefit from deploying the full suite in one click.


How bundles work

Subdomain assignment

Each public container in a bundle gets its own subdomain:

Container order in the manifest:
  1st public container → default subdomain
  2nd public container → named subdomain (container name)
  3rd public container → named subdomain (container name)

Result (workspace: "myschool"):
  moodle  (1st) → https://myschool.clouve.dev
  gibbon  (2nd) → https://gibbon.myschool.clouve.dev

The first container in the services section becomes the "primary" app on the default subdomain. Reorder containers to control which app is primary.

Naming convention

Supporting containers (databases, caches) must be prefixed with the name of the main app they belong to:

✅ moodle        (public, main app)
✅ moodle-mysql  (private, database for moodle)
✅ gibbon        (public, main app)
✅ gibbon-mysql  (private, database for gibbon)

❌ mysql         (no prefix: which app does this belong to?)
❌ database      (too generic)

This convention is required: Clouve's cluster management layer uses it to associate each service with its main app during deployment.

If a shared service is truly shared between multiple apps, prefix it with the name of one of the main apps (conventionally the first alphabetically):

# Shared Redis: prefix with one main app name
gibbon-redis:
  # Used by both gibbon and moodle

Bundle metadata

Each public container in a bundle needs its own metadata: title, version, description, icon, and an optional admin URL path. The marketplace UI displays this metadata, and the platform uses it to generate the attributes.json.hbs file that drives subdomain routing.

When you download a configured bundle as a Docker Compose file, these per-app fields are preserved in an x-clouve-bundle-metadata extension on each public service so that the upload step can restore them exactly:

services:
  moodle:
    image: moodle:4.3.0
    # ... other config ...
    x-clouve-bundle-metadata:
      appVersion: "4.3.0"
      appTitle: "Moodle LMS"
      appDescription: "Open-source learning management system"
      appIcon: "https://storage.googleapis.com/clouve-attachments/moodle-icon.png"
      adminPath: "/login/index.php" # Optional

The fields:

FieldRequiredPurpose
appVersionYesSemantic version shown in the marketplace listing for this sub-app
appTitleYesDisplay name (e.g., "Moodle LMS")
appDescriptionYesMarketing description shown to deployers
appIconYesURL of the icon image (typically GCS-hosted from the platform upload)
adminPathNoURL path that opens the app's admin UI (e.g., /wp-admin, /login)

The extension is emitted only on public containers, and only when at least one field has a value, so private services (databases, caches) carry no irrelevant metadata.


Creating a bundle step by step

Step 1: Plan your bundle

Decide:

  • Which apps will be public (one subdomain each)
  • Which services will be shared vs dedicated
  • Naming for all containers (following the prefix convention)
  • Which app appears on the default subdomain (first in container order)

Example plan: Education Kit

ContainerTypePublicSubdomain
moodleLMSYesmyschool.clouve.dev (default)
moodle-mysqlDatabaseNoNone
gibbonSchool managementYesgibbon.myschool.clouve.dev
gibbon-mysqlDatabaseNoNone

Step 2: Write the manifest

A bundle manifest is a standard clv-docker-compose.yml with multiple isPublic: true containers:

version: "3.8"

services:
  # First public app → default subdomain
  moodle:
    image: moodle:4.3.0
    ports:
      - "80:80"
    environment:
      MOODLE_DATABASE_HOST: moodle-mysql
      MOODLE_DATABASE_NAME: moodle
      MOODLE_DATABASE_USER: moodle
      MOODLE_DATABASE_PASSWORD: changeme
      MOODLE_SITE_URL: ""
      MOODLE_ADMIN_USER: admin
      MOODLE_ADMIN_PASSWORD: changeme

    x-clouve-metadata:
      containerName: moodle
      purpose: Frontend
      protocol: TCP
      isPublic: true # ← Public app
      memoryBase: 2 # 2 GB
      cpuBase: 2

    x-clouve-healthcheck:
      enabled: true
      type: HTTP
      path: /
      port: 80
      initialDelay: 120
      interval: 15
      timeout: 10
      failureThreshold: 5
      successThreshold: 1

    x-clouve-environment-types:
      MOODLE_DATABASE_HOST: containerReference
      MOODLE_DATABASE_NAME: static
      MOODLE_DATABASE_USER: static
      MOODLE_DATABASE_PASSWORD: secret
      MOODLE_SITE_URL: applicationUrl
      MOODLE_ADMIN_USER: applicationUsername
      MOODLE_ADMIN_PASSWORD: applicationPassword

  moodle-mysql:
    image: mysql:8.0
    ports:
      - "3306:3306"
    environment:
      MYSQL_DATABASE: moodle
      MYSQL_USER: moodle
      MYSQL_PASSWORD: changeme
      MYSQL_ROOT_PASSWORD: changeme
    volumes:
      - moodle-db:/var/lib/mysql

    x-clouve-metadata:
      containerName: moodle-mysql
      purpose: Database
      protocol: TCP
      isPublic: false # ← Private service, prefixed with "moodle"
      memoryBase: 1 # 1 GB
      cpuBase: 0.5

    x-clouve-healthcheck:
      enabled: true
      type: TCP
      path: ""
      port: 3306
      initialDelay: 30
      interval: 10
      timeout: 5
      failureThreshold: 3
      successThreshold: 1

    x-clouve-environment-types:
      MYSQL_DATABASE: static
      MYSQL_USER: static
      MYSQL_PASSWORD: secret
      MYSQL_ROOT_PASSWORD: secret

    x-clouve-volumes:
      - name: moodle-db
        size: 20Gi
        description: Moodle MySQL database

  # Second public app → named subdomain (gibbon.workspace.clouve.dev)
  gibbon:
    image: gibbon:26.0.0
    ports:
      - "80:80"
    environment:
      GIBBON_DB_HOST: gibbon-mysql
      GIBBON_DB_NAME: gibbon
      GIBBON_DB_USER: gibbon
      GIBBON_DB_PASSWORD: changeme
      GIBBON_SITE_URL: ""
      GIBBON_ADMIN_USER: admin
      GIBBON_ADMIN_PASSWORD: changeme

    x-clouve-metadata:
      containerName: gibbon
      purpose: Frontend
      protocol: TCP
      isPublic: true # ← Public app
      memoryBase: 1 # 1 GB
      cpuBase: 1

    x-clouve-healthcheck:
      enabled: true
      type: HTTP
      path: /
      port: 80
      initialDelay: 60
      interval: 10
      timeout: 5
      failureThreshold: 3
      successThreshold: 1

    x-clouve-environment-types:
      GIBBON_DB_HOST: containerReference
      GIBBON_DB_NAME: static
      GIBBON_DB_USER: static
      GIBBON_DB_PASSWORD: secret
      GIBBON_SITE_URL: applicationUrl
      GIBBON_ADMIN_USER: applicationUsername
      GIBBON_ADMIN_PASSWORD: applicationPassword

  gibbon-mysql:
    image: mysql:8.0
    ports:
      - "3306:3306"
    environment:
      MYSQL_DATABASE: gibbon
      MYSQL_USER: gibbon
      MYSQL_PASSWORD: changeme
      MYSQL_ROOT_PASSWORD: changeme
    volumes:
      - gibbon-db:/var/lib/mysql

    x-clouve-metadata:
      containerName: gibbon-mysql
      purpose: Database
      protocol: TCP
      isPublic: false # ← Private, prefixed with "gibbon"
      memoryBase: 1 # 1 GB
      cpuBase: 0.5

    x-clouve-healthcheck:
      enabled: true
      type: TCP
      path: ""
      port: 3306
      initialDelay: 30
      interval: 10
      timeout: 5
      failureThreshold: 3
      successThreshold: 1

    x-clouve-environment-types:
      MYSQL_DATABASE: static
      MYSQL_USER: static
      MYSQL_PASSWORD: secret
      MYSQL_ROOT_PASSWORD: secret

    x-clouve-volumes:
      - name: gibbon-db
        size: 10Gi
        description: Gibbon MySQL database

volumes:
  moodle-db:
  gibbon-db:

Step 3: Provide bundle metadata in the UI

In the submission wizard, for each public container, provide:

FieldRulesExample
App VersionSemantic: X.Y.Z or X.Y.Z-suffix4.3.0, 26.0.0
App Title2 to 100 charactersMoodle LMS, Gibbon School Platform
App Description10 to 500 charactersOpen-source learning management system
App IconImage file (PNG recommended)moodle-icon.png
Admin Path (optional)URL path that opens the admin UI for this sub-app. Validated by the form./wp-admin, /login/index.php

The UI warns you when metadata is missing. Missing fields fall back to defaults (the container name in title case for the title, an auto-generated description), but providing real values is strongly recommended.

Step 4: Review and submit

The UI shows a warning if private containers don't follow the naming convention. Address warnings before submitting.


Subdomain structure in detail

Given a bundle with containers in this order:

  1. moodle (public, first → default)
  2. gibbon (public, second → named)

And a business user deploys to workspace myschool:

moodle → https://myschool.clouve.dev           (default)
gibbon → https://gibbon.myschool.clouve.dev    (named by container name)

The generated attributes.json.hbs structure:

{
  "suiteApps": {
    "default": "moodle",
    "gibbon": "gibbon"
  },
  "suiteTitles": {
    "default": "Moodle LMS",
    "gibbon": "Gibbon School Platform"
  },
  "suiteAdminPaths": {
    "default": "/login/index.php",
    "gibbon": "/index.php?q=login"
  }
}

The suiteApps keys (default, gibbon, ...) double as DNS subdomain labels. Strato's clv-agent apply_dns step keys the per-app Ingress hostname off these labels, so the subdomain you see in the browser comes from the suiteApps key, not directly from the container name. Renaming a public container without updating its expected subdomain key (via the bundle metadata flow) can desynchronize the hostname from the container identity. If you need a container to be addressable at a subdomain that differs from its container name, raise the requirement with the platform team rather than special-casing it on the manifest.


Converting a single app to a bundle

To convert an existing single-app submission to a bundle:

  1. Add additional public containers (isPublic: true)
  2. Ensure all supporting containers follow the naming convention
  3. Add bundle metadata for each new public container
  4. Reorder containers to set which app is the primary (default subdomain)

The platform automatically detects a bundle when more than one container has isPublic: true.


Common bundle mistakes

MistakeSymptomFix
Private container has no prefixNaming validation warningAdd main app name prefix: mysql → moodle-mysql
Two public containers on the same portPort conflictEach public container needs its own port (or they serve on the same host with Nginx routing)
Wrong container orderWrong app on default subdomainReorder tabs in the UI; the first tab is the default
Missing bundle metadataFallback values used, less polished marketplace listingFill in version, title, description, icon for each public container

Next steps