Guides

Add an app

Write the catalog JSON that makes a one-click Openship app, built up field by field from a bare service to a complete submission — plus how to ship it as a pull request or upload it privately to your own organization.

Openship's Apps tab is a catalog of one-click installs. Each entry is a single JSON file, and that file is the whole deliverable: it describes the containers, how they wire together, which secrets to generate, how it gets exposed, and what the user sees once it is running.

This page walks through writing one from scratch. Every field is catalogued in the App catalog JSON reference; here we build a real app end to end.

Curated catalog entryCustom app
HowA pull request adding catalog/<id>.jsonUpload JSON in your dashboard, or POST /api/apps/custom
Who sees itEveryone, on every instanceOnly your organization
ReviewedYes, by a maintainerNo — always marked unverified
Good forAn app the whole community wantsYour own internal stack, or trying an idea out

Both use the same format and the same strict validation, so everything below applies either way. Jump to the custom-app path if you are not opening a PR.

What a pull request contains

One file: packages/core/src/apps/catalog/<id>.json, plus the regenerated catalog.json. Optionally a logo SVG. That is it — no code.

packages/core/src/apps/catalog/umami.json    ← the app you wrote
packages/core/src/apps/catalog.json          ← regenerated (a drift test fails CI without it)
apps/dashboard/public/app-logos/umami.svg    ← only if the logo is not on simpleicons

The bar it has to clear

The catalog is curated, not open-ended — installing an app never runs arbitrary third-party code. Expect review on exactly these points:

  • Open-source only. A well-known project with a public source repository, so anyone can see what it is before installing.
  • Official or reputable images, pinned. The project's own published image at a pinned version (postgres:16-alpine, qdrant/qdrant:v1.18.3) — never an unpinned tag or an unknown publisher.
  • Secure defaults. Credentials auto-generated, never plaintext defaults. Publicly-exposed admin UIs require auth.
  • Fully auditable. The JSON is the whole truth — images, ports, env, volumes, all in one small file. Nothing fetched from a private source.

Build it up

We will add Umami, a self-hosted analytics app that needs a Postgres database. Each step below is one decision; the complete file is at the end.

Keep the $schema line

Start the file with "$schema": "https://openship.io/app.schema.json". Your editor then autocompletes field names and flags bad values — an invalid category, a wrong type, a missing required field. It cannot flag a misspelled field name, because unknown keys are deliberately ignored rather than rejected, so lean on the autocomplete.

1. Identity, and the app itself

id is a stable kebab-case slug and the filename must match it. Start with available: false so the app appears as coming soon and cannot be installed while you are still working on it.

{
  "$schema": "https://openship.io/app.schema.json",
  "available": false,
  "id": "umami",
  "name": "Umami",
  "description": "Privacy-focused web analytics — a lightweight, self-hosted alternative to Google Analytics.",
  "kind": "template",
  "logo": "umami",
  "category": "analytics",
  "tags": ["analytics", "privacy", "statistics"],
  "framework": "docker-compose",
  "services": [
    {
      "name": "umami",
      "image": "ghcr.io/umami-software/umami:postgresql-v2.11.3",
      "ports": ["3000:3000"],
      "exposedPort": 3000,
      "exposed": true,
      "restart": "unless-stopped"
    }
  ]
}

kind: "template" means "instantiate these services" — the normal case. (kind: "flow" exists only for apps with a bespoke built-in wizard, like the mail stack, and declares no services.)

2. Add the database, and share the generated password

Umami needs Postgres. Add it as a second service — and remember that the service name is the hostname: umami reaches the database at umami-db on the project network.

The password must never be a plaintext default. Declare it as a configField with generate: "secret", and Openship mints a random value at install that the operator never types.

The interesting part is getting that one generated password into two places — the database's own POSTGRES_PASSWORD, and the connection string the app reads:

"services": [
  {
    "name": "umami-db",
    "image": "postgres:16-alpine",
    "environment": { "POSTGRES_DB": "umami", "POSTGRES_USER": "umami" },
    "secretEnv": ["POSTGRES_PASSWORD"],
    "volumes": ["umami_db_data:/var/lib/postgresql/data"],
    "healthcheck": { "test": "pg_isready -U umami", "interval": "5s", "retries": 10 },
    "restart": "unless-stopped"
  },
  {
    "name": "umami",
    "image": "ghcr.io/umami-software/umami:postgresql-v2.11.3",
    "ports": ["3000:3000"],
    "exposedPort": 3000,
    "exposed": true,
    "dependsOn": ["umami-db"],
    "environment": {
      "DATABASE_TYPE": "postgresql",
      "DATABASE_URL": "postgresql://umami:{{config:POSTGRES_PASSWORD}}@umami-db:5432/umami"
    },
    "secretEnv": ["DATABASE_URL", "APP_SECRET"],
    "restart": "unless-stopped"
  }
],
"configFields": [
  { "key": "POSTGRES_PASSWORD", "service": "umami-db", "label": "Database password",
    "generate": "secret", "secret": true },
  { "key": "APP_SECRET", "service": "umami", "label": "App secret",
    "help": "Signs session tokens. Auto-generated.", "generate": "secret", "secret": true }
]

Three things worth understanding here, because they are where most authoring mistakes happen:

  • {{config:KEY}} inlines a generated value into any environment string. That is how one password ends up inside DATABASE_URL on a different service.
  • secretEnv lists env keys on that service which are secrets — forced encrypted and never written as plaintext, whether the value came from environment or from a configField. DATABASE_URL is listed because it embeds a secret.
  • dependsOn + healthcheck is how you make the app wait for a real, ready database rather than just a started container.

generateGroup is the other sharing mechanism

Use {{config:KEY}} when one generated value belongs in another service's env string. Use generateGroup when two separate configFields must receive the same generated value — for example a database's MYSQL_ROOT_PASSWORD and an app's own database__connection__password. Give both fields the same generateGroup and they always match.

3. Choose how it gets exposed

endpoints is what the install wizard asks the operator about. Omit it and Openship derives one http endpoint per exposed service — fine here, but declaring it lets you label it properly:

"endpoints": [
  { "service": "umami", "port": 3000, "label": "Dashboard", "kind": "http" }
]

Use kind: "tcp" for a raw port with no domain, such as a database that should not be domain-routable. defaultMode preselects an exposure and allowedModes restricts the choices.

A domain is never invented for you

Public hostnames come only from the operator's routing choice at install. An app that nobody routes installs port-only, which is the correct outcome for a database or an internal tool.

4. What the user sees after install

This is the part that separates a good submission from a bare one. connection becomes the post-install Connection card — the URLs and keys the user actually needs:

