Skip to content

Latest commit

 

History

History
610 lines (494 loc) · 36.9 KB

app-catalog.mdx

File metadata and controls

610 lines (494 loc) · 36.9 KB
title App catalog JSON
description The complete field reference for an Openship App definition — the JSON that turns a set of containers into a one-click install, with generated secrets, a curated settings form, and a connection card.

import { TypeTable } from 'fumadocs-ui/components/type-table';

An App is a JSON file that wraps a deployment into a one-click, focused experience. Instead of creating a project, adding a service per container, hand-writing the images and the env that wire them together, generating and matching a database password across two services, and then hunting through raw env tabs to change a setting — you click the app, fill a short form, and deploy.

So an App is not a new runtime. It is metadata over the normal multi-service deploy: a repo-less services project marked isApp. Same engine underneath, much shorter path for the user. This page documents every field you can author. To actually submit one, see Add an app.

Add the $schema line for editor autocomplete and inline validation:

{
  "$schema": "https://openship.io/app.schema.json",
  "id": "it-tools",
  "name": "IT-Tools",
  "description": "A handy collection of developer and sysadmin utilities.",
  "kind": "template",
  "logo": "it-tools",
  "category": "other"
}

It is JSON, not JSONC — no comments, no trailing commas.

The validator strips keys it does not recognize instead of failing. That is deliberate: it is what lets a newer app definition stay installable on an older Openship, and it is why growth is always additive. The trade-off is that a **misspelled field name fails silently** — it is dropped, not reported, by both the published schema and the server. So keep the `$schema` line and let your editor autocomplete field names rather than typing them from memory.

Two kinds

kind What it does
template Instantiates the services defined in the entry (a backend plus its database, a CMS plus its database, …) and deploys them through the compose/services path. This is the common case.
flow Provisioning already has a bespoke wizard (for example the mail stack). The entry just points at that wizard via flowHref; it does not instantiate services.

Top level

