Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Packaging Guide

StartOS is a Server OS – a Linux distribution optimized for administering servers. While operating systems like Mac, Windows, and Ubuntu are designed for client devices such as phones and laptops, StartOS provides a graphical interface for server administration that eliminates the need to “pop the hood” and use the command line.

Through the StartOS web interface, users can discover, download, install, configure, monitor, back up, and generally manage any variety of self-hosted, open-source software.

What is a StartOS Package?

What makes this experience possible is a unique package format (.s9pk) that permits services to take advantage of StartOS APIs. In its most basic form, a package is a thin metadata wrapper around a service that allows it to be discovered, installed, and run on StartOS. Beyond that, the StartOS APIs grant developers an incredible degree of creative capacity to define the end-user experience for their service. Developers can:

  • Display instructions and tooltips
  • Present alerts and warnings under certain conditions
  • Run arbitrary code on install, update, and uninstall
  • Represent configuration files as validated forms with all varieties of form inputs
  • Define scripts and commands that present as buttons with optional inputs
  • Write health checks that run on an interval and are optionally displayed
  • Automatically install and configure dependencies
  • Maintain state and optionally expose particular values to users or dependent services
  • Grant users flexible networking options such as LAN, Tor, and clearnet
  • Offer one-click, encrypted backups of targeted data

Table of Contents

  1. Environment Setup - Install the required development tools
  2. Quick Start - Create, build, and install your first package
  3. Project Structure - Understand the file layout of a StartOS package
  4. Manifest - Define your service metadata, release notes, and alerts
  5. Versions - Handle install, update, and downgrade logic
  6. Main - Configure daemons, health checks, and the service lifecycle
  7. Initialization - Run code when your service initializes
  8. Interfaces - Expose network interfaces to users
  9. Actions - Define user-facing buttons and scripts
  10. Tasks - Prompt users to run actions at the right time
  11. File Models - Represent and validate configuration files
  12. Dependencies - Declare and configure service dependencies
  13. Makefile - Automate build and install workflows
  14. Writing READMEs - Write effective service documentation

Environment Setup

Before building service packages, you need to install several development tools on your workstation. This page lists each prerequisite and how to install it.

StartOS Device

You must have a computer running StartOS to test your packages. Follow the installation guide to install StartOS on a physical device or VM.

Docker

Docker is essential for building and managing container images that will be used for the final .s9pk build. It handles pulling base images and building custom container images from Dockerfiles.

Follow the official Docker installation guide for your platform.

Make

Make is a build automation tool used to execute build scripts defined in Makefiles and coordinate the packaging workflow (building and installing s9pk binaries to StartOS).

Linux (Debian-based):

sudo apt install build-essential

macOS:

xcode-select --install

Node.js v22 (Latest LTS)

Node.js is required for compiling TypeScript code used in StartOS package configurations.

The recommended installation method is nvm:

nvm install 22
nvm use 22

You can also download Node.js directly from nodejs.org.

SquashFS

SquashFS is used to create compressed filesystem images that package your compiled service code.

Linux (Debian-based):

sudo apt install squashfs-tools squashfs-tools-ng

macOS (requires Homebrew):

brew install squashfs

Start CLI

start-cli is the core development toolkit for building StartOS packages. It provides package validation, s9pk file creation, and development workflow management.

Install using the automated installer script:

curl -fsSL https://start9labs.github.io/start-cli/install.sh | sh

Verification

After installation, verify all tools are available:

docker --version
make --version
node --version
mksquashfs -version
start-cli --version

Tip

If any command is not found, revisit the installation steps for that tool and ensure it is on your system PATH.

AI coding tools like Claude Code can dramatically accelerate your packaging workflow. To get the best results, set up a workspace that gives Claude direct access to the packaging guide.

1. Create a workspace directory

Create a directory that will serve as your AI-assisted workspace. This is not inside your package repo — it sits alongside it.

mkdir my-workspace && cd my-workspace

2. Clone the docs

Clone the Start9 docs repo so Claude can read the packaging guide locally:

git clone https://github.com/Start9Labs/docs.git start-docs

3. Add a CLAUDE.md

Important

This is critical. Without this file, Claude will not know how to package a service for StartOS.

Download the provided CLAUDE.md and place it in your workspace root:

Download CLAUDE.md

This file instructs Claude to use the local packaging guide as its primary reference.

4. Add your package repo

Clone or create your package repo inside the workspace:

git clone https://github.com/user/my-service-startos.git

Your workspace should look like this:

my-workspace/
├── CLAUDE.md            ← AI instructions (not committed anywhere)
├── start-docs/          ← packaging guide for Claude to read
└── my-service-startos/  ← your package repo

Note

Do not put start-docs/ or CLAUDE.md inside your package repo. They live alongside it in the workspace so they don’t pollute your package’s git history.

Quick Start

This guide walks you through creating your own service package repository from the Hello World template, building it, and installing it on StartOS.

Note

Ensure you have completed every step of Environment Setup before beginning.

Create Your Repository

  1. Navigate to the Hello World Template on GitHub.

  2. In the top right, click “Use this template > Create new repository”.

  3. Name your repository using the convention <service-name>-startos (e.g., nextcloud-startos).

  4. Click “Create Repository”.

  5. Clone your new repository:

    git clone https://github.com/YOUR-USERNAME/YOUR-REPO.git
    cd YOUR-REPO
    

Build

Install dependencies and build the package:

npm install
make

This generates a hello-world.s9pk file in the project root.

Install to StartOS

Option 1: Sideload via UI

Open the Sideload tab and upload the .s9pk.

Option 2: Direct Install (Local Network)

See Installation.

Next Steps

With Hello World running on your server, explore the rest of this packaging guide to learn how to build your own service.

Project Structure

Every StartOS service package follows a standard directory layout. This page documents the purpose of each file and directory in the project.

Root Directory Layout

A StartOS package follows this organizational pattern:

my-service-startos/
├── .github/
│   └── workflows/
│       ├── buildService.yml   # CI build on push/PR
│       └── releaseService.yml # Release on tag push
├── assets/                 # Supplementary files (required, can be empty)
│   └── README.md
├── startos/                # Primary development directory
│   ├── actions/            # User-facing action scripts
│   ├── fileModels/         # Type-safe config file representations
│   ├── i18n/               # Internationalization
│   │   ├── index.ts        # setupI18n() call (boilerplate)
│   │   └── dictionaries/
│   │       ├── default.ts  # English strings keyed by index
│   │       └── translations.ts  # Translations for other locales
│   ├── init/               # Container initialization logic
│   ├── install/            # Version management and migrations
│   │   └── versions/
│   ├── manifest/           # Static service metadata
│   │   ├── index.ts        # setupManifest() call
│   │   └── i18n.ts         # Static translations: manifest descriptions/alerts
│   ├── backups.ts          # Backup volumes and exclusions
│   ├── dependencies.ts     # Service dependencies
│   ├── index.ts            # Exports (boilerplate)
│   ├── interfaces.ts       # Network interface definitions
│   ├── main.ts             # Daemon runtime and health checks
│   ├── sdk.ts              # SDK initialization (boilerplate)
│   └── utils.ts            # Package-specific utilities
├── .gitignore
├── CONTRIBUTING.md         # Build instructions for contributors
├── Dockerfile              # Optional - for custom images
├── icon.svg                # Service icon (max 40 KiB)
├── LICENSE                 # Package license (symlink to upstream)
├── Makefile                # Project config (includes s9pk.mk)
├── s9pk.mk                 # Shared build logic (boilerplate)
├── package.json
├── package-lock.json
├── README.md               # Service documentation (see Writing READMEs)
├── tsconfig.json
└── upstream-project/       # Git submodule (optional)

Core Files

Boilerplate Files

These files typically require minimal modification:

  • .gitignore
  • Makefile - Just includes s9pk.mk (see Makefile)
  • s9pk.mk - Shared build logic, copy from template without modification
  • package.json / package-lock.json
  • tsconfig.json

.github/workflows/

Every package should include two GitHub Actions workflows that delegate to shared-workflows:

buildService.yml – builds the .s9pk on push/PR:

name: Build Service

on:
  workflow_dispatch:
  pull_request:
    paths-ignore: ["*.md"]
    branches: ["master"]
  push:
    paths-ignore: ["*.md"]
    branches: ["master"]

concurrency:
  group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
  cancel-in-progress: true

jobs:
  build:
    if: github.event.pull_request.draft == false
    uses: start9labs/shared-workflows/.github/workflows/buildService.yml@master
    secrets:
      DEV_KEY: ${{ secrets.DEV_KEY }}

releaseService.yml – publishes on tag push:

name: Release Service

on:
  push:
    tags:
      - "v*.*"

jobs:
  release:
    uses: start9labs/shared-workflows/.github/workflows/releaseService.yml@master
    with:
      REGISTRY: ${{ vars.REGISTRY }}
      S3_S9PKS_BASE_URL: ${{ vars.S3_S9PKS_BASE_URL }}
    secrets:
      DEV_KEY: ${{ secrets.DEV_KEY }}
      S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
      S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
    permissions:
      contents: write

CONTRIBUTING.md

Build instructions for contributors. Keep it short – link to the StartOS Packaging Guide for environment setup, then provide npm ci and make as a quick start.

Dockerfile (optional)

It is recommended to pull an existing Docker image as shown in the Quick Start. If necessary, you can define a custom image using a Dockerfile in the project root.

icon.svg

The service’s visual identifier. Maximum size is 40 KiB. Accepts .svg, .png, .jpg, and .webp formats.

LICENSE

The package’s software license, which should always match the upstream service’s license. If your package contains multiple upstream services with different licenses, select the more restrictive license.

If you have a git submodule, symlink to its license:

ln -sf upstream-project/LICENSE LICENSE

If you are pulling a pre-built Docker image (no submodule), copy the license text directly from the upstream repository.

README.md

Service documentation following the structure described in Writing READMEs. Every README should document how the StartOS package differs from the upstream service.

assets/

Stores supplementary files and scripts needed by the service, such as configuration generators. Required – create with at least a README.md if empty. It should rarely be necessary to use this directory.