"connection": {
  "title": "Umami",
  "description": "Sign in to the dashboard, then add a website to get its tracking snippet.",
  "outputs": [
    { "id": "dashboard", "label": "Dashboard", "source": "publicUrl:umami",
      "kind": "url", "recommended": true }
  ],
  "firstLogin": {
    "username": "admin",
    "password": "umami",
    "note": "Change this immediately after the first sign-in."
  }
}
  • source must be env:<service>:<KEY>, publicUrl:<service>[:<port>], or template:….
  • kind: "url" adds an open-in-new-tab action next to the value.
  • secret: true masks a value behind a reveal action — use it for any credential.
  • envKey prefills the handover when a user wires this app into another project.
  • firstLogin is for static credentials the image ships with (Umami's admin / umami). Those are fixed values, not resolved from a running service, which is why they cannot be outputs.

5. Day-2 settings

settings renders a curated form instead of leaving the user to edit raw env. Anything with installStep: true is also collected during install:

"settings": [
  {
    "id": "tracking",
    "label": "Tracking",
    "description": "How the tracker behaves on your sites.",
    "fields": [
      { "key": "DISABLE_TELEMETRY", "service": "umami", "label": "Disable telemetry",
        "type": "boolean", "trueValue": "1", "falseValue": "0", "default": "0",
        "requiresRedeploy": true },
      { "key": "TRACKER_SCRIPT_NAME", "service": "umami", "label": "Tracker script name",
        "type": "text", "advanced": true, "requiresRedeploy": true }
    ]
  }
]

settings vs configFields — the #1 mistake

If the user types it, it is a setting (with installStep: true to collect it during install). If the operator never types it — a generated secret, a signed key, a derived default — it is a configField, which is resolved server-side and never rendered as an input. See the comparison table.

The complete file

Everything above, assembled — this is exactly what the PR adds:

{
  "$schema": "https://openship.io/app.schema.json",
  "available": false,
  "id": "umami",
  "name": "Umami",
  "description": "Privacy-focused web analytics — a lightweight, self-hosted alternative to Google Analytics.",
  "kind": "template",
  "logo": "umami",
  "category": "analytics",
  "tags": ["analytics", "privacy", "statistics"],
  "framework": "docker-compose",
  "services": [
    {
      "name": "umami-db",
      "image": "postgres:16-alpine",
      "environment": { "POSTGRES_DB": "umami", "POSTGRES_USER": "umami" },
      "secretEnv": ["POSTGRES_PASSWORD"],
      "volumes": ["umami_db_data:/var/lib/postgresql/data"],
      "healthcheck": { "test": "pg_isready -U umami", "interval": "5s", "retries": 10 },
      "restart": "unless-stopped"
    },
    {
      "name": "umami",
      "image": "ghcr.io/umami-software/umami:postgresql-v2.11.3",
      "ports": ["3000:3000"],
      "exposedPort": 3000,
      "exposed": true,
      "dependsOn": ["umami-db"],
      "environment": {
        "DATABASE_TYPE": "postgresql",
        "DATABASE_URL": "postgresql://umami:{{config:POSTGRES_PASSWORD}}@umami-db:5432/umami"
      },
      "secretEnv": ["DATABASE_URL", "APP_SECRET"],
      "restart": "unless-stopped"
    }
  ],
  "configFields": [
    { "key": "POSTGRES_PASSWORD", "service": "umami-db", "label": "Database password",
      "help": "Auto-generated. Also inlined into the app's DATABASE_URL.",
      "generate": "secret", "secret": true },
    { "key": "APP_SECRET", "service": "umami", "label": "App secret",
      "help": "Signs session tokens. Auto-generated.", "generate": "secret", "secret": true }
  ],
  "endpoints": [
    { "service": "umami", "port": 3000, "label": "Dashboard", "kind": "http" }
  ],
  "settings": [
    {
      "id": "tracking",
      "label": "Tracking",
      "description": "How the tracker behaves on your sites.",
      "fields": [
        { "key": "DISABLE_TELEMETRY", "service": "umami", "label": "Disable telemetry",
          "help": "Stop Umami sending anonymous usage data upstream.",
          "type": "boolean", "trueValue": "1", "falseValue": "0", "default": "0",
          "requiresRedeploy": true },
        { "key": "TRACKER_SCRIPT_NAME", "service": "umami", "label": "Tracker script name",
          "help": "Rename the tracker script to dodge ad blockers, e.g. \"stats\".",
          "type": "text", "advanced": true, "requiresRedeploy": true }
      ]
    }
  ],
  "connection": {
    "title": "Umami",
    "description": "Sign in to the dashboard, then add a website to get its tracking snippet.",
    "outputs": [
      { "id": "dashboard", "label": "Dashboard", "source": "publicUrl:umami",
        "kind": "url", "recommended": true }
    ],
    "firstLogin": {
      "username": "admin",
      "password": "umami",
      "note": "Change this immediately after the first sign-in."
    }
  }
}

Pin to the current release

The image tag above is illustrative. Check the project's releases and pin the version you actually tested.

Going further

Two fields worth knowing for richer apps, both covered in the reference:

  • prepare runs a command inside a service container after start — never a host shell — and can capture its stdout into an env var. This is how an app creates a first bucket or mints an admin key. Must be re-run safe, or gated with once.
  • provides / requires wire apps to each other: provides advertises a connectable bundle (an S3 endpoint plus its keys), and requires declares something this app needs from another project, which the wizard offers a picker for.

packages/core/src/apps/catalog/minio.json is a good real example of both.

Build a service instead of pulling an image

Every service so far has pulled a prebuilt image. Some apps can't — the upstream ships a base image that still needs extra packages and a provisioning entrypoint baked in (Neon's compute node is the canonical case). For those, give the service a build instead of an image: an inline Dockerfile plus its build-context files, which Openship materializes and docker builds on the deploy host at deploy time. Set exactly one of image or build per service.