<TypeTable type={{ id: { type: 'string', description: 'Stable kebab-case slug. Must match the filename and be globally unique.', required: true }, name: { type: 'string', description: 'Display name shown in the catalog.', required: true }, description: { type: 'string', description: 'One-line summary shown on the catalog card.', required: true }, marketplace: { type: 'AppMarketplaceContent', description: 'Optional public feature summaries and a Markdown article. Presentation only; never changes installation.' }, kind: { type: '"template" | "flow"', description: 'template instantiates services; flow points at a bespoke wizard.', required: true }, logo: { type: 'string', description: 'A simpleicons slug, or an id mapped to a vendored SVG. See Add an app.', required: true }, category: { type: '"backend" | "database" | "cms" | "mail" | "analytics" | "automation" | "other"', description: 'Catalog grouping. Closed enum — an unknown value fails validation.', required: true }, tags: { type: 'string[]', description: 'Free-form keywords used for catalog search.' }, framework: { type: 'string', description: 'Stack slug for the created project. Usually "docker-compose" for template apps.' }, services: { type: 'TemplateServiceSpec[]', description: 'The containers to run. Required in practice for template apps.' }, supportsCloud: { type: 'boolean', description: 'Defaults to true. Set false for self-hosted-only apps. The installer rejects managed Cloud before creating a project. Requires schemaVersion 2.' }, gpu: { type: '"recommended" | "required"', description: 'NVIDIA GPU placement. Recommended offers CPU or NVIDIA mode; services with GPU requests default to required. Requires schemaVersion 2.' }, configFields: { type: 'AppConfigField[]', description: 'Machine-generated or derived env. Never rendered as a form input.' }, flowHref: { type: 'string', description: 'For kind:"flow" — internal route of the bespoke wizard. Must start with "/".' }, settings: { type: 'AppSettingGroup[]', description: 'The curated settings form, and the install wizard inputs.' }, installLayout: { type: '{ settings: "single" | "split" | "grouped", columns?: 1 | 2 }', description: 'Optional install-form grouping and responsive field columns. See Form layout.' }, management: { type: 'object', description: 'Override how the installed app is managed. See Management.' }, prepare: { type: 'PrepareStep[]', description: 'Commands run inside a service container after start.' }, connection: { type: 'object', description: 'The post-install Connection card. See Connection.' }, endpoints: { type: 'Endpoint[]', description: 'How the install wizard offers to expose each port.' }, files: { type: 'FileSpec[]', description: 'Generated config files bind-mounted into a container.' }, provides: { type: 'Provides[]', description: 'Connectable bundles this app advertises to other projects.' }, requires: { type: 'Requires[]', description: 'Connections this app needs from another project.' }, available: { type: 'boolean', description: 'false or omitted → shown dimmed as "coming soon" and not installable (enforced server-side too).' }, unlisted: { type: 'boolean', description: 'Hidden from the catalog grid but fully installable by id — for an app reached through another app's wizard. Ignored on custom uploads (the grid is their only entry point).' }, verified: { type: 'boolean', description: 'Curated-catalog marker. Forced by the server on custom uploads — authoring it has no effect.' }, hosting: { type: '"self-hosted" | "experimental"', description: 'experimental → an ⚗ badge on the card and a heads-up in the wizard: it runs, but it is heavy or not production-grade upstream.' }, minResources: { type: '{ memoryMb?, cpuCores? }', description: 'What the app needs from the machine. Shown against the chosen self-hosted destination as an advisory recommendation. See Host requirements.' }, schemaVersion: { type: 'number', description: 'JSON shape revision. Defaults to 1. See Stability and versioning.' }, minEngine: { type: 'string (semver)', description: 'Minimum Openship version required to install this app.' }, updatedAt: { type: 'string', description: 'Informational last-updated stamp.' }, repository: { type: 'string (URL)', description: 'Upstream source repository. Must be a valid URL.' }, }} />

Public app pages

The public Apps directory uses the same catalog as the installer. name, description, logo, category, and tags provide its cards and search. unlisted entries stay hidden; available: false entries appear as coming soon without an install button. Deployment settings and generated credentials are never included in the website's client data.

Add optional marketplace content for the app's detail page:

{
  "marketplace": {
    "features": [
      { "title": "Private analytics", "description": "Understand your audience without tracking individual visitors." },
      { "title": "Your own database", "description": "Keep analytics data on the infrastructure you choose." }
    ],
    "articleFile": "articles/umami.md"
  }
}

features contains short titles with optional descriptions. For a longer overview, author packages/core/src/apps/catalog/articles/umami.md and reference it with articleFile. The filename must follow articles/<slug>.md; paths outside that directory are rejected. Alternatively, use an inline article Markdown string. Choose one article source, not both.

Run bun scripts/gen-catalog.ts from packages/core after editing the JSON or Markdown. The generator embeds the file as marketplace.article in the published catalog.json and removes the authoring path. No separate article request is needed. The website renders Markdown headings, lists, code, images, and safe links; raw HTML is displayed as text. Apps without an article still have an overview, their feature summaries, and included services.

Development uses the bundled catalog to preview unpublished changes. Production reads the published GitHub artifact with a ten-minute cache and falls back to the bundled catalog if the fetch fails. Marketplace content is presentation only and never changes installation.

Services

Each entry becomes one container in the created project.

Services reach each other by `name` on the project network. A service named `ghost-db` is reachable at the host `ghost-db` — which is why a database host in `environment` is just the plain service name, never an IP or a localhost address.

<TypeTable type={{ name: { type: 'string', description: 'Service name, and its hostname on the project network.', required: true }, image: { type: 'string', description: 'Prebuilt image to pull. Pin a version — unpinned tags are rejected in review. Exactly one of image | build per service.' }, replacesImages: { type: 'string[]', description: 'Exact retired image names eligible for explicit operator-reviewed repair. Never automatically applied to installed services.' }, gpus: { type: 'ComposeGpu | ComposeGpu[]', description: 'NVIDIA requests applied in GPU mode; uses the same count/deviceIds/capabilities/options validation as Compose and the service API. Requires schemaVersion 2.' }, shmSize: { type: 'number', description: 'Private container /dev/shm size in bytes (1 byte–1 TiB). No host IPC or privileged mode. Requires schemaVersion 2.' }, replacesBuilds: { type: 'string[]', description: 'SHA-256 fingerprints of exact retired inline recipes eligible for explicit operator-reviewed conversion to this image. The repair tool requires the same official base and preserves custom recipes.' }, resources: { type: '{ cpuCores, memoryMb, diskMb }', description: 'Advisory sizing recommendation: CPU at least 0.25, whole MB of RAM at least 128, and whole MB of disk at least 64. All three fields are required when supplied. Never copied into installed container limits.' }, build: { type: 'ServiceBuild', description: 'Inline Docker build context — for a service Openship must BUILD rather than pull. Mutually exclusive with image. See Building an image.' }, ports: { type: 'string[]', description: 'Compose-style port strings, e.g. ["2368:2368"].' }, exposedPort: { type: 'number', description: 'The container port that receives the public route.' }, exposed: { type: 'boolean', description: 'Marks this service as the one that gets a public route.' }, routes: { type: '{ port, slugSuffix? }[]', description: 'For a service needing more than one public port. slugSuffix distinguishes the extra hostname.' }, environment: { type: 'Record<string, string>', description: 'Non-secret env defaults. Supports placeholders.' }, secretEnv: { type: 'string[]', description: 'Env keys on this service that are secrets — forced encrypted and never written as plaintext, whether the value comes from environment or a configField.' }, volumes: { type: 'string[]', description: 'Compose-style volumes. Named volumes are project-scoped.' }, dependsOn: { type: 'string[]', description: 'Other service names this one starts after.' }, healthcheck: { type: 'ComposeHealthcheck', description: '{ test, interval, timeout, retries, startPeriod, disable }. disable:true turns off an image baked-in check.' }, restart: { type: '"no" | "always" | "on-failure" | "unless-stopped"', description: 'Restart policy.' }, command: { type: 'string', description: 'Override the container command.' }, entrypointArgv: { type: 'string[]', description: 'Exact image entrypoint override, mapped to the existing Compose entrypoint. An empty array clears it. Requires minEngine >= 0.9.2.' }, commandArgv: { type: 'string[]', description: 'Exact container command arguments; useful for invoking a mounted startup script through its interpreter.' }, }} />

Service sizing recommendations

Catalog resources profiles are advisory. They never become installed CPU or memory limits on Cloud, Desktop or a connected server, and retrying an installation does not copy them into saved settings. minResources describes recommended host capacity.

Installed services use the user's saved service limits, inheriting project limits only for omitted dimensions. Without either setting, Docker receives no additional CPU or memory cap. Existing saved settings and frozen deployment snapshots remain unchanged; reset a service's limits explicitly to Full capacity if an older installer saved a catalog profile there.

Cloud services share the selected managed server. Its purchased VM capacity and billing remain independent of per-container limits. Automatic builds have no additional Docker CPU or memory limits; Cloud admission keeps them from overlapping other builds on the same server. A catalog update cannot resize the server or grant credits.

Building an image

Prefer a published image with its supported environment settings. Use top-level files and commandArgv when a setup script can run with tools already in that image. WordPress and Odoo follow this path: their startup helpers are mounted read-only and run before the application starts, so the customer does not need an image build. Initial account creation must finish before exposing a public installer, and redeployment must preserve existing accounts and data.

Use build when the image actually needs additional software or compiled assets, as with PocketBase's binary installation. It is an inline Docker build context that Openship materializes at deploy time and builds on the deploy host through the normal compose build pipeline. Use entrypointArgv with a read-only startup file when a published image has an executable entrypoint that cannot invoke the helper. Keep its native initialization behavior and gate the template to Openship 0.9.2 or newer. A build can also set its own ENTRYPOINT.

Set exactly one of image or build per service. Neither (nothing to run) and both (ambiguous) are rejected — service "<name>" must set exactly one of image|build.

<TypeTable type={{ dockerfile: { type: 'string', description: 'Full Dockerfile contents, inline.', required: true }, files: { type: '{ path: string, content: string }[]', description: 'Extra build-context files (scripts, config) the Dockerfile COPYs in.' }, }} />

COPY paths are prefixed by the service name

This is the rule authors get wrong first. Every buildable service is materialized under a subdir named after the service, inside one shared build context, and docker build runs with that shared root as its context. So COPY/ADD sources are relative to the root, not to the service's own subdir:

# service "compute", with files: [{ "path": "compute.sh", ... }]
COPY compute/compute.sh /shell/compute.sh   # ✅ prefixed with the service name
COPY compute.sh /shell/compute.sh           # ❌ COPY source not found → build fails

A files[].path is relative to the service's own subdir; you COPY it as <service-name>/<path>.

Values and build args

{{config:KEY}} placeholders are resolved in dockerfile and in every files[].content at install time, exactly as in environment values — so a generated secret or an install-step answer can flow into the build.

There is no args map. A build ARG the Dockerfile declares is fed from the project's build env: the runtime passes each env var as --build-arg. Declare the value as a normal environment entry (or a configField) and reference it with ARG in the Dockerfile.

A large base image builds where it deploys. On a small host a heavy build — Neon's compute node pulls a multi-hundred-MB base — can be slow or memory-bound. That cost is inherent to the app, not to Openship.

Two field systems

The single most common authoring mistake is mixing these up. There are two places to declare env, split by role:

configFields settings with installStep: true
For Machine-generated or derived env Human inputs the wizard renders
Rendered as a form input? No — resolved server-side Yes — text, select, number, …
Typical use generate: "secret" / "jwt", generateGroup a name, a toggle, a chosen option

If you want the user to fill it in, it is an installStep setting — not a configField. Use configFields only for values the operator never types.

configFields

Generated or derived env only. Each entry maps to one env key on one service.

<TypeTable type={{ key: { type: 'string', description: 'Env key this value writes to.', required: true }, service: { type: 'string', description: 'Service whose env receives it. Must name a declared service.', required: true }, label: { type: 'string', description: 'Human label, used in summaries.', required: true }, help: { type: 'string', description: 'Longer explanation.' }, type: { type: '"text" | "password"', description: 'Presentation hint.' }, default: { type: 'string', description: 'Default value when nothing is generated.' }, generate: { type: '"secret" | "jwt"', description: 'secret = random value the operator never types. jwt = a signed key (Supabase anon / service_role).' }, generateGroup: { type: 'string', description: 'Fields sharing a group get the SAME generated value — how a database password matches on both sides.' }, jwtSecretGroup: { type: 'string', description: 'For generate:"jwt" — the generateGroup holding the signing secret. Must match a declared generateGroup.' }, jwtRole: { type: 'string', description: 'Role claim baked into the generated JWT.' }, required: { type: 'boolean', description: 'Must resolve to a value.' }, secret: { type: 'boolean', description: 'Store encrypted and mask on display.' }, }} />

"configFields": [
  { "key": "MYSQL_ROOT_PASSWORD", "service": "ghost-db", "label": "Database password",
    "generate": "secret", "generateGroup": "ghostdb", "secret": true },
  { "key": "database__connection__password", "service": "ghost", "label": "Ghost DB password",
    "generate": "secret", "generateGroup": "ghostdb", "secret": true }
]

Both fields share generateGroup: "ghostdb", so one generated password is written to both sides and the two always match.

Settings

The curated settings form, grouped. A field with installStep: true is also collected in the install wizard — this is the human-input surface. Everything else is day-2 configuration. Validation is enforced live in the form and server-side on save.

Groups:

<TypeTable type={{ id: { type: 'string', description: 'Stable group id.', required: true }, label: { type: 'string', description: 'Group heading.', required: true }, description: { type: 'string', description: 'Group help text.' }, columns: { type: '1 | 2', description: 'Field columns when space allows. Applies to installed settings and grouped installs; narrow containers always stack.' }, fields: { type: 'AppSettingField[]', description: 'The fields in this group.', required: true }, }} />

Fields:

<TypeTable type={{ key: { type: 'string', description: 'Env key this field writes to.', required: true }, service: { type: 'string', description: 'Service whose env it writes to. Must name a declared service.', required: true }, label: { type: 'string', description: 'Field label.', required: true }, type: { type: '"text" | "password" | "boolean" | "select" | "number" | "multiselect" | "radio" | "textarea"', description: 'Input type.', required: true }, help: { type: 'string', description: 'Help text under the input.' }, options: { type: '{ value, label }[]', description: 'Choices for select, radio, and multiselect.' }, separator: { type: 'string', description: 'For multiselect — how chosen values are joined into the env value.' }, min: { type: 'number', description: 'Minimum, for type:"number".' }, max: { type: 'number', description: 'Maximum, for type:"number".' }, step: { type: 'number', description: 'Step, for type:"number".' }, integer: { type: 'boolean', description: 'Require a whole number.' }, pattern: { type: 'string', description: 'Regex the value must match.' }, patternError: { type: 'string', description: 'Message shown when pattern fails.' }, default: { type: 'string', description: 'Prefilled value.' }, placeholder: { type: 'string', description: 'Input placeholder.' }, fullWidth: { type: 'boolean', description: 'Span the whole row in a two-column form.' }, secret: { type: 'boolean', description: 'Store encrypted and mask on display.' }, trueValue: { type: 'string', description: 'For type:"boolean" — env value written when on (e.g. "1", "yes").' }, falseValue: { type: 'string', description: 'For type:"boolean" — env value written when off.' }, requiresRedeploy: { type: 'boolean', description: 'Changing this field needs a redeploy to take effect; the UI says so.' }, advanced: { type: 'boolean', description: 'Collapse into the advanced section.' }, installStep: { type: 'boolean', description: 'Also collect this field in the install wizard.' }, required: { type: 'boolean', description: 'Gate install or save on a value being present.' }, showIf: { type: '{ field, service?, equals?, truthy? }', description: 'Conditional visibility. equals or truthy only — no expressions.' }, }} />

Form layout

The catalog controls grouping and field widths. The dashboard supplies the controls, spacing, colors, and responsive behavior through the same form renderer used for installed app settings. Fields remain defined once in settings; only fields with installStep: true appear at install.

For a compact install such as Convex, add this at the top level:

{
  "installLayout": {
    "settings": "split"
  }
}

split puts the editable project name and install fields in two adjacent cards, stacking them when the available width is too small. Field labels identify each input without an extra card heading. This suits small forms such as Convex's Name and Instance name.

single combines the name and install fields in one card. grouped keeps a separate name card and renders each visible settings group with its label and description. Empty groups are omitted. Without installLayout, the existing separate name card and single vertical settings card remain. When reopening a draft, its project name stays hidden and the remaining settings use the full width.

columns defaults to 1. Set it to 2 for fields that fit side by side; the form stacks them when its available width is too small, including when dashboard navigation takes more room. In grouped mode, a group's own columns overrides the install default. That group setting also applies after installation. Set fullWidth: true on a field such as a long textarea to span both columns. Field order follows the catalog; conditional and advanced visibility still apply.

These optional hints are returned in the catalog API for consumers to render. They do not change environment values, required-field validation, or routing, and older clients can ignore them. Use these supported hints rather than CSS classes or executable layout expressions in catalog JSON.

Connection

The post-install Connection card: the URLs and keys a user needs, plus the handover into another project.

<TypeTable type={{ title: { type: 'string', description: 'Card heading.' }, description: { type: 'string', description: 'Card intro copy.' }, outputs: { type: 'Output[]', description: 'The values shown. See below.', required: true }, guide: { type: '{ intro?, useHint?, defaultMode? }', description: 'Guidance copy. defaultMode ("internal" | "public") preselects which form of the values is shown first.' }, firstLogin: { type: '{ username?, password?, note? }', description: 'Static default credentials the app ships with (for example Grafana admin/admin). Fixed values, not resolved from a running service — which is why they cannot live in outputs.' }, }} />

Each output:

<TypeTable type={{ id: { type: 'string', description: 'Stable id. Referenced by provides.outputRefs.', required: true }, label: { type: 'string', description: 'Row label.', required: true }, source: { type: 'string', description: 'Where the value comes from. See Output sources.', required: true }, help: { type: 'string', description: 'Row help text.' }, secret: { type: 'boolean', description: 'Mask the value with a reveal action.' }, envKey: { type: 'string', description: 'Env key to prefill when handing this value to another project.' }, service: { type: 'string', description: 'Source service alias. Authoritative for internal-mode host rewriting — set it when a template: source cannot carry the service.' }, recommended: { type: 'boolean', description: 'Highlight this output in the "Use in a project" handover.' }, sourceLabel: { type: 'string | localized', description: 'Labels the primary value when variants are present.' }, variants: { type: '{ id, label, source }[]', description: 'Labeled alternative forms of the same value — for example a public URL versus an internal-network URL. Rendered as a switch.' }, width: { type: '"full" | "half"', description: 'half pairs two short outputs on one line (for example user and password).' }, kind: { type: '"text" | "url"', description: 'url adds an open-in-new-tab action. Honored only when the resolved value is http(s).' }, }} />

Output sources

An output source (and each variant source) must match one of these three forms:

Form Resolves to
env:<service>:<KEY> That env value on that service
publicUrl:<service> or publicUrl:<service>:<port> The public URL for that service, optionally for a specific route
template:… A composed string, embedding {{env:svc:KEY}} placeholders

Anything else fails validation, and the <service> part must name a declared service.

Prepare

Commands run inside a service container after it starts — never a host shell. Their stdout can be captured and persisted as an env var (this is how an app like Convex gets its admin key).

A prepare step can run again on a later deploy. Write it idempotently, or gate it with `once`.

<TypeTable type={{ service: { type: 'string', description: 'Service whose container runs the command. Must name a declared service.', required: true }, command: { type: 'string', description: 'Command to run inside the container.', required: true }, capture: { type: 'string', description: 'Name for the captured stdout.', required: true }, capturePattern: { type: 'string', description: 'Regex to extract the value out of noisier output.' }, persistAs: { type: '{ key, secret? }', description: 'Persist the captured value as this env key, optionally encrypted.' }, once: { type: 'boolean', description: 'Run on first install only, never on later deploys.' }, phase: { type: '"pre-deploy" | "post-start" | "post-ready"', description: 'When it runs. Defaults to post-start.' }, mustSucceed: { type: 'boolean', description: 'Fail the deploy if the step errors. Default false = advisory.' }, readiness: { type: '{ test, interval?, retries? }', description: 'For phase:"post-ready" — poll test via sh -c every interval ms up to retries before running.' }, title: { type: 'string | localized', description: 'Display copy for this step in the install stepper.' }, description: { type: 'string | localized', description: 'Longer display copy for the stepper.' }, icon: { type: 'string', description: 'Icon id for the stepper row.' }, }} />

The schema accepts `"pre-deploy"`, but **the engine does not run it yet**. Do not rely on it. For pre-run database initialization use [`files`](#files) to write into `/docker-entrypoint-initdb.d`, combined with `dependsOn` and a `healthcheck`.

Endpoints

What the install wizard asks you about — how to reach each port. Omit endpoints entirely and Openship derives one public http endpoint per declared route of each exposed service.

Defaults follow the app's intended use on every deployment target:

  • Public HTTP endpoints, such as dashboards and APIs, start with Domain routing.
  • Raw TCP endpoints, such as databases, start Internal only unless their scope is public.
  • An explicit defaultMode overrides these defaults within allowedModes. Use it for choices such as a port-only API. Local or internal HTTP endpoints default to port-only access.

A Cloud connection only selects the domain provider: a managed free domain when connected, or a custom domain when disconnected. It does not switch a public UI to port-only routing. Custom domains still need a hostname and DNS setup. Users can choose another allowed mode; saved draft choices take precedence over fresh-install defaults.

<TypeTable type={{ service: { type: 'string', description: 'Service this port belongs to. Must name a declared service.', required: true }, port: { type: 'number', description: 'Container port.', required: true }, label: { type: 'string', description: 'How the wizard names this endpoint.', required: true }, kind: { type: '"http" | "tcp"', description: 'http is domain-routable; tcp is a raw port such as a database, with no domain.', required: true }, required: { type: 'boolean', description: 'The user must choose an exposure for it.' }, scope: { type: '"public" | "internal" | "local"', description: 'Declares reachability intent. Defaults to public for HTTP and internal for TCP.' }, defaultMode: { type: '"domain" | "port" | "publish" | "internal"', description: 'Preselected routing mode, constrained by allowedModes. Independent of the deployment target.' }, allowedModes: { type: 'string[]', description: 'Restrict which exposures are offered.' }, }} />

Provides and requires

The connection graph between projects. provides advertises a connectable bundle; requires declares a connection this app needs from another project — the install wizard then offers a same-org source picker and wires it in one shot. Same-org only, and always user-confirmed.

<TypeTable type={{ 'provides[].id': { type: 'string', description: 'Bundle id.', required: true }, 'provides[].outputRefs': { type: 'string[]', description: 'Ids of connection.outputs to include. Each must reference a real output.', required: true }, 'provides[].category': { type: 'string', description: 'Bundle category, used for matching.' }, 'requires[].id': { type: 'string', description: 'Requirement id.', required: true }, 'requires[].label': { type: 'string | localized', description: 'How the wizard names what is needed.', required: true }, 'requires[].envKey': { type: 'string', description: 'Env key the chosen connection is written to.', required: true }, 'requires[].category': { type: 'string', description: 'Restrict candidate sources to this category.' }, 'requires[].bundleId': { type: 'string', description: 'Match a specific provides bundle and use its first output. Prevents wiring an incompatible backend from the same category. Requires schemaVersion 2.' }, 'requires[].mode': { type: '"internal" | "public"', description: 'Which form of the source value to wire in.' }, 'requires[].optional': { type: 'boolean', description: 'Install can proceed without it.' }, }} />

Host requirements

minResources describes the host capacity recommended for the app. Declare it when upstream documents a meaningful requirement, such as 8 GB for a multi-service analytics stack. It does not set container limits.

{
  "minResources": { "memoryMb": 8192, "cpuCores": 4 }
}

On self-hosted destinations, the installer and deployment preflight compare these recommendations with the destination's Docker daemon (NCPU, MemTotal). A shortfall shows a warning; it never prevents a first installation or redeployment. An unavailable capacity reading means unknown, and no shortfall is inferred. A 10% tolerance accounts for memory reserved by the operating system on machines sold with round RAM sizes.

On Cloud, the installer checks the runtime configuration, including service overrides, against the organization's plan. It offers an upgrade when the allocation does not fit. Advanced setup remains available to adjust the configuration. Source builds use the remaining shared pool capacity reported by Oblien, with optional saved build caps; image-only templates need no source builder. Deployment checks again before queuing work, and Oblien enforces live capacity atomically when allocating or resizing a workspace.

GET /apps/catalog/{id}/host-fit exposes this preview. Pass projectId to include an existing draft's saved resource settings. The preview does not modify the project, allocate resources, or grant a deployment allowance.

Files

Generated config files bind-mounted into a container at deploy — for apps that need a config file, not just env (an init .sql, for example). Supports placeholders.

<TypeTable type={{ service: { type: 'string', description: 'Service to mount into. Must name a declared service.', required: true }, path: { type: 'string', description: 'Absolute path inside the container.', required: true }, content: { type: 'string', description: 'File contents.', required: true }, }} />

`files` are not applied on Openship Cloud.

Management

Overrides how the installed app is managed.

Value Effect
{ "kind": "schema" } Render the curated settings form from settings.
{ "kind": "custom", "href": "/emails" } Send the user to a bespoke surface instead.

Omit it and Openship derives the behavior: schema when settings exist, otherwise the raw project tabs.

Placeholders

Resolved at install. Usable in environment values, files[].content, and connection outputs:

Placeholder Resolves to
{{publicUrl:<service>}} That service public URL. Add :<port> for a specific route.
{{config:<KEY>}} A generated config value by key — for example a generate: "secret".

Localized strings

Several display-only fields accept either a plain string or an inline per-locale map:

{ "title": { "en": "Creating the admin key", "ar": "إنشاء مفتاح المدير" } }

This applies to prepare[].title, prepare[].description, requires[].label, connection.firstLogin.username / password / note, connection.guide.intro / useHint, connection.outputs[].sourceLabel, and variants[].label.

Validation

Beyond field types, these referential checks run at the gate — for both curated and custom apps — so a dangling reference fails immediately instead of at deploy time:

  • Every service reference resolves to a declared service — in configFields, prepare, endpoints, files, settings[].fields, and connection.outputs[].service.
  • Every connection.outputs[].source and variants[].source matches env:<service>:<KEY>, publicUrl:<service>[:<port>], or template:….
  • Every provides[].outputRefs entry names a real connection.outputs[].id.
  • Every jwtSecretGroup matches a declared generateGroup.
  • flowHref starts with / — it can never be an external URL.
  • category is one of the seven allowed values.

Stability and versioning

The catalog JSON is a stable, versioned public API. Growth is additive and backward-compatible: new optional fields and new enum members default to prior behavior, and a field is never repurposed or removed within a schema version.

<TypeTable type={{ minEngine: { type: 'string (semver)', description: 'THE version knob. Minimum Openship version required to install this app. Set it to the release that introduced whatever capability the definition now uses.' }, schemaVersion: { type: 'number', description: 'The JSON shape revision, default 1. An instance refuses a shape newer than it understands. Bump only on a genuinely breaking shape change.' }, }} />

One file per app — never author multiple versions. The repo always holds the single latest file. An older instance simply keeps the copy bundled in its own build. When an instance is older than an app minEngine:

  • the app is bundled there → it keeps serving the bundled copy, and may note that an update is available;
  • the app is brand new → the catalog shows a guided Requires Openship ≥ X card and install is refused, client and server. Never a silent disappearance.

See also