startos/

The startos/ directory is where you take advantage of the StartOS SDK and APIs. This is the primary development directory containing all SDK integration files and package logic.

Core TypeScript Modules

FilePurpose
main.tsDaemon runtime configuration and health checks
interfaces.tsNetwork interface definitions and port bindings
backups.tsBackup volumes and exclusion patterns
dependencies.tsService dependencies and version requirements
sdk.tsSDK initialization (boilerplate)
utils.tsPackage-specific constants and helper functions
index.tsModule exports (boilerplate)

backups.ts

setupBackups() is where you define what volumes to back up as well as what directories or files to exclude from backups.

dependencies.ts

setupDependencies() is where you define any dependencies of this package, including their versions, whether or not they need to be running or simply installed, and which health checks, if any, need to be passing for this package to be satisfied.

index.ts

This file is plumbing, used for exporting package functions to StartOS.

interfaces.ts

setupInterfaces() is where you define the service interfaces and determine how they are exposed. This function executes on service install, update, and config save. It takes the user’s config input as an argument, which will be null for install and update.

main.ts

setupMain() is where you define the daemons that compose your service’s runtime. It runs each time the service is started. Daemon comes with built-in health checks that can optionally be displayed to the user. You can also use setupMain() to define additional health checks, such as tracking and displaying a sync percentage.

manifest/

The manifest directory defines static metadata about the service, such as ID, name, description, release notes, helpful links, volumes, images, hardware requirements, alerts, and dependencies. See Manifest for details.

sdk.ts

This file is plumbing, used to imbue the generic Start SDK with package-specific type information defined in manifest.ts and store.ts. The exported SDK is what should be used throughout the startos/ directory. It is a custom SDK just for this package.

utils.ts

This file is for defining constants and functions specific to your package that are used throughout the code base. Many packages will not make use of this file.

Subdirectories

DirectoryPurpose
actions/Custom user-facing scripts displayed as buttons in the UI
fileModels/Type-safe representations of config files (.json, .yaml, .toml, etc.)
i18n/Internationalization: default dictionary and translated strings
init/Container initialization logic (install, update, restart)
install/Version management and migration logic
manifest/Service metadata (ID, name, description, images) with i18n

actions/

actions/
├── index.ts
├── action1.ts
└── action2.ts

In the actions/ directory, you define custom actions for your package.

Actions are predefined scripts that display as buttons to the user. They accept arbitrary input and return structured data that can be optionally displayed masked or as QR codes. For example, a config.ts action might present a validated form that represents an underlying config file of the service, allowing users to configure the service without needing SSH or the command line. A resetPassword action could use the upstream service’s CLI to generate a new password for the primary admin, then display it to the user.

Each action receives its own file and is also passed into Actions.of() in actions/index.ts.

fileModels/ (optional)

fileModels/
├── store.json.ts
└── config.json.ts

In the fileModels/ directory, you can create separate .ts files from which you export a file model for each file from the file system you want to represent. Supported file formats are .yaml, .toml, .json, .env, .ini, .txt. For alternative file formats, you can use the raw method and provide custom serialization and parser functions.

These .ts files afford a convenient and type-safe way for your package to read, write, monitor, and react to files on the file system.

It is common for packages to have a store.json.ts file model as a convenient place to persist arbitrary data that are needed by the package but not persisted by the upstream service. For example, you might use store.json to persist startup flags or login credentials.

init/

init/
├── index.ts
├── taskCreateAdmin.ts
└── seedDatabase.ts

In the init/ directory, you define the container initialization sequence for your package as well as optional custom init functions. Name each init file specifically for what it does (e.g., taskCreateAdmin.ts, seedDatabase.ts) rather than using a generic name like initializeService.ts.

Container initialization takes place under the following circumstances:

  1. Package install (including fresh install, update, downgrade, and restore)
  2. Server (not service) restart
  3. “Container Rebuild” (a built-in Action that must be manually triggered by the user)

Note

Starting or restarting a service does not trigger container initialization. Even if a service is stopped, the container still exists with event listeners still active.

init/index.ts

setupInit() is where you define the specific order in which functions will be executed when your container initializes.

  • restoreInit and versionGraph must remain first and second. Do not move them.
  • setInterfaces, setDependencies, actions are recommended to remain in this order, but could be rearranged if necessary.
  • Any custom init functions can be appended to the list of built-in functions, or even inserted between them. Most custom init functions are simply appended to the list.

It is possible to limit the execution of custom init functions to specific kinds of initialization. For example, if you only wanted to run a particular init function on fresh install and ignore it for updates and restores, setupOnInit() provides a kind variable (one of install, update, restore) that you can use for conditional logic. kind can also be null, which means the container is being initialized due to a server restart or manual container rebuild, rather than installation.

install/

install/
├── versions/
└── versionGraph.ts

In the install/ directory, you manage package versions and define pre-install and migration logic. See Versions for full details.

versionGraph.ts

VersionGraph.of() is where you index the current version as well as other versions of your package. The function accepts a preInstall argument where you can define custom logic to run once, before anything else, on initial installation only. A common use case of the preInstall function is to seed files that other init functions expect to exist.

versions/

versions/
├── index.ts
├── v1_0_3_2.ts
└── v1_0_2_0.ts

In the versions/ directory, you create a new file for each new package version. In each version file, use VersionInfo.of() to provide the version number, release notes, and any migrations that should run.

Similar to preInstall, migration up and down functions run once, before anything else, upon updating or downgrading to that version only.

All versions should then be provided in index.ts, either as the current version or list of other versions.

Warning

Migrations are only for migrating data that is not migrated by the upstream service itself.

Manifest

The manifest defines service identity, metadata, and build configuration. It lives in startos/manifest/ as two files:

  • index.ts – the setupManifest() call
  • i18n.ts – translated strings for description and alerts

manifest/i18n.ts

Locale objects for user-facing manifest strings. Each is a record of locale to string:

export const short = {
  en_US: 'Brief description (one line)',
  es_ES: 'Descripcion breve (una linea)',
  de_DE: 'Kurze Beschreibung (eine Zeile)',
  pl_PL: 'Krotki opis (jedna linia)',
  fr_FR: 'Description breve (une ligne)',
}

export const long = {
  en_US:
    'Longer description explaining what the service does and its key features.',
  es_ES:
    'Descripcion mas larga que explica que hace el servicio y sus caracteristicas principales.',
  de_DE:
    'Langere Beschreibung, die erklart, was der Dienst tut und seine wichtigsten Funktionen.',
  pl_PL:
    'Dluzszy opis wyjasniajacy, co robi usluga i jej kluczowe funkcje.',
  fr_FR:
    'Description plus longue expliquant ce que fait le service et ses fonctionnalites principales.',
}

// Export alertInstall, alertUpdate, etc. as needed (or null for no alert)

manifest/index.ts

import { setupManifest } from '@start9labs/start-sdk'
import { short, long } from './i18n'

export const manifest = setupManifest({
  id: 'my-service',
  title: 'My Service',
  license: 'MIT',
  packageRepo: 'https://github.com/Start9Labs/my-service-startos',
  upstreamRepo: 'https://github.com/original/my-service',
  marketingUrl: 'https://example.com/',
  donationUrl: null,
  docsUrls: ['https://docs.example.com/guides'],
  description: { short, long },
  volumes: ['main'],
  images: {
    /* see Images Configuration below */
  },
  alerts: {
    install: null,
    update: null,
    uninstall: null,
    restore: null,
    start: null,
    stop: null,
  },
  dependencies: {},
})

Required Fields

FieldDescription
idUnique identifier (lowercase, hyphens allowed)
titleDisplay name shown in UI
licenseSPDX identifier (MIT, Apache-2.0, GPL-3.0, etc.)
packageRepoURL to the StartOS package repository
upstreamRepoURL to the original project repository
marketingUrlURL for the project’s main website
donationUrlDonation URL or null
docsUrlsArray of URLs to upstream documentation
description.shortLocale object (see manifest/i18n.ts)
description.longLocale object (see manifest/i18n.ts)
volumesStorage volumes (usually ['main'])
imagesDocker image configuration (including arch)
alertsUser notifications for lifecycle events (locale objects or null)
dependenciesService dependencies

License

Check the upstream project’s LICENSE file and use the correct SPDX identifier (e.g., MIT, Apache-2.0, GPL-3.0). If you have a git submodule, symlink to its license. Otherwise, copy the license text directly from the upstream repository:

# With submodule
ln -sf upstream-project/LICENSE LICENSE

# Without submodule -- copy from upstream repo

Icon

Symlink from upstream if available (svg, png, jpg, or webp, max 40 KiB):

ln -sf upstream-project/logo.svg icon.svg

Images Configuration

Each image can include an arch field specifying supported architectures. It defaults to ['x86_64', 'aarch64', 'riscv64'] if omitted, but it is good practice to list architectures explicitly for transparency. The arch field must align with the ARCHES variable in the Makefile.

Pre-built Docker Tag

Use when an image exists on Docker Hub or another registry:

images: {
  main: {
    source: {
      dockerTag: 'nginx:1.25',
    },
    arch: ['x86_64', 'aarch64'],
  },
},

Local Docker Build

Use when building from a Dockerfile in the project:

// Dockerfile in project root
images: {
  main: {
    source: {
      dockerBuild: {},
    },
    arch: ['x86_64', 'aarch64'],
  },
},

If upstream has a working Dockerfile: Set workdir to the upstream directory. If the Dockerfile is named Dockerfile, you can omit the dockerfile field:

images: {
  main: {
    source: {
      dockerBuild: {
        workdir: './upstream-project',
      },
    },
    arch: ['x86_64', 'aarch64'],
  },
},

For a non-standard Dockerfile name, specify dockerfile relative to project root:

images: {
  main: {
    source: {
      dockerBuild: {
        workdir: './upstream-project',
        dockerfile: './upstream-project/sync-server.Dockerfile',
      },
    },
    arch: ['x86_64', 'aarch64'],
  },
},

If you need a custom Dockerfile: Create one in your project root:

COPY upstream-project/ .

Architecture Support

The arch field accepts these values:

ValueArchitecture
x86_64Intel/AMD 64-bit
aarch64ARM 64-bit
riscv64RISC-V 64-bit

Most services support ['x86_64', 'aarch64']. Only add riscv64 if the upstream image actually supports it. The ARCHES variable in the Makefile must align (see Makefile).

GPU/Hardware Acceleration

For services requiring GPU access:

images: {
  main: {
    source: {
      dockerTag: 'ollama/ollama:0.13.5',
    },
    arch: ['x86_64', 'aarch64'],
    nvidiaContainer: true,  // Enable NVIDIA GPU support
  },
},
hardwareAcceleration: true,  // Top-level flag

Multiple Images

Services can define multiple images. Each image needs its own arch field:

images: {
  app: {
    source: { dockerTag: 'myapp:latest' },
    arch: ['x86_64', 'aarch64'],
  },
  db: {
    source: { dockerTag: 'postgres:15' },
    arch: ['x86_64', 'aarch64'],
  },
},

Alerts

Display messages to users during lifecycle events. Use locale objects for translated alerts, or null for no alert:

// In manifest/i18n.ts
export const alertInstall = {
  en_US: 'After installation, run the "Get Admin Credentials" action to retrieve your password.',
  es_ES: 'Despues de la instalacion, ejecute la accion "Obtener credenciales de administrador" para recuperar su contrasena.',
  de_DE: 'Fuhren Sie nach der Installation die Aktion "Admin-Zugangsdaten abrufen" aus, um Ihr Passwort abzurufen.',
  pl_PL: 'Po instalacji uruchom akcje "Pobierz dane administratora", aby uzyskac haslo.',
  fr_FR: "Apres l'installation, executez l'action 'Obtenir les identifiants admin' pour recuperer votre mot de passe.",
}

// In manifest/index.ts
import { short, long, alertInstall } from './i18n'

alerts: {
  install: alertInstall,
  update: null,
  uninstall: null,
  restore: null,
  start: null,
  stop: null,
},

Volumes

Storage volumes for persistent data. When possible, prefer matching the upstream project’s volume naming convention for clarity:

// If upstream docker-compose uses a volume named "mcaptcha-data"
volumes: ['mcaptcha-data'],

// Simple services can use 'main'
volumes: ['main'],

For services needing separate storage areas:

volumes: ['main', 'db', 'config'],

Reference these in main.ts mounts by the volume ID you chose.

Dependencies

Declare dependencies on other StartOS services. Note that dependency description is a plain string, not a locale object:

dependencies: {
  // Required dependency
  bitcoin: {
    description: 'Required for blockchain data',
    optional: false,
  },

  // Optional dependency with metadata
  'c-lightning': {
    description: 'Needed for Lightning payments',
    optional: true,
    metadata: {
      title: 'Core Lightning',
      icon: 'https://raw.githubusercontent.com/Start9Labs/cln-startos/refs/heads/master/icon.png',
    },
  },
},

Versions

StartOS uses Extended Versioning (ExVer) to manage package versions, allowing downstream maintainers to release updates without upstream changes.

Version Format

