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
- Environment Setup - Install the required development tools
- Quick Start - Create, build, and install your first package
- Project Structure - Understand the file layout of a StartOS package
- Manifest - Define your service metadata, release notes, and alerts
- Versions - Handle install, update, and downgrade logic
- Main - Configure daemons, health checks, and the service lifecycle
- Initialization - Run code when your service initializes
- Interfaces - Expose network interfaces to users
- Actions - Define user-facing buttons and scripts
- Tasks - Prompt users to run actions at the right time
- File Models - Represent and validate configuration files
- Dependencies - Declare and configure service dependencies
- Makefile - Automate build and install workflows
- 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.
Coding with Claude (Recommended)
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:
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/orCLAUDE.mdinside 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
-
Navigate to the Hello World Template on GitHub.
-
In the top right, click “Use this template > Create new repository”.
-
Name your repository using the convention
<service-name>-startos(e.g.,nextcloud-startos). -
Click “Create Repository”.
-
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:
.gitignoreMakefile- Just includess9pk.mk(see Makefile)s9pk.mk- Shared build logic, copy from template without modificationpackage.json/package-lock.jsontsconfig.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
| File | Purpose |
|---|---|
main.ts | Daemon runtime configuration and health checks |
interfaces.ts | Network interface definitions and port bindings |
backups.ts | Backup volumes and exclusion patterns |
dependencies.ts | Service dependencies and version requirements |
sdk.ts | SDK initialization (boilerplate) |
utils.ts | Package-specific constants and helper functions |
index.ts | Module 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
| Directory | Purpose |
|---|---|
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:
- Package install (including fresh install, update, downgrade, and restore)
- Server (not service) restart
- “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.
restoreInitandversionGraphmust remain first and second. Do not move them.setInterfaces,setDependencies,actionsare 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– thesetupManifest()calli18n.ts– translated strings fordescriptionandalerts
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
| Field | Description |
|---|---|
id | Unique identifier (lowercase, hyphens allowed) |
title | Display name shown in UI |
license | SPDX identifier (MIT, Apache-2.0, GPL-3.0, etc.) |
packageRepo | URL to the StartOS package repository |
upstreamRepo | URL to the original project repository |
marketingUrl | URL for the project’s main website |
donationUrl | Donation URL or null |
docsUrls | Array of URLs to upstream documentation |
description.short | Locale object (see manifest/i18n.ts) |
description.long | Locale object (see manifest/i18n.ts) |
volumes | Storage volumes (usually ['main']) |
images | Docker image configuration (including arch) |
alerts | User notifications for lifecycle events (locale objects or null) |
dependencies | Service 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:
| Value | Architecture |
|---|---|
x86_64 | Intel/AMD 64-bit |
aarch64 | ARM 64-bit |
riscv64 | RISC-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]
| Component | Description | Example |
|---|---|---|
flavor | Optional variant for diverging forks | #libre: |
upstream | Upstream project version (SemVer) | 26.0.0 |
upstream-prerelease | Upstream prerelease suffix | -beta.1 |
downstream | StartOS wrapper revision | 0, 1, 2 |
downstream-prerelease | Wrapper 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 String | Upstream | Downstream |
|---|---|---|
26.0.0:0 | 26.0.0 (stable) | 0 (stable) |
26.0.0:0-beta.0 | 26.0.0 (stable) | 0-beta.0 |
26.0.0-rc.1:0-alpha.0 | 26.0.0-rc.1 | 0-alpha.0 |
0.13.5:0-alpha.0 | 0.13.5 (stable) | 0-alpha.0 |
2.3.2:1-beta.0 | 2.3.2 (stable) | 1-beta.0 |
Version Ordering
Versions are compared by:
- Upstream version (most significant)
- Upstream prerelease (stable > rc > beta > alpha)
- Downstream revision
- Downstream prerelease (stable > rc > beta > alpha)
Example ordering (lowest to highest):
1.0.0-alpha.0:01.0.0-beta.0:01.0.0:0-alpha.01.0.0:0-beta.01.0.0:0(fully stable)1.0.0:1-alpha.01.0.0:11.1.0:0-alpha.0
Choosing a Version
When creating a new package:
- Select the latest stable upstream version – avoid prereleases (alpha, beta, rc) unless necessary.
- Match the Docker image tag – the version in
manifest/index.tsimages.*.source.dockerTagmust match the upstream version. - Match the git submodule – if using a submodule, check out the corresponding tag.
- Start downstream at 0 – increment only when making wrapper-only changes.
- Start downstream as alpha or beta – use
-alpha.0or-beta.0for 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'inmanifest/index.ts(if using pre-built image) - Git submodule checked out to
vX.Y.Ztag (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
| Version | Filename |
|---|---|
26.0.0:0-beta.0 | v26.0.0.0.b0.ts |
0.13.5:0-alpha.0 | v0.13.5.0.a0.ts |
2.3.2:1 | v2.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:
- Update git submodule to new tag
- Update
dockerTagin manifest/index.ts - Create new version file with new upstream version
- Reset downstream to 0
Wrapper-Only Changes
When making changes to the StartOS wrapper without upstream changes:
- Keep upstream version the same
- Increment downstream revision
- Create new version file
Promoting Prereleases
To promote from alpha to beta to stable:
- Create new version file without prerelease suffix (or with next stage)
- Update
index.tsto export new version as current - Move old version to
otherarray
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:
| Kind | When it runs |
|---|---|
'install' | Fresh install |
'restore' | Restoring from backup |
null | Container 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:
| Method | Returns | Behavior on Change |
|---|---|---|
.once() | Parsed content only | Nothing – value is stale |
.const(effects) | Parsed content | Re-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
| Method | Purpose |
|---|---|
.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) inmain.tsoneshots – they run on every startup and will fail on subsequent runs. Useinit/initializeService.tsinstead. 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:
| Method | Behavior 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.tsand 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 accessPOSTGRES_PASSWORD: Auto-generated password, stored instore.jsondisplay: 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
postgresimage handles initial database creation automatically. You do not need to runcreatedborinitdbmanually 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
$1syntax 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:
| Kind | When | Use For |
|---|---|---|
'install' | Fresh install | Generate passwords, create admin users, bootstrap via API |
'restore' | Restoring from backup | Re-register triggers, skip password generation |
null | Container rebuild, server restart | Register 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:
- The daemon starts and runs its health check
- Once healthy, the dependent oneshot executes
- When the oneshot completes successfully,
runUntilSuccessreturns - 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:
- Create a
MultiHostand bind a port with a protocol - Create one or more interfaces using
sdk.createInterface() - 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
})
| Option | Type | Description |
|---|---|---|
name | string | Display name shown to the user. Wrap with i18n(). |
id | string | Unique identifier. Used to retrieve this interface in main.ts via sdk.serviceInterface.getOwn(). |
description | string | Description shown to the user. Wrap with i18n(). |
type | 'ui' or 'api' | Whether this interface is a user-facing UI or a programmatic API. |
masked | boolean | If true, the interface is hidden from service discovery. |
schemeOverride | string or null | Force a specific URL scheme ('https' or 'http'). Use null to let the system decide. |
username | string or null | Username for basic authentication, if required. |
path | string | URL path appended to the base address (e.g., '/admin/'). |
query | object | URL query parameters as key-value pairs. |
Tip
The
idyou assign to an interface is what you use inmain.tsto retrieve hostnames for that interface. For example, if you setid: 'ui', you would callsdk.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:
- Start with registrations enabled in the initial config.
- Create an important task in
setupOnInitadvising the user to disable registrations after creating their admin account. - 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
| Parameter | Type | Description |
|---|---|---|
effects | Effects | Provided by the calling context |
action | ActionDefinition | The 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
| Parameter | Type | Description |
|---|---|---|
effects | Effects | Provided by the calling context |
packageId | string | The dependency’s service ID |
action | ActionDefinition | Imported from the dependency’s package |
severity | 'critical' | 'high' | 'medium' | 'low' | How urgently the task is surfaced |
options | object | See below |
Options
| Field | Type | Description |
|---|---|---|
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 |
reason | string | Human-readable explanation shown to the user |
replayId | string (optional) | Prevents duplicate tasks across restarts |
Note
The dependency must be listed in your
package.jsonso 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
| Method | Purpose |
|---|---|
.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
nullif 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.replayIdprevents 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
| Target | Description |
|---|---|
make or make all | Build for all architectures (default) |
make x86 | Build for x86_64 only |
make arm | Build for aarch64 only |
make riscv | Build for riscv64 only |
make universal | Build a single package containing all architectures |
make install | Install the most recent .s9pk to your StartOS server |
make clean | Remove build artifacts |
Variables
| Variable | Default | Description |
|---|---|---|
ARCHES | x86 arm riscv | Architectures to build by default |
TARGETS | arches | Default 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 toolnpm– 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:
- Humans – clear, scannable, with practical examples
- AI assistants – structured data that can be parsed programmatically
Recommended Structure
# [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 Document | Example |
|---|---|
| Image source | Upstream unmodified, or custom Dockerfile |
| Architectures | x86_64, aarch64, riscv64 |
| Entrypoint | Default or custom |
Volume and Data Layout
| What to Document | Example |
|---|---|
| Volume names | main, data, config |
| Mount points | /data, /config |
| StartOS files | store.json for persistent settings |
| Database | Embedded 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-Managed | Upstream-Managed |
|---|---|
| Settings controlled via actions/env vars | Settings 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.mdexists with build instructions