{
  "name": "compute",
  "build": {
    "dockerfile": "FROM ghcr.io/example/base:1.2.3\nRUN apt-get update && apt-get install -y curl jq\nCOPY compute/start.sh /start.sh\nRUN chmod +x /start.sh\nENTRYPOINT [\"/start.sh\"]\n",
    "files": [
      { "path": "start.sh", "content": "#!/bin/sh\nexec my-server --config {{config:SERVER_MODE}}\n" }
    ]
  }
}

The one thing to get right: COPY sources are prefixed by the service name. Each buildable service is materialized under a subdir named after it inside one shared context, so a files[].path of start.sh on service compute is copied as COPY compute/start.sh …, not COPY start.sh …. {{config:KEY}} resolves in both dockerfile and files[].content; a Dockerfile ARG is fed from the project's build env (declare it as environment / a configField, no separate args map). Full rules in Building an image.

logo is an id resolved by the dashboard's AppLogo component:

  • On simpleicons? Set logo to the slug ("umami", "ghost", "grafana") and you are done.
  • Not there, or the mark renders wrong? Drop an SVG at apps/dashboard/public/app-logos/<id>.svg and add a LOGO_CONFIG entry mapping the id to it. Apps with no good mark fall back to a generic glyph.

Regenerate and validate

The bundle imports one merged artifact, so regenerate it and run the catalog test:

cd packages/core
bun scripts/gen-catalog.ts                  # rewrites src/apps/catalog.json from catalog/*.json
bunx vitest run src/apps/catalog.test.ts    # asserts sync + every app passes the full schema

The test checks the merged file is in sync and that your entry passes validation — required fields, valid category, and every referential rule (each service reference resolves, each output.source is well-formed, and so on).

Order in catalog.json is preserved from the existing file (curated, featured-first); a new app is appended alphabetically, so reorder it by hand if you want it featured earlier.

Ship it

Set "available": true once it deploys cleanly end to end. Until then it shows as coming soon and install is refused server-side, not just hidden in the UI.

A merged app reaches existing instances without a redeploy

The runtime catalog is the bundled copy overlaid by a live fetch of catalog.json from the repo, on a 10-minute TTL. So your app appears on instances that are already running — gated per-entry by minEngine. If your definition uses a capability that only shipped in a given release, set minEngine to it: older instances then show a guided Requires Openship ≥ X card instead of failing the install.

What the user sees on install

Four named phases, rendered as a stepper:

PhaseMeaning
Preparing imagesPulling or building. Skipped for pull-only apps.
Starting servicesCreating and starting the containers.
Finishing setupYour prepare steps. Present only if the app declares any.
LiveDone — the Connection card is available.

You can label your own prepare steps in that third phase with title and description.

Checklist

  • Open-source app; official or reputable image pinned to a version.
  • catalog/<id>.json created, id matches the filename, kebab-case.
  • Services wire by name; one service exposed with an exposedPort.
  • Secrets use generate with secret: true — no plaintext credentials anywhere.
  • Env that embeds a secret (a connection URL) is listed in secretEnv.
  • Anything the user types is an installStep setting, not a configField.
  • connection gives the user the URLs and keys they need, with secret: true on credentials.
  • Logo resolves — simpleicons slug, or a vendored SVG plus a LOGO_CONFIG entry.
  • bun scripts/gen-catalog.ts run and catalog.json committed.
  • bunx vitest run src/apps/catalog.test.ts passes.
  • minEngine set if the definition uses a recently added capability.
  • available: true only once it deploys cleanly end to end.

Upload a custom app (no pull request)

The same JSON, kept private to your organization and needing no review. In the dashboard open Apps → Add a custom app, paste or upload the file. Or over the API:

curl -X POST https://your-host/api/apps/custom \
  -H "Authorization: Bearer $OPENSHIP_TOKEN" \
  -H 'Content-Type: application/json' \
  --data @umami.json

It runs through the same strict validation as the curated catalog and installs through the normal services pipeline, with the same in-container boundary as any project you deploy yourself — no new privilege. Four rules are enforced:

  • Always unverified. Trust comes from provenance, never the file: verified and available are set by the server, so a JSON claiming "verified": true is ignored.
  • kind must be template. Flow apps point at built-in wizards and cannot be uploaded.
  • The id cannot shadow a built-in. Curated apps always win.
  • It must validate, with the offending detail returned in the error.

See also

On this page