[#flavor:]<upstream>[-upstream-prerelease]:<downstream>[-downstream-prerelease]
ComponentDescriptionExample
flavorOptional variant for diverging forks#libre:
upstreamUpstream project version (SemVer)26.0.0
upstream-prereleaseUpstream prerelease suffix-beta.1
downstreamStartOS wrapper revision0, 1, 2
downstream-prereleaseWrapper prerelease suffix-alpha.0, -beta.0

Flavor

Flavors are for diverging forks of a project that maintain separate version histories. Example: if a project forks into “libre” and “pro” editions that diverge significantly, each would have its own flavor prefix.

Note

Do NOT use flavors for hardware variants (like GPU types) – those should be handled via build configuration.

Examples

Version StringUpstreamDownstream
26.0.0:026.0.0 (stable)0 (stable)
26.0.0:0-beta.026.0.0 (stable)0-beta.0
26.0.0-rc.1:0-alpha.026.0.0-rc.10-alpha.0
0.13.5:0-alpha.00.13.5 (stable)0-alpha.0
2.3.2:1-beta.02.3.2 (stable)1-beta.0

Version Ordering

Versions are compared by:

  1. Upstream version (most significant)
  2. Upstream prerelease (stable > rc > beta > alpha)
  3. Downstream revision
  4. Downstream prerelease (stable > rc > beta > alpha)

Example ordering (lowest to highest):

  • 1.0.0-alpha.0:0
  • 1.0.0-beta.0:0
  • 1.0.0:0-alpha.0
  • 1.0.0:0-beta.0
  • 1.0.0:0 (fully stable)
  • 1.0.0:1-alpha.0
  • 1.0.0:1
  • 1.1.0:0-alpha.0

Choosing a Version

When creating a new package:

  1. Select the latest stable upstream version – avoid prereleases (alpha, beta, rc) unless necessary.
  2. Match the Docker image tag – the version in manifest/index.ts images.*.source.dockerTag must match the upstream version.
  3. Match the git submodule – if using a submodule, check out the corresponding tag.
  4. Start downstream at 0 – increment only when making wrapper-only changes.
  5. Start downstream as alpha or beta – use -alpha.0 or -beta.0 for initial releases.

Version Consistency Checklist

Ensure these all match for upstream version X.Y.Z:

  • Version file exists: startos/install/versions/vX.Y.Z.0.a0.ts
  • Version string matches: version: 'X.Y.Z:0-alpha.0' in VersionInfo
  • Docker tag matches: dockerTag: 'image:X.Y.Z' in manifest/index.ts (if using pre-built image)
  • Git submodule checked out to vX.Y.Z tag (if applicable)

File Structure

startos/install/versions/
├── index.ts              # Exports current and historical versions
├── v1.0.0.0.a0.ts        # Version 1.0.0:0-alpha.0
├── v1.0.0.0.ts           # Version 1.0.0:0 (stable)
└── v1.1.0.0.a0.ts        # Version 1.1.0:0-alpha.0

Version File Naming

Convert the version string to a filename:

  • Replace . and : with .
  • Replace -alpha. with .a
  • Replace -beta. with .b
  • Prefix with v
VersionFilename
26.0.0:0-beta.0v26.0.0.0.b0.ts
0.13.5:0-alpha.0v0.13.5.0.a0.ts
2.3.2:1v2.3.2.1.ts

Version File Template

import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk'

export const v_X_Y_Z_0_a0 = VersionInfo.of({
  version: 'X.Y.Z:0-alpha.0',
  releaseNotes: {
    en_US: 'Initial release for StartOS',
    es_ES: 'Version inicial para StartOS',
    de_DE: 'Erstveeroffentlichung fuer StartOS',
    pl_PL: 'Pierwsze wydanie dla StartOS',
    fr_FR: 'Version initiale pour StartOS',
  },
  migrations: {
    up: async ({ effects }) => {},
    down: IMPOSSIBLE,  // Use for initial versions or breaking changes
  },
})

index.ts

export { v_X_Y_Z_0_a0 as current } from './vX.Y.Z.0.a0'
export const other = []  // Add previous versions here for migrations

Incrementing Versions

Upstream Update

When the upstream project releases a new version:

  1. Update git submodule to new tag
  2. Update dockerTag in manifest/index.ts
  3. Create new version file with new upstream version
  4. Reset downstream to 0

Wrapper-Only Changes

When making changes to the StartOS wrapper without upstream changes:

  1. Keep upstream version the same
  2. Increment downstream revision
  3. Create new version file

Promoting Prereleases

To promote from alpha to beta to stable:

  1. Create new version file without prerelease suffix (or with next stage)
  2. Update index.ts to export new version as current
  3. Move old version to other array

Migrations

Migrations run when users update between versions:

migrations: {
  up: async ({ effects }) => {
    // Code to migrate from previous version
    // Access volumes, update configs, etc.
  },
  down: async ({ effects }) => {
    // Code to rollback (if possible)
  },
}

Use IMPOSSIBLE for the down migration when:

  • It is the initial version (nothing to roll back to)
  • The migration involves breaking changes that cannot be reversed
migrations: {
  up: async ({ effects }) => {
    // Migration logic
  },
  down: IMPOSSIBLE,
}

Warning

Migrations are only for migrating data that is not migrated by the upstream service itself.

setupOnInit

Use sdk.setupOnInit() to run setup logic during installation, restore, or container rebuild. It receives a kind parameter:

KindWhen it runs
'install'Fresh install
'restore'Restoring from backup
nullContainer rebuild (no data changes)

Bootstrapping Config Files

Generate passwords, write initial config files, and seed stores on fresh install:

// init/seedFiles.ts
export const seedFiles = sdk.setupOnInit(async (effects, kind) => {
  if (kind !== 'install') return

  const secretKey = utils.getDefaultString({ charset: 'a-z,A-Z,0-9', len: 32 })
  await storeJson.merge(effects, { secretKey })
  await configToml.merge(effects, { /* initial config */ })
})

Creating Tasks

Tasks reference actions, so they must be created in a setupOnInit that runs after actions are registered in the init sequence:

// init/initializeService.ts
export const initializeService = sdk.setupOnInit(async (effects, kind) => {
  if (kind !== 'install') return
  await sdk.action.createOwnTask(effects, toggleRegistrations, 'important', {
    reason: 'After creating your admin account, disable registrations.',
  })
})

Main

setupMain() defines the runtime behavior of your service – daemons, health checks, volume mounts, environment variables, and config file generation. It runs each time the service is started.

Basic Structure

import { i18n } from "./i18n";
import { sdk } from "./sdk";
import { uiPort } from "./utils";

export const main = sdk.setupMain(async ({ effects }) => {
  /**
   * ======================== Setup (optional) ========================
   *
   * In this section, we fetch any resources or run any desired preliminary commands.
   */
  console.info(i18n("Starting Hello World!"));

  /**
   * ======================== Daemons ========================
   *
   * In this section, we create one or more daemons that define the service runtime.
   *
   * Each daemon defines its own health check, which can optionally be exposed to the user.
   */
  return sdk.Daemons.of(effects).addDaemon("primary", {
    subcontainer: await sdk.SubContainer.of(
      effects,
      { imageId: "hello-world" },
      sdk.Mounts.of().mountVolume({
        volumeId: "main",
        subpath: null,
        mountpoint: "/data",
        readonly: false,
      }),
      "hello-world-sub",
    ),
    exec: { command: ["hello-world"] },
    ready: {
      display: i18n("Web Interface"),
      fn: () =>
        sdk.healthCheck.checkPortListening(effects, uiPort, {
          successMessage: i18n("The web interface is ready"),
          errorMessage: i18n("The web interface is not ready"),
        }),
    },
    requires: [],
  });
});

Reactive vs One-time Reads

When reading configuration in main.ts, you choose how the system responds to changes:

MethodReturnsBehavior on Change
.once()Parsed content onlyNothing – value is stale
.const(effects)Parsed contentRe-runs the setupMain context, restarting daemons
// Reactive: re-runs setupMain when value changes (restarts daemons)
const store = await storeJson.read().const(effects);

// One-time: read once, no re-run on change
const store = await storeJson.read().once();

Subset Reading

Use a mapper function to read only specific fields. This is more efficient and limits reactivity to only the fields you care about:

// Read only secretKey - re-runs only if secretKey changes
const secretKey = await storeJson.read((s) => s.secretKey).const(effects);

Other Reading Methods

MethodPurpose
.onChange(effects, callback)Register callback for value changes
.watch(effects)Create async iterator of new values

Getting Hostnames

Use a mapper function to extract only the data you need. The service only restarts if the mapped result changes, not if other interface properties change:

// With mapper - only restarts if hostnames change
const allowedHosts =
  (await sdk.serviceInterface
    .getOwn(effects, "ui", (i) =>
      i?.addressInfo?.format("hostname-info").map((h) => h.hostname.value),
    )
    .const()) || [];

// Without mapper - restarts on any interface change (not recommended)
const uiInterface = await sdk.serviceInterface.getOwn(effects, "ui").const();
const allowedHosts =
  uiInterface?.addressInfo
    ?.format("hostname-info")
    .map((h) => h.hostname.value) ?? [];

Oneshots (Runtime)

Oneshots are tasks that run on every startup before daemons. Use them for idempotent operations like migrations:

// change ownership of a directory
.addOneshot('chown', {
  subcontainer,
  exec: {
    command: ['chown', '-R', 'user:user', '/data',],
    user: 'root',
  },
  requires: [],
})
.addOneshot('collectstatic', {
  subcontainer: appSub,
  exec: { command: ['python', 'manage.py', 'collectstatic', '--noinput'] },
  requires: ['migrate'],
})

Warning

Do NOT put one-time setup tasks (like createsuperuser) in main.ts oneshots – they run on every startup and will fail on subsequent runs. Use init/initializeService.ts instead. See Initialization Patterns for details.

Exec Command

Using Upstream Entrypoint

If the upstream Docker image has a compatible ENTRYPOINT/CMD, use sdk.useEntrypoint() instead of specifying a custom command. This is the simplest approach and ensures compatibility with the upstream image:

.addDaemon('primary', {
  subcontainer: appSub,
  exec: {
    command: sdk.useEntrypoint(),
  },
  // ...
})

You can pass an array of arguments to override the image’s CMD while keeping the ENTRYPOINT:

.addDaemon('postgres', {
  subcontainer: postgresSub,
  exec: {
    command: sdk.useEntrypoint(['-c', 'listen_addresses=127.0.0.1']),
  },
  // ...
})

When to use sdk.useEntrypoint():

  • Upstream image has a working entrypoint that starts the service correctly
  • You want to use the entrypoint but optionally override CMD arguments
  • Examples: Ollama, Jellyfin, Vaultwarden, Postgres

Custom Command

Use a custom command array when you need to bypass the entrypoint entirely:

.addDaemon('primary', {
  subcontainer: appSub,
  exec: {
    command: ['/opt/app/bin/start.sh', '--port=' + uiPort],
  },
  // ...
})

Environment Variables

Pass environment variables to a daemon or oneshot via the env option on exec:

.addDaemon('main', {
  subcontainer: appSub,
  exec: {
    command: sdk.useEntrypoint(),
    env: {
      DATABASE_URL: 'sqlite:///data/db.sqlite3',
      SECRET_KEY: store?.secretKey ?? '',
    },
  },
  // ...
})

Health Checks

All user-facing strings must be wrapped with i18n():

ready: {
  display: i18n('Web Interface'),  // Shown in UI
  fn: () =>
    sdk.healthCheck.checkPortListening(effects, 8080, {
      successMessage: i18n('Service is ready'),
      errorMessage: i18n('Service is not ready'),
    }),
},

Volume Mounts

sdk.Mounts.of()
  // Mount entire volume (directory)
  .mountVolume({
    volumeId: "main",
    subpath: null,
    mountpoint: "/data",
    readonly: false,
  })
  // Mount specific file from volume (requires type: 'file')
  .mountVolume({
    volumeId: "main",
    subpath: "config.py",
    mountpoint: "/app/config.py",
    readonly: true,
    type: "file", // Required when mounting a single file
  });

Writing to Subcontainer Rootfs

For config files that are generated from code on every startup (e.g., a Python settings file built from hostnames and secrets), write directly to the subcontainer’s rootfs:

// Write a generated config to subcontainer rootfs
await writeFile(
  `${appSub.rootfs}/app/config.py`,
  generateConfig({ secretKey, allowedHosts }),
);

Warning

If the config file is managed by a FileModel, do NOT read it and write it back to rootfs. Mount it from the volume instead — the file already exists there.

When to use rootfs vs volume mounts:

  • Rootfs: Config files generated from code that don’t exist on a volume (e.g., built from hostnames, env vars, or templates)
  • Volume mount (directory): Mount a directory that contains the config file alongside other persistent data. The config file is just one of many files in the mounted directory.
  • Volume mount (file): Mount a single config file with type: 'file' when the config lives on a volume that is otherwise unrelated to the container’s filesystem.

Executing Commands in SubContainers

Use exec or execFail to run commands in a subcontainer:

MethodBehavior on Non-zero Exit
exec()Returns result with exitCode, stdout, stderr – does NOT throw
execFail()Throws an error on non-zero exit code
// exec() - manual error handling (good for optional/warning cases)
const result = await appSub.exec(["update-ca-certificates"], { user: "root" });
if (result.exitCode !== 0) {
  console.warn("Failed to update CA certificates:", result.stderr);
}

// execFail() - throws on error (good for required commands)
// Uses the default user from the Dockerfile (no need to specify { user: '...' })
await appSub.execFail(["git", "clone", "https://github.com/user/repo.git"]);

// Override user when needed (e.g., run as root)
await appSub.exec(["update-ca-certificates"], { user: "root" });

The user option is optional. If omitted, commands run as the default user defined in the Dockerfile (USER directive). Only specify { user: 'root' } when you need elevated privileges.

Use execFail() when:

  • The command must succeed for the service to work correctly
  • You are in initializeService.ts and want installation to fail if setup fails
  • You want automatic error propagation

Use exec() when:

  • The command failure is not critical (warnings, optional setup)
  • You need to inspect the exit code or output regardless of success/failure
  • You want custom error handling logic

PostgreSQL Sidecar

Many services require a PostgreSQL database. Run it as a sidecar daemon within the same service package.

Security Model

Use password authentication with localhost-only binding. Auto-generate the password on install and store it in your store.json FileModel.

Password generation (in utils.ts):

import { utils } from "@start9labs/start-sdk";

export function getDefaultPgPassword(): string {
  return utils.getDefaultString({ charset: "a-z,A-Z,0-9", len: 22 });
}

Store schema (in fileModels/store.json.ts):

const shape = z.object({
  pgPassword: z.string().catch(""),
  // ...other fields
});

Seed on install (in init/seedFiles.ts):

export const seedFiles = sdk.setupOnInit(async (effects, kind) => {
  if (kind !== "install") return;
  await storeJson.merge(effects, {
    pgPassword: getDefaultPgPassword(),
  });
});

Seed on upgrade (in version migration):

// Generate pgPassword for users upgrading from a version that didn't have one
const existing = await storeJson.read((s) => s.pgPassword).once();
await storeJson.merge(effects, {
  pgPassword: existing || getDefaultPgPassword(),
});

Daemon Configuration

import { sdk } from "./sdk";
import { i18n } from "./i18n";

// Read password from store
const pgPassword = store.pgPassword;

// Define mounts for PostgreSQL data
const pgMounts = sdk.Mounts.of().mountVolume({
  volumeId: "main",
  subpath: "postgresql",
  mountpoint: "/var/lib/postgresql",
  readonly: false,
});

// Create subcontainer
const postgresSub = await sdk.SubContainer.of(
  effects,
  { imageId: "postgres" },
  pgMounts,
  "postgres",
);

// Add as daemon
.addDaemon('postgres', {
  subcontainer: postgresSub,
  exec: {
    command: sdk.useEntrypoint(['-c', 'listen_addresses=127.0.0.1']),
    env: {
      POSTGRES_PASSWORD: pgPassword,
    },
  },
  ready: {
    display: null, // Internal service, not shown in UI
    fn: async () => {
      const result = await postgresSub.exec([
        'pg_isready', '-q', '-h', '127.0.0.1',
        '-d', 'postgres', '-U', 'postgres',
      ])
      if (result.exitCode !== 0) {
        return {
          result: 'loading',
          message: i18n('Waiting for PostgreSQL to be ready'),
        }
      }
      return {
        result: 'success',
        message: i18n('PostgreSQL is ready'),
      }
    },
  },
  requires: [],
})

Key points:

  • listen_addresses=127.0.0.1: Restricts connections to localhost only — no external access
  • POSTGRES_PASSWORD: Auto-generated password, stored in store.json
  • display: null: Internal sidecar health checks are typically not shown to the user

Connection Strings

When the upstream service needs a PostgreSQL connection string, include the password:

.addDaemon('app', {
  subcontainer: appSub,
  exec: {
    command: sdk.useEntrypoint(),
    env: {
      // .NET-style connection string
      DATABASE_URL: `User ID=postgres;Password=${pgPassword};Host=127.0.0.1;Port=5432;Database=mydb`,
      // Or standard PostgreSQL URI
      DATABASE_URL: `postgresql://postgres:${pgPassword}@127.0.0.1:5432/mydb`,
    },
  },
  requires: ['postgres'],
})

Note

The Docker entrypoint for the official postgres image handles initial database creation automatically. You do not need to run createdb or initdb manually on fresh installs.

Querying PostgreSQL from Actions

Some actions need to query PostgreSQL directly (e.g., resetting a user password). Read the password from the store:

import { Client } from 'pg'

const pgPassword = (await storeJson.read((s) => s.pgPassword).once()) || ''

const client = new Client({
  user: 'postgres',
  password: pgPassword,
  host: '127.0.0.1',
  database: 'mydb',
  port: 5432,
})

try {
  await client.connect()
  await client.query(
    `UPDATE "Users" SET "PasswordHash"=$1 WHERE "Id"=$2`,
    [hash, userId],
  )
} finally {
  await client.end()
}

Warning

When interpolating values into raw SQL strings (e.g., for psql -c), always escape single quotes to prevent SQL injection:

function sqlLiteral(value: string): string {
  return `'${value.replace(/'/g, "''")}'`
}

// Use in psql commands
await sub.execFail(
  ['psql', '-c', `ALTER USER myuser PASSWORD ${sqlLiteral(password)}`],
  { user: 'postgres' },
)

Prefer parameterized queries (the $1 syntax above) whenever possible — they handle escaping automatically.

Config File Generation

A common pattern is to define a helper function that generates a config file string from your service’s configuration values:

function generateConfig(config: {
  secretKey: string;
  allowedHosts: string[];
}): string {
  const hostsList = config.allowedHosts.map((h) => `'${h}'`).join(", ");

  return `
SECRET_KEY = '${config.secretKey}'
ALLOWED_HOSTS = [${hostsList}]
DATABASE = '/data/db.sqlite3'
`;
}

Initialization

setupOnInit runs during container initialization. The kind parameter indicates why init is running:

KindWhenUse For
'install'Fresh installGenerate passwords, create admin users, bootstrap via API
'restore'Restoring from backupRe-register triggers, skip password generation
nullContainer rebuild, server restartRegister long-lived triggers (e.g., .const() watchers)

Init Kinds

Install Only

For one-time setup that generates new state:

export const initializeService = sdk.setupOnInit(async (effects, kind) => {
  if (kind !== 'install') return

  // Create a critical task for the user to perform before they can start the service
  await sdk.action.createOwnTask(effects, createPassword, 'critical', {
    reason: i18n('Create your admin password'),
  })
})

Restore

For setup that should also run when restoring from backup (but not on container rebuild):

export const initializeService = sdk.setupOnInit(async (effects, kind) => {
  if (kind === null) return // Skip on container rebuild

  // Runs on both install and restore
  await sdk.action.createOwnTask(effects, getAdminPassword, 'critical', {
    reason: i18n('Retrieve the admin password'),
  })
})

Always (Container Lifetime)

For registering .const() triggers that need to persist for the container’s lifetime. These re-register on container rebuild:

export const initializeService = sdk.setupOnInit(async (effects, kind) => {
  // Runs on install, restore, AND container rebuild

  // Register a watcher that lives for the container lifetime
  someConfig.read((c) => c.setting).const(effects)

  // Install-specific setup
  if (kind === 'install') {
    const adminPassword = utils.getDefaultString({
      charset: 'a-z,A-Z,0-9',
      len: 22,
    })
    await storeJson.write(effects, { adminPassword })
  }
})

Basic Structure

// init/initializeService.ts
import { utils } from '@start9labs/start-sdk'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { storeJson } from '../fileModels/store.json'
import { getAdminPassword } from '../actions/getAdminPassword'

export const initializeService = sdk.setupOnInit(async (effects, kind) => {
  if (kind !== 'install') return

  // Generate and store password
  const adminPassword = utils.getDefaultString({
    charset: 'a-z,A-Z,0-9',
    len: 22,
  })
  await storeJson.write(effects, { adminPassword })

  // Create task prompting user to retrieve password
  await sdk.action.createOwnTask(effects, getAdminPassword, 'critical', {
    reason: i18n('Retrieve the admin password'),
  })
})

Registering initializeService

Add your custom init function to init/index.ts:

import { sdk } from '../sdk'
import { setDependencies } from '../dependencies'
import { setInterfaces } from '../interfaces'
import { versionGraph } from '../install/versionGraph'
import { actions } from '../actions'
import { restoreInit } from '../backups'
import { initializeService } from './initializeService'

export const init = sdk.setupInit(
  restoreInit,
  versionGraph,
  setInterfaces,
  setDependencies,
  actions,
  initializeService, // Add this
)

export const uninit = sdk.setupUninit(versionGraph)

runUntilSuccess Pattern

Use runUntilSuccess(timeout) to run daemons and oneshots during init, waiting for completion before continuing. This is essential for setup tasks that need a running server.

Oneshots Only

For simple sequential tasks (like database migrations):

await sdk.Daemons.of(effects)
  .addOneshot('migrate', {
    subcontainer: appSub,
    exec: { command: ['python', 'manage.py', 'migrate', '--noinput'] },
    requires: [],
  })
  .addOneshot('create-superuser', {
    subcontainer: appSub,
    exec: {
      command: ['python', 'manage.py', 'createsuperuser', '--noinput'],
      env: {
        DJANGO_SUPERUSER_USERNAME: 'admin',
        DJANGO_SUPERUSER_PASSWORD: adminPassword,
      },
    },
    requires: ['migrate'],
  })
  .runUntilSuccess(120_000) // 2 minute timeout

Daemon + Dependent Oneshot

For services that require calling an API after the server starts (e.g., bootstrapping via HTTP):

await sdk.Daemons.of(effects)
  .addDaemon('server', {
    subcontainer: appSub,
    exec: { command: ['node', 'server.js'] },
    ready: {
      display: null,
      fn: () =>
        sdk.healthCheck.checkPortListening(effects, 8080, {
          successMessage: 'Server ready',
          errorMessage: 'Server not ready',
        }),
    },
    requires: [],
  })
  .addOneshot('bootstrap', {
    subcontainer: appSub,
    exec: {
      command: [
        'node',
        '-e',
        `fetch('http://127.0.0.1:8080/api/bootstrap', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ password: '${adminPassword}' })
        }).then(r => {
          if (!r.ok) throw new Error('Bootstrap failed');
          process.exit(0);
        }).catch(e => {
          console.error(e);
          process.exit(1);
        })`,
      ],
    },
    requires: ['server'], // Waits for daemon to be healthy
  })
  .runUntilSuccess(120_000)

How it works:

  1. The daemon starts and runs its health check
  2. Once healthy, the dependent oneshot executes
  3. When the oneshot completes successfully, runUntilSuccess returns
  4. All processes are cleaned up automatically

Making HTTP Calls Without curl

Many slim Docker images do not have curl. Use the runtime’s built-in HTTP capabilities instead.

Node.js (v18+):

command: [
  'node',
  '-e',
  `fetch('http://127.0.0.1:${port}/api/endpoint', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ key: 'value' })
  }).then(r => r.ok ? process.exit(0) : process.exit(1))
    .catch(() => process.exit(1))`,
]

Python:

command: [
  'python',
  '-c',
  `import urllib.request, json
req = urllib.request.Request(
  'http://127.0.0.1:${port}/api/endpoint',
  data=json.dumps({'key': 'value'}).encode(),
  headers={'Content-Type': 'application/json'},
  method='POST'
)
urllib.request.urlopen(req)`,
]

Common Patterns

Generate Random Password

import { utils } from '@start9labs/start-sdk'

const password = utils.getDefaultString({
  charset: 'a-z,A-Z,0-9',
  len: 22,
})

Create User Task

Prompt the user to run an action after install:

await sdk.action.createOwnTask(effects, getAdminPassword, 'critical', {
  reason: i18n('Retrieve the admin password'),
})

Priority levels: 'critical', 'high', 'medium', 'low'

Checking Init Kind

export const initializeService = sdk.setupOnInit(async (effects, kind) => {
  // kind === 'install': Fresh install
  // kind === 'restore': Restoring from backup
  // kind === null: Container rebuild / server restart

  if (kind === 'install') {
    // Generate new passwords, bootstrap server
  }

  if (kind !== null) {
    // Runs on install OR restore (skip container rebuild)
  }

  // No check: runs on ALL init types (install, restore, container rebuild)
})

Interfaces

setupInterfaces() defines the network interfaces your service exposes and how they are made available to the user. This function runs on service install, update, and config save.

Single Interface

For a service with one web interface, refer to the Hello World example in the Quick Start.

Multiple Interfaces

Expose multiple paths (e.g., web UI and admin panel) from the same port:

import { i18n } from './i18n'
import { sdk } from './sdk'

const uiPort = 8080

export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
  const uiMulti = sdk.MultiHost.of(effects, 'ui-multi')
  const uiMultiOrigin = await uiMulti.bindPort(uiPort, {
    protocol: 'http',
  })

  const ui = sdk.createInterface(effects, {
    name: i18n('Web UI'),
    id: 'ui',
    description: i18n('The web interface'),
    type: 'ui',
    masked: false,
    schemeOverride: null,
    username: null,
    path: '',
    query: {},
  })

  const admin = sdk.createInterface(effects, {
    name: i18n('Admin Panel'),
    id: 'admin',
    description: i18n('Admin interface'),
    type: 'ui',
    masked: false,
    schemeOverride: null,
    username: null,
    path: '/admin/',
    query: {},
  })

  const uiReceipt = await uiMultiOrigin.export([ui, admin])
  return [uiReceipt]
})

The key steps are:

  1. Create a MultiHost and bind a port with a protocol
  2. Create one or more interfaces using sdk.createInterface()
  3. Export the interfaces from the origin and return the receipt(s)

Interface Options

sdk.createInterface(effects, {
  name: i18n('Display Name'),      // Shown in UI (wrap with i18n)
  id: 'unique-id',                 // Used in sdk.serviceInterface.getOwn()
  description: i18n('Description'),// Shown in UI (wrap with i18n)
  type: 'ui',                      // 'ui' or 'api'
  masked: false,                   // Hide from discovery?
  schemeOverride: null,            // Force 'https' or 'http'?
  username: null,                  // Auth username (if any)
  path: '/some/path/',             // URL path
  query: {},                       // URL query params
})
OptionTypeDescription
namestringDisplay name shown to the user. Wrap with i18n().
idstringUnique identifier. Used to retrieve this interface in main.ts via sdk.serviceInterface.getOwn().
descriptionstringDescription shown to the user. Wrap with i18n().
type'ui' or 'api'Whether this interface is a user-facing UI or a programmatic API.
maskedbooleanIf true, the interface is hidden from service discovery.
schemeOverridestring or nullForce a specific URL scheme ('https' or 'http'). Use null to let the system decide.
usernamestring or nullUsername for basic authentication, if required.
pathstringURL path appended to the base address (e.g., '/admin/').
queryobjectURL query parameters as key-value pairs.

Tip

The id you assign to an interface is what you use in main.ts to retrieve hostnames for that interface. For example, if you set id: 'ui', you would call sdk.serviceInterface.getOwn(effects, 'ui') to get its address information. See Main for details.

Actions

Actions are user-triggered operations that appear in the StartOS UI for your service. They can display information, accept user input, modify configuration, and more.

Action Without Input

The simplest action type retrieves or computes a result and displays it to the user.

import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { storeJson } from '../fileModels/store.json'

export const getAdminCredentials = sdk.Action.withoutInput(
  // ID
  'get-admin-credentials',

  // Metadata
  async ({ effects }) => ({
    name: i18n('Get Admin Credentials'),
    description: i18n('Retrieve admin username and password'),
    warning: null,
    allowedStatuses: 'any',  // 'any', 'only-running', 'only-stopped'
    group: null,
    visibility: 'enabled',   // 'enabled', 'disabled', 'hidden'
  }),

  // Handler
  async ({ effects }) => {
    const store = await storeJson.read((s) => s).once()

    return {
      version: '1' as const,
      title: 'Admin Credentials',
      message: 'Your admin credentials:',
      result: {
        type: 'group',
        value: [
          {
            type: 'single',
            name: 'Username',
            description: null,
            value: 'admin',
            masked: false,
            copyable: true,
            qr: false,
          },
          {
            type: 'single',
            name: 'Password',
            description: null,
            value: store?.adminPassword ?? 'UNKNOWN',
            masked: true,
            copyable: true,
            qr: false,
          },
        ],
      },
    }
  },
)

Registering Actions

All actions must be registered in actions/index.ts:

import { sdk } from '../sdk'
import { getAdminCredentials } from './getAdminCredentials'

export const actions = sdk.Actions.of().addAction(getAdminCredentials)

Result Types

Actions return structured results that the StartOS UI renders for the user.

Single Value

result: {
  type: 'single',
  name: 'API Key',
  description: null,
  value: 'abc123',
  masked: true,
  copyable: true,
  qr: false,
}

Group of Values

result: {
  type: 'group',
  value: [
    { type: 'single', name: 'Username', value: 'admin', masked: false, copyable: true, qr: false },
    { type: 'single', name: 'Password', value: 'secret', masked: true, copyable: true, qr: false },
  ],
}

Tasks

Actions can be surfaced to users as tasks — notifications that prompt them to run a specific action at the right time. See Tasks for details.

Common Patterns

Auto-Generate Passwords

Do not accept password input from users — users are bad at choosing passwords. Instead, auto-generate strong passwords and display them in action results:

import { utils } from '@start9labs/start-sdk'

// Generate a strong random password
const password = utils.getDefaultString({ charset: 'a-z,A-Z,0-9', len: 22 })

If a user needs to recover a lost password, provide a “Reset Password” action that generates a new one and updates the service’s database or config. Display the new password as a masked, copyable result.

Registration-Gated Services

Some services require that “registrations” or “signups” be enabled for users to create accounts. This creates a security tension: the service must be open for the admin to register, but should be locked down after.

The recommended pattern:

  1. Start with registrations enabled in the initial config.
  2. Create an important task in setupOnInit advising the user to disable registrations after creating their admin account.
  3. Provide a toggle action that reads the current registration state, flips it, and writes back.
// In init/initializeService.ts
export const initializeService = sdk.setupOnInit(async (effects, kind) => {
  if (kind !== 'install') return
  await sdk.action.createOwnTask(effects, toggleRegistrations, 'important', {
    reason: 'After creating your admin account, disable registrations to prevent unauthorized signups.',
  })
})

// In actions/toggleRegistrations.ts
export const toggleRegistrations = sdk.Action.withoutInput(
  'toggle-registrations',
  async ({ effects }) => {
    const allowed = await configToml.read((c) => c.allow_registration).const(effects)
    return {
      name: allowed ? i18n('Disable Registrations') : i18n('Enable Registrations'),
      description: allowed
        ? i18n('Registrations are currently enabled. Run this action to disable them.')
        : i18n('Registrations are currently disabled. Run this action to enable them.'),
      warning: allowed ? null : i18n('Anyone with your URL will be able to create an account.'),
      allowedStatuses: 'any',
      group: null,
      visibility: 'enabled',
    }
  },
  async ({ effects }) => {
    const allowed = await configToml.read((c) => c.allow_registration).const(effects)
    await configToml.merge(effects, { allow_registration: !allowed })
  },
)

SMTP Configuration

The SDK provides a built-in SMTP input specification for managing email credentials. This supports three modes: disabled, system SMTP (from StartOS settings), or custom SMTP.

1. Add SMTP to store.json.ts

Add the SMTP validator to your store’s shape definition. See File Models for more on file model patterns.

import { matches, FileHelper } from '@start9labs/start-sdk'
import { sdk } from '../sdk'

const { object, string } = matches

const shape = object({
  adminPassword: string.optional().onMismatch(undefined),
  secretKey: string.optional().onMismatch(undefined),
  smtp: sdk.inputSpecConstants.smtpInputSpec.validator.onMismatch({
    selection: 'disabled',
    value: {},
  }),
})

export const storeJson = FileHelper.json(
  { volumeId: 'main', subpath: 'store.json' },
  shape,
)

2. Create the manageSmtp Action

import { i18n } from '../i18n'
import { storeJson } from '../fileModels/store.json'
import { sdk } from '../sdk'

const { InputSpec } = sdk

export const inputSpec = InputSpec.of({
  smtp: sdk.inputSpecConstants.smtpInputSpec,
})

export const manageSmtp = sdk.Action.withInput(
  'manage-smtp',

  async ({ effects }) => ({
    name: i18n('Configure SMTP'),
    description: i18n('Add SMTP credentials for sending emails'),
    warning: null,
    allowedStatuses: 'any',
    group: null,
    visibility: 'enabled',
  }),

  inputSpec,

  // Pre-fill form with current values
  async ({ effects }) => ({
    smtp: (await storeJson.read((s) => s.smtp).const(effects)) || undefined,
  }),

  // Save to store
  async ({ effects, input }) => storeJson.merge(effects, { smtp: input.smtp }),
)

3. Register the Action

import { sdk } from '../sdk'
import { getAdminCredentials } from './getAdminCredentials'
import { manageSmtp } from './manageSmtp'

export const actions = sdk.Actions.of()
  .addAction(getAdminCredentials)
  .addAction(manageSmtp)

4. Use SMTP Credentials at Runtime

In your main.ts, resolve the SMTP credentials based on the user’s selection:

import { T } from '@start9labs/start-sdk'

export const main = sdk.setupMain(async ({ effects }) => {
  const store = await storeJson.read((s) => s).const(effects)

  // Resolve SMTP credentials based on selection
  const smtp = store?.smtp
  let smtpCredentials: T.SmtpValue | null = null

  if (smtp?.selection === 'system') {
    // Use system-wide SMTP from StartOS settings
    smtpCredentials = await sdk.getSystemSmtp(effects).const()
    if (smtpCredentials && smtp.value.customFrom) {
      smtpCredentials.from = smtp.value.customFrom
    }
  } else if (smtp?.selection === 'custom') {
    // Use custom SMTP credentials
    smtpCredentials = smtp.value
  }
  // If smtp.selection === 'disabled', smtpCredentials remains null

  // Pass to config generation
  const config = generateConfig({
    smtp: smtpCredentials,
    // ... other config
  })

  // ...
})

5. Initialize with SMTP Disabled

In init/initializeService.ts, set the default SMTP state:

await storeJson.write(effects, {
  adminPassword,
  secretKey,
  smtp: { selection: 'disabled', value: {} },
})

T.SmtpValue Type

The resolved SMTP credentials have this structure:

interface SmtpValue {
  server: string
  port: number
  login: string
  password?: string | null
  from: string
}

Tasks

Tasks are notifications that appear in the StartOS UI prompting the user to run a specific action. They are commonly used to surface important information after install or restore, request required configuration, or coordinate setup with dependency services.

Own Tasks

Use sdk.action.createOwnTask() to prompt the user to run one of your service’s own actions.

await sdk.action.createOwnTask(effects, getAdminCredentials, 'critical', {
  reason: i18n('Retrieve the admin password'),
})

Parameters

ParameterTypeDescription
effectsEffectsProvided by the calling context
actionActionDefinitionThe action to prompt the user to run
severity'critical' | 'high' | 'medium' | 'low'How urgently the task is surfaced in the UI
options{ reason: string }Human-readable explanation shown to the user

Severity Levels

  • critical — Blocks the service from starting until the user completes the task. Use for essential setup like retrieving generated passwords or accepting terms.
  • high — Prominently displayed but does not block the service.
  • medium — Standard priority notification.
  • low — Informational, least prominent.

Common Patterns

Prompt on Install Only

Generate a password and prompt the user to retrieve it. Skip on restore (password already exists) and container rebuild:

export const initializeService = sdk.setupOnInit(async (effects, kind) => {
  if (kind !== 'install') return

  const adminPassword = utils.getDefaultString({ charset: 'a-z,A-Z,0-9', len: 22 })
  await storeJson.write(effects, {
    adminPassword,
    smtp: { selection: 'disabled', value: {} },
  })

  await sdk.action.createOwnTask(effects, getAdminCredentials, 'critical', {
    reason: i18n('Retrieve the admin password'),
  })
})

Prompt on Install and Restore

Useful when the user should always be reminded of credentials, even after restoring from backup:

export const initializeService = sdk.setupOnInit(async (effects, kind) => {
  if (kind === null) return // Skip on container rebuild

  if (kind === 'install') {
    const adminPassword = utils.getDefaultString({ charset: 'a-z,A-Z,0-9', len: 22 })
    await storeJson.write(effects, { adminPassword })
  }

  await sdk.action.createOwnTask(effects, getAdminCredentials, 'critical', {
    reason: i18n('Retrieve the admin password'),
  })
})

Prompt for Required Configuration

Ask the user to configure something before the service can function:

await sdk.action.createOwnTask(effects, manageSmtp, 'high', {
  reason: i18n('Configure email settings to enable notifications'),
})

Dependency Tasks

Use sdk.action.createTask() to prompt the user to run an action on a dependency service. The action must be imported from the dependency’s package.

import { someAction } from 'dependency-package/startos/actions/someAction'

export const setDependencies = sdk.setupDependencies(async ({ effects }) => {
  await sdk.action.createTask(effects, 'dependency-id', someAction, 'critical', {
    input: {
      kind: 'partial',
      value: { /* fields matching the action's input spec */ },
    },
    when: { condition: 'input-not-matches', once: false },
    reason: i18n('Configure the dependency for use with this service'),
  })

  return {
    'dependency-id': {
      kind: 'running',
      versionRange: '>=1.0.0:0',
      healthChecks: [],
    },
  }
})

Parameters

ParameterTypeDescription
effectsEffectsProvided by the calling context
packageIdstringThe dependency’s service ID
actionActionDefinitionImported from the dependency’s package
severity'critical' | 'high' | 'medium' | 'low'How urgently the task is surfaced
optionsobjectSee below

Options

FieldTypeDescription
input{ kind: 'partial', value: Partial<InputSpec> }Pre-fill fields in the action’s input form
when{ condition: 'input-not-matches', once: boolean }Re-trigger until the action’s input matches the provided values
reasonstringHuman-readable explanation shown to the user
replayIdstring (optional)Prevents duplicate tasks across restarts

Note

The dependency must be listed in your package.json so the action can be imported (e.g., "synapse-startos": "file:../synapse-wrapper"). See Dependencies for more on cross-service integration.

File Models

File Models represent configuration files as TypeScript definitions, providing type safety and runtime enforcement throughout your codebase.

Supported Formats

File Models support automatic parsing and serialization for:

  • .json
  • .yaml / .yml
  • .toml
  • .ini
  • .env

Custom parser/serializer support is available for non-standard formats.

Creating a File Model

store.json.ts (Common Pattern)

The most common file model is store.json, used to persist internal service state:

import { matches, FileHelper } from '@start9labs/start-sdk'
import { sdk } from '../sdk'

const { object, string, number, boolean } = matches

const shape = object({
  adminPassword: string.optional().onMismatch(undefined),
  secretKey: string.optional().onMismatch(undefined),
  someNumber: number.optional().onMismatch(0),
  someFlag: boolean.optional().onMismatch(false),
})

export const storeJson = FileHelper.json(
  { base: sdk.volumes.main, subpath: 'store.json' },
  shape,
)

YAML Configuration

import { matches, FileHelper } from '@start9labs/start-sdk'
import { sdk } from '../sdk'

const { object, string, array } = matches

const shape = object({
  server: object({
    host: string,
    port: number,
  }),
  features: array(string),
})

export const configYaml = FileHelper.yaml(
  { base: sdk.volumes.main, subpath: 'config.yaml' },
  shape,
)

Reading File Models

Reading Methods

MethodPurpose
.once()Read content once, no reactivity
.const(effects)Read content AND re-run context if changes occur
.onChange(effects)Register callback for value changes
.watch(effects)Create async iterator of new values

Note

All read methods return null if the file doesn’t exist. Do NOT use try-catch for missing files.

Examples

// One-time read (no restart on change) - returns null if file doesn't exist
const store = await storeJson.read().once()

// Handle missing file with nullish coalescing
const keys = (await authorizedKeysFile.read().once()) ?? []

// Reactive read (service restarts if value changes)
const store = await storeJson.read().const(effects)

// Read only specific fields (subset reading)
const password = await storeJson.read((s) => s.adminPassword).once()

// Reactive subset read - daemon only restarts if secretKey changes
const secretKey = await storeJson.read((s) => s.secretKey).const(effects)

Warning

Never use an identity mapper like .read((s) => s). Either omit the mapper to get the full object (.read()) or use it to extract a specific field (.read((s) => s.someField)).

Subset Reading

Use mapping to retrieve only specific fields rather than entire files:

// Read only adminPassword - more efficient
const password = await storeJson.read((s) => s.adminPassword).once()

// Read nested values
const serverHost = await configYaml.read((c) => c.server.host).once()

Writing File Models

Full Write

await storeJson.write(effects, {
  adminPassword: 'secret123',
  secretKey: 'abc123',
  someNumber: 42,
  someFlag: true,
})

Merge (Partial Update)

// Only update specific fields, preserve others
await storeJson.merge(effects, { someFlag: false })

Type Coercion

File Models provide runtime type coercion. For example, if a number is unexpectedly stored as a string, the validator can convert it back:

const shape = object({
  port: number.onMismatch((val) => {
    // Convert string to number if needed
    if (typeof val === 'string') return parseInt(val, 10)
    return 8080 // default
  }),
})

Best Practices

Prefer Direct FileModel Over store.json + Environment Variables

When an upstream service reads a config file (TOML, YAML, JSON, etc.), model that file directly with FileHelper rather than storing values in store.json and passing them as environment variables. A direct FileModel provides:

  • Two-way binding: Actions can read and write the upstream config file directly.
  • Simpler main.ts: Mount the config file from the volume into the subcontainer. No need to read and regenerate it.
  • Easy user configuration: Exposing config options via Actions is as simple as configToml.merge(effects, { key: newValue }).

Use store.json only for internal package state that has no upstream config file equivalent (e.g., a generated PostgreSQL password that the upstream service doesn’t read from its own config file).

// GOOD: Model the upstream config directly
export const configToml = FileHelper.toml(
  { base: sdk.volumes['my-data'], subpath: 'config.toml' },
  shape,
)

// In main.ts, mount the volume so the config file is accessible in the subcontainer.
// You can mount the individual file or an entire directory that contains it.
const appSub = await sdk.SubContainer.of(effects, { imageId: 'my-app' },
  sdk.Mounts.of().mountVolume({
    volumeId: 'my-data',
    subpath: 'config.toml',
    mountpoint: '/etc/my-app/config.toml',
    readonly: false,
    type: 'file',
  }),
  'my-app-sub',
)

// Reactive read triggers daemon restart when config changes (e.g. via actions)
await configToml.read((c) => c.some_mutable_setting).const(effects)

// In an action, toggle a setting directly
await configToml.merge(effects, { allow_registration: !current })

Warning

Do NOT read a FileModel in main.ts and then write it back to the subcontainer rootfs. The file already lives on the volume — just mount it.

Common Patterns

Optional Fields with Defaults

const shape = object({
  // Optional with undefined default
  apiKey: string.optional().onMismatch(undefined),

  // Optional with value default
  port: number.optional().onMismatch(8080),

  // Required field
  name: string,
})

Nested Objects

const shape = object({
  database: object({
    host: string,
    port: number,
    name: string,
  }),
  smtp: object({
    enabled: boolean,
    server: string.optional().onMismatch(undefined),
  }),
})

Hardcoded Literal Values

For values that should always be a specific literal and never change (e.g., internal ports, paths, auth modes), use literal().onMismatch():

import { matches, FileHelper } from '@start9labs/start-sdk'

const { object, string, literal } = matches

const port = 8080
const dataDir = '/data'

const shape = object({
  // These values are hardcoded and will be corrected on the next merge
  port: literal(port).onMismatch(port),
  dataDir: literal(dataDir).onMismatch(dataDir),
  auth: literal('password').onMismatch('password'),
  tls: literal(false).onMismatch(false),

  // This value can vary
  password: string.optional().onMismatch(undefined),
})

This pattern ensures:

  • The value is validated to match the literal exactly
  • If the file ends up with a different value (e.g., user edits it manually), it’s corrected on the next merge()
  • You can use merge() to update only the non-literal fields without specifying the hardcoded ones

Using SDK Input Spec Validators

For complex types like SMTP, use the SDK’s built-in validators. See Actions for the full SMTP configuration walkthrough.

import { sdk } from '../sdk'

const shape = object({
  adminPassword: string.optional().onMismatch(undefined),
  smtp: sdk.inputSpecConstants.smtpInputSpec.validator.onMismatch({
    selection: 'disabled',
    value: {},
  }),
})

Dependencies

Cross-service dependencies allow your service to interact with other StartOS services. Use them when your service needs to:

  • Enforce configuration on a dependency (e.g., enable a feature)
  • Register with a dependency (e.g., appservice registration)
  • Read a dependency’s interface URL at runtime

Declaring Dependencies

Dependencies are declared in manifest/index.ts. Each dependency requires either metadata or s9pk to provide display info (title and icon). Both approaches achieve the same result – they are two ways of providing the metadata:

dependencies: {
  // Provide metadata directly
  synapse: {
    description: 'Needed for Matrix homeserver',
    optional: false,
    metadata: {
      title: 'Synapse',
      icon: '../synapse-wrapper/icon.png',
    },
  },

  // Extract metadata from an s9pk file
  electrs: {
    description: 'Provides an index for address lookups',
    optional: true,
    s9pk: 'https://github.com/org/repo/releases/download/v1.0/electrs.s9pk',
  },

  // s9pk: null when no s9pk URL is available
  'other-service': {
    description: 'Optional integration',
    optional: true,
    s9pk: null,
  },
}

Creating Cross-Service Tasks

Use sdk.action.createTask() in dependencies.ts to trigger an action on a dependency. The action must be exported from the dependency’s package.

import { i18n } from './i18n'
import { sdk } from './sdk'
import { someAction } from 'dependency-package/startos/actions/someAction'

export const setDependencies = sdk.setupDependencies(async ({ effects }) => {
  await sdk.action.createTask(effects, 'dependency-id', someAction, 'critical', {
    input: {
      kind: 'partial',
      value: { /* fields matching the action's input spec */ },
    },
    when: { condition: 'input-not-matches', once: false },
    reason: i18n('Human-readable reason shown to user'),
  })

  return {
    'dependency-id': {
      kind: 'running',
      versionRange: '>=1.0.0:0',
      healthChecks: [],
    },
  }
})

API Signature

sdk.action.createTask(
  effects,
  packageId: string,         // dependency service ID
  action: ActionDefinition,  // imported from the dependency package
  severity: 'critical' | 'high' | 'medium' | 'low',
  options?: {
    input?: { kind: 'partial', value: Partial<InputSpec> },
    when?: { condition: 'input-not-matches', once: boolean },
    reason: string,
    replayId?: string,       // prevents duplicate task execution
  }
)

Note

  • Import the action object from the dependency’s published package.
  • The dependency must be listed in your package.json (e.g., "synapse-startos": "file:../synapse-wrapper").
  • when: { condition: 'input-not-matches', once: false } re-triggers until the action’s input matches.
  • replayId prevents duplicate tasks across restarts.

Reading Dependency Interfaces

Use sdk.serviceInterface.get() in main.ts to read a dependency’s interface at runtime:

const url = await sdk.serviceInterface
  .get(
    effects,
    { id: 'interface-id', packageId: 'dependency-id' },
    (i) => {
      const urls = i?.addressInfo?.format('urlstring')
      if (!urls || urls.length === 0) return null
      return urls[0]
    },
  )
  .const()  // re-runs setupMain if the interface changes

Alternatively, services are reachable directly by hostname at http://<package-id>.startos:<port>:

const url = 'http://bitcoind.startos:8332'

Mounting Dependency Volumes

Mount a dependency’s volume for direct file access in main.ts:

const mounts = sdk.Mounts.of()
  .mountVolume({ volumeId: 'main', subpath: null, mountpoint: '/data', readonly: false })
  .mountDependency({
    dependencyId: 'bitcoind',
    volumeId: 'main',
    subpath: null,
    mountpoint: '/mnt/bitcoind',
    readonly: true,
  })

Init Order

Dependencies are resolved during initialization in this order:

restoreInit -> versionGraph -> setInterfaces -> setDependencies -> actions -> setup

setInterfaces runs before setDependencies, so your service’s interfaces are available when creating cross-service tasks.

Makefile Build System

StartOS packages use a two-file Makefile system that separates reusable build logic from project-specific configuration.

File Structure

my-service-startos/
├── Makefile     # Project-specific configuration (minimal)
└── s9pk.mk      # Shared build logic (copy from template)

s9pk.mk

The s9pk.mk file contains all the common build logic shared across StartOS packages. Copy this file from hello-world-startos/s9pk.mk without modification.

Targets

TargetDescription
make or make allBuild for all architectures (default)
make x86Build for x86_64 only
make armBuild for aarch64 only
make riscvBuild for riscv64 only
make universalBuild a single package containing all architectures
make installInstall the most recent .s9pk to your StartOS server
make cleanRemove build artifacts

Variables

VariableDefaultDescription
ARCHESx86 arm riscvArchitectures to build by default
TARGETSarchesDefault build target
VARIANT(unset)Optional variant suffix for package name

Makefile

The project Makefile is minimal and just includes s9pk.mk:

include s9pk.mk

Adding Custom Targets

For services with variants (e.g., GPU support), extend the Makefile:

TARGETS := generic rocm
ARCHES := x86 arm

include s9pk.mk

.PHONY: generic rocm

generic:
	$(MAKE) all_arches VARIANT=generic

rocm:
	ROCM=1 $(MAKE) all_arches VARIANT=rocm ARCHES=x86_64

This produces packages named myservice_generic_x86_64.s9pk and myservice_rocm_x86_64.s9pk.

Overriding Defaults

Override variables before include s9pk.mk:

# Build only for x86 and arm
ARCHES := x86 arm

include s9pk.mk

Build Commands

# Build for all architectures
make

# Build for a specific architecture
make x86
make arm

# Install to StartOS server (requires ~/.startos/config.yaml)
make install

# Clean build artifacts
make clean

Chaining Commands

You can chain multiple targets in a single invocation:

make clean arm          # Clean, then build ARM package
make clean x86 install  # Clean, build x86 package, then install
make clean install      # Clean, build universal, then install

Prerequisites

The build system checks for:

  • start-cli – StartOS CLI tool
  • npm – Node.js package manager
  • ~/.startos/developer.key.pem – Developer key (auto-initialized if missing)

See Environment Setup for installation instructions.

Installation

To install a package directly to your StartOS server, configure the server address in ~/.startos/config.yaml:

host: http://your-server.local

Then run:

make install

This builds the package and sideloads it to your device.

Example Output

Building an ARM package:

$ make arm
   Re-evaluating ingredients...
   Packing 'albyhub_aarch64.s9pk'...
Build Complete!

  Alby Hub   v1.19.3:1-alpha.0
  Filename:   albyhub_aarch64.s9pk
  Size:       7M
  Arch:       aarch64
  SDK:        0.4.0-beta.36
  Git:        78c30ec776f6a9d55be3701e9b82093c866a382c

Note

If you have uncommitted changes, the Git hash will be shown in red.

Installing a package:

$ make arm install

Installing to working-finalist.local ...
Sideloading 100%
  Uploading...
  Validating Headers...
  Unpacking...

Writing Service READMEs

Every StartOS package README should document how your service on StartOS differs from the upstream version. Users should be able to read your README and understand exactly what is different – everything else, they can find in the upstream docs.

Philosophy

Do not duplicate upstream documentation. If something is not mentioned in your README, users should assume the upstream docs are accurate.

Write for two audiences:

  1. Humans – clear, scannable, with practical examples
  2. AI assistants – structured data that can be parsed programmatically
# [Service Name] on StartOS

> **Upstream docs:** <https://docs.example.com/>
>
> Everything not listed in this document should behave the same as upstream
> [Service Name] [version]. If a feature, setting, or behavior is not mentioned
> here, the upstream documentation is accurate and fully applicable.

[Brief description of what the service does and link to upstream repo]

---

## Table of Contents

[Links to each section]

---

## Image and Container Runtime

[Image source, architectures, entrypoint modifications]

## Volume and Data Layout

[Mount points, data directories, StartOS-specific files like store.json]

## Installation and First-Run Flow

[How setup differs from upstream -- skipped wizards, auto-configuration, initial credentials]

## Configuration Management

[Which settings are managed by StartOS vs configurable via upstream methods]

## Network Access and Interfaces

[Exposed ports, protocols, access methods]

## Actions (StartOS UI)

[Each action: name, purpose, availability, inputs/outputs]

## Backups and Restore

[What's backed up, restore behavior]

## Health Checks

[Endpoint, grace period, messages]

## Limitations and Differences

[Numbered list of key limitations compared to upstream]

## What Is Unchanged from Upstream

[Explicit list of features that work exactly as documented upstream]

## Contributing

[Link to CONTRIBUTING.md]

---

## Quick Reference for AI Consumers

```yaml
package_id: string
upstream_version: string
image: string
architectures: [list]
volumes:
  volume_name: mount_path
ports:
  interface_name: port_number
dependencies: [list or "none"]
startos_managed_env_vars:
  - VAR_NAME
actions:
  - action-id
```

Sections to Document

Image and Container Runtime

What to DocumentExample
Image sourceUpstream unmodified, or custom Dockerfile
Architecturesx86_64, aarch64, riscv64
EntrypointDefault or custom

Volume and Data Layout

What to DocumentExample
Volume namesmain, data, config
Mount points/data, /config
StartOS filesstore.json for persistent settings
DatabaseEmbedded SQLite vs external

Installation and First-Run Flow

Document if your package:

  • Skips an upstream setup wizard
  • Auto-generates credentials
  • Pre-configures settings
  • Creates tasks for initial setup

Configuration Management

Use a table to clarify the division of responsibility:

StartOS-ManagedUpstream-Managed
Settings controlled via actions/env varsSettings configurable via app’s own UI/config

Actions

For each action, document:

  • Name: What users see in the StartOS UI
  • Purpose: What it does
  • Visibility: Visible, hidden, or conditional
  • Availability: Any status, only running, only stopped
  • Inputs: What users provide
  • Outputs: Credentials, confirmation, etc.

Network Interfaces

For each interface:

  • Port number
  • Protocol (HTTP, SSH, etc.)
  • Purpose (UI, API, etc.)
  • Access methods (LAN IP, .local, .onion, custom domains)

Backups

  • What volumes/data are included
  • Data NOT backed up (if any)
  • Restore behavior

Health Checks

  • Endpoint or method
  • Grace period
  • Success/failure messages

Limitations

Be explicit about:

  • Features that do not work or work differently
  • Unavailable configuration options
  • Unsupported dependencies
  • Version-specific limitations

AI Quick Reference

End every README with a YAML block for machine parsing. This block should contain the package ID, upstream version, image, architectures, volumes, ports, dependencies, managed environment variables, and action IDs.

Pre-Publish Checklist

  • Upstream docs linked at the top
  • Upstream version stated
  • All volumes and mount points documented
  • All actions documented with their purpose
  • All StartOS-managed settings/env vars listed
  • All limitations listed explicitly
  • “What Is Unchanged” section included
  • YAML quick reference block for AI consumers
  • Tested that documented features match actual behavior
  • CONTRIBUTING.md exists with build instructions