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.

Designed for AI-Assisted Development

StartOS service packaging is designed to be done with an AI coding agent. This guide, the SDK, and every existing package are structured so that an AI assistant can read the docs, study real packages, and write or modify package code with minimal human intervention. You do not need to be an expert TypeScript developer – you need to understand what your service requires and let the AI handle how to implement it.

The recommended setup is Claude Code with this guide and your package in the same workspace. See Environment Setup for instructions.

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

Where to Start

  1. Set up your environment — Follow Environment Setup, including the Claude Code section.
  2. Build your first package — Follow Quick Start to create, build, and install the Hello World template.
  3. Use recipes to build your service — Browse Recipes to find the patterns you need. Each recipe describes what to do, links to reference pages for API details, and points to real packages for working code. Your AI agent reads these docs and writes the code.

Recipes

Intent-driven guides for common packaging tasks. These are the primary entry point for both you and your AI coding agent.

Getting Started

  1. Environment Setup - Install the required development tools
  2. Quick Start - Create, build, and install your first package

Reference

  1. Project Structure - Understand the file layout of a StartOS package
  2. Manifest - Define your service metadata, release notes, and alerts
  3. Versions - Handle install, update, and downgrade logic
  4. Main - Configure daemons, health checks, and the service lifecycle
  5. Initialization - Run code when your service initializes
  6. Interfaces - Expose network interfaces to users
  7. Actions - Define user-facing buttons and scripts
  8. Tasks - Prompt users to run actions at the right time
  9. File Models - Represent and validate configuration files
  10. Dependencies - Declare and configure service dependencies
  11. Makefile - Automate build and install workflows
  12. 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. The final section — Coding with Claude — sets up the AI-assisted development environment that all packaging is designed around.

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 the Package

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, you’re ready to package your own service. Browse the Recipes to find the patterns your service needs — each recipe describes the approach and points you to reference docs and real package code.

If you set up Claude Code during environment setup, point your agent at the recipe for your first task and let it work from there.

Recipes

This is the primary entry point for StartOS service packaging — for both you and your AI coding agent. Each recipe describes a common packaging pattern, names the SDK constructs involved, links to reference pages for API details, and points to real packages for working code. Your agent reads these to understand what to build; you read them to understand what to ask for.

If you’re using Claude Code (recommended), point your agent at the recipe for your task and let it follow the reference and package links from there.

Configuration

RecipeDescription
Set Up a Basic ServiceMinimal single-container service with a web UI, health check, and backup
Create Configuration ActionsLet users configure your service through actions with input forms
Generate Config FilesProduce YAML, TOML, INI, JSON, or ENV files from user settings using FileModel
Pass Config via Environment VariablesConfigure your service through environment variables in the daemon definition
Hardcode Config ValuesLock down ports, paths, or auth modes so users cannot change them
Set a Primary URLLet users choose which hostname the service uses for links, invites, and federation
Set Up SMTP / EmailLet users configure email sending with disabled/system/custom modes

Credentials & Access Control

RecipeDescription
Auto-Generate Internal SecretsGenerate passwords or tokens in init for internal use (database auth, secret keys)
Prompt User to Create Admin CredentialsCritical task on install that triggers an action to generate and display a password
Reset a PasswordAction that regenerates credentials and updates the running application
Gate User RegistrationToggle action that enables/disables public signups with a dynamic label

Setup & Lifecycle

RecipeDescription
Require Setup Before StartingBlock service startup with a critical task until the user completes configuration
Run One-Time Setup on InstallGenerate passwords, seed databases, or bootstrap config on first install only
Bootstrap via Temporary Daemon ChainStart the service during init, call its API to bootstrap, then tear it down
Handle Version UpgradesMigrate data between package versions using the version graph
Handle Restore from BackupRe-register services or fix state after restoring from backup

Daemons & Containers

RecipeDescription
Run Multiple ContainersApp + database, app + cache, app + worker — multi-daemon setups
Run a PostgreSQL SidecarPassword generation, pg_isready health check, pg_dump backup
Run a MySQL/MariaDB SidecarMySQL daemon, health check, mysqldump backup and restore
Run a Redis/Valkey CacheEphemeral cache daemon with valkey-cli ping health check
Create Dynamic DaemonsVariable number of daemons based on user configuration
Run a One-Shot CommandMigrations, file ownership fixes, or setup scripts before the main daemon starts

Networking

RecipeDescription
Expose a Web UISingle HTTP interface for browser access
Expose Multiple InterfacesRPC, API, peer, WebSocket, or SSH on different ports
Expose an API-Only InterfaceProgrammatic access with no browser UI

Dependencies

RecipeDescription
Depend on Another ServiceDeclare a dependency, read its connection info, and auto-configure
Enforce Settings on a DependencyCreate a cross-service task that requires specific dependency configuration
Mount Volumes from Another ServiceRead-only access to a dependency’s data volume
Support Alternative DependenciesLet users choose between backends (e.g., LND vs CLN)

Data & Health

RecipeDescription
Back Up and Restore DataVolume snapshots, pg_dump, mysqldump, and incremental rsync strategies
Add Standalone Health ChecksSync progress, reachability, and other ongoing checks beyond daemon readiness

Set Up a Basic Service

A minimal StartOS service: one container, one web UI, one health check, one backup volume. This is the starting point for any new package — every other recipe builds on this foundation.

Solution

Define a daemon in setupMain() with one subcontainer, mount a volume, and add a checkPortListening health check. Define a single HTTP interface in setupInterfaces() using MultiHost.of() and createInterface(). Define backups with sdk.Backups.ofVolumes() to back up the data volume.

Reference: Main · Interfaces

Examples

See startos/main.ts, startos/interfaces.ts, and startos/backups.ts in: hello-world, actual-budget, filebrowser, uptime-kuma, myspeed, ollama, phoenixd

Create Configuration Actions

Many services need user-configurable settings — log levels, feature toggles, resource limits. On StartOS, these are presented as actions with input forms. The user fills out the form, and the handler writes the values to a file model.

Solution

Use sdk.Action.withInput() with an InputSpec built from Value.select(), Value.number(), Value.toggle(), etc. The prefill function reads current values from a file model with .read().once(). The handler writes new values with fileModel.merge(), which preserves any keys not in the input.

Reference: Actions · File Models

Examples

See startos/actions/ in: bitcoin-core, cln, lnd, electrs, fulcrum, nostr-rs-relay, monerod, searxng, ghost, gitea, nextcloud, synapse, vaultwarden, btcpayserver, mempool, public-pool, garage, filebrowser

Generate Config Files

Most services read their configuration from files (YAML, TOML, INI, JSON, ENV). StartOS file models let you define the file’s schema in zod, then read and write it type-safely. The schema doubles as the source of truth for defaults — use .catch() on every field so files self-heal and merge() works correctly.

Solution

Define a FileHelper (.json(), .yaml(), .toml(), etc.) with a zod schema where every field has .catch() for self-healing defaults. Use .merge() to write (preserves unknown keys), .read().const(effects) for reactive reads that restart the daemon on change, and .read().once() for one-time reads. Seed defaults on install with fileModel.merge(effects, {}) — the empty merge applies all .catch() defaults.

Reference: File Models · Main

Examples

See startos/fileModels/ in: bitcoin-core, cln, lnd, electrs, fulcrum, monerod, nostr-rs-relay, searxng, synapse, tor, simplex, ghost, nextcloud, home-assistant, public-pool, ride-the-lightning, mcaptcha, bitcoin-explorer

Pass Config via Environment Variables

Some services expect configuration through environment variables rather than config files. StartOS lets you set them in the daemon’s exec.env object, with values sourced from file models, store.json, or hardcoded strings.

Solution

Read values from file models or store.json in setupMain(), then pass them as the env property of the daemon’s exec config. Values can be read reactively with .const(effects) so the daemon restarts when config changes. Hardcoded values like ports and feature flags can be set as plain strings directly in the env object.

Reference: Main · File Models

Examples

See startos/main.ts in: ghost, gitea, immich, lnbits, mempool, spliit, vaultwarden, open-webui, searxng, btcpayserver, bitcoin-explorer, helipad, mcaptcha, albyhub, jam, jitsi, ollama, public-pool, robosats, ride-the-lightning

Hardcode Config Values

Some settings must be fixed for the service to work on StartOS — ports, data paths, bind addresses, auth modes. Use z.literal().catch() in your file model schema to enforce these values. Any manual edit or stale config is automatically corrected on the next read.

Solution

In your zod schema, use z.literal(value).catch(value) for fields that must never change (ports, bind addresses, data paths, auth modes). The literal type prevents writes with different values, and .catch() auto-corrects existing files on the next merge(). Every nested object needs its own .catch() with full defaults — zod cannot cascade through nested objects, so if the outer object is missing, the inner .catch() values are never reached.

Reference: File Models

Examples

See startos/fileModels/ in: bitcoin-core, cln, lnd, monerod, synapse, tor, nostr-rs-relay, simplex

Set a Primary URL

Some services need to know which URL they’re hosted at — for generating links, sending invites, federating with other servers, or embedding in emails. Since StartOS services can be reached via multiple addresses (LAN, Tor, clearnet), the user must choose which URL the service treats as primary.

Solution

Create a “Set Primary URL” action using sdk.Action.withInput() with Value.dynamicSelect() that queries the service’s own interfaces for available hostnames. The action persists the choice to a file model. In setupMain(), read the selected URL and pass it to the service as an env var or config value.

There are two variants. For services where the URL can change anytime (Ghost, Gitea, Vaultwarden), register a reactive watcher in setupOnInit that monitors the URL via .const(effects). If the selected URL becomes unavailable (e.g., user disables a gateway), create a critical task prompting the user to pick a new one. For services where the hostname is permanent and cannot change after initial setup (Synapse), use a critical task on install with visibility: 'hidden' so it’s a one-time choice.

Reference: Actions · Interfaces · Initialization · Tasks

Examples

See startos/actions/ and startos/init/ in: ghost (changeable URL), gitea (changeable URL), vaultwarden (changeable domain), synapse (permanent server name)

Set Up SMTP / Email

Services that send email (notifications, password resets, invites) need SMTP configuration. The standard StartOS pattern offers three modes: disabled (no email), system (uses the StartOS system SMTP if configured), and custom (user provides their own SMTP server). The SDK provides built-in constructs for the entire flow.

Solution

Add the SDK’s built-in smtpShape to your store.json file model. Create a manageSmtp action using sdk.Action.withInput() with Value.smtpComposite() — this provides the standard three-mode UI (disabled/system/custom). In setupOnInit, default SMTP to disabled. In setupMain, read the SMTP config and pass credentials as environment variables or write them to the app’s config file.

Reference: Actions · File Models · Main

Examples

See startos/actions/ and startos/fileModels/ in: ghost, gitea, immich, synapse, vaultwarden, mcaptcha

Auto-Generate Internal Secrets

Many services need passwords or tokens that are generated once and used internally — database passwords, API secret keys, inter-container auth tokens. These are never shown to the user. Generate them at install time and store them in store.json for later consumption.

Solution

In setupOnInit, check for kind === 'install' and generate random strings with utils.getDefaultString(). Write them to store.json via a file model. These secrets are consumed in setupMain as env vars or config file values — they are never shown to the user.

Reference: Initialization · File Models

Examples

See startos/init/ and startos/fileModels/ in: spliit, ghost, nextcloud, immich, jitsi, mcaptcha, simplex, vaultwarden, gitea, synapse

Prompt User to Create Admin Credentials

After installing a service, the user typically needs admin credentials to log in. The standard pattern creates a critical task during init that points to a hidden action. The action generates a password (or reads one already generated) and displays it to the user. The task blocks the user from ignoring the step.

Solution

In setupOnInit (on install), generate a password and store it in a file model. Call sdk.action.createOwnTask() with severity 'critical' pointing to a hidden action. The action reads the stored password and returns it in a group result with username (unmasked, copyable) and password (masked, copyable). Use visibility: 'hidden' so the action only appears via the task.

Reference: Initialization · Tasks · Actions

Examples

See startos/init/ and startos/actions/ in: actual-budget, gitea, helipad, lightning-terminal, lnbits, nextcloud, openclaw, vaultwarden

Reset a Password

When users lose their admin password, they need a way to generate a new one. A reset action creates a temporary subcontainer, runs the app’s password-reset command, and returns the new credentials. This works whether the service is running or stopped, depending on the app.

Solution

Create an action with sdk.Action.withoutInput() that generates a new password using utils.getDefaultString(). Use sdk.SubContainer.withTemp() to spin up a temporary container, exec the app’s password-reset command with sub.execFail(), then return the password as a masked, copyable result. For multi-user apps, use sdk.Action.withInput() with Value.dynamicSelect to query the running app for admin users and let the user choose which to reset.

Reference: Actions

Examples

See startos/actions/ in: uptime-kuma, jitsi, filebrowser, gitea, nextcloud, open-webui, ride-the-lightning, synapse, immich, vaultwarden

Gate User Registration

Multi-user services often need registration enabled briefly (for the admin to create their account) then disabled to prevent unauthorized signups. A toggle action flips the setting and dynamically updates its own label to reflect the current state — “Enable Signups” vs “Disable Signups.”

Solution

Use sdk.Action.withoutInput() with an async metadata function (not a static object). The metadata reads the current registration state from a file model and dynamically sets the action name (“Enable Signups” vs “Disable Signups”), description, and warning. The handler reads the same state and flips the boolean. Pair with an 'important' severity task on install reminding the user to disable registrations after creating their admin account.

Reference: Actions · File Models

Examples

See startos/actions/ in: gitea, synapse, vaultwarden, mcaptcha

Require Setup Before Starting

Some services need the user to complete a step before the service can start — choosing a backend, setting a permanent hostname, entering API credentials. A critical task with a hidden action blocks startup until the user acts.

Solution

In setupOnInit (on install), call sdk.action.createOwnTask() with severity 'critical' pointing to a hidden action. The action collects user input via InputSpec and persists the choice to a file model. Because the task is critical, the service cannot start until the user completes it. Use allowedStatuses: 'only-stopped' on the action.

Reference: Initialization · Tasks · Actions

Examples

See startos/init/ and startos/actions/ in: albyhub, lnbits, lnd, synapse, vaultwarden, openclaw, lightning-terminal, start9-pages

Run One-Time Setup on Install

Fresh installs often need one-time bootstrapping — generating passwords, seeding config file defaults, creating initial database records. The setupOnInit hook receives a kind parameter that tells you why initialization is running.

Solution

In setupOnInit, check kind === 'install' and run one-time setup: generate passwords with utils.getDefaultString(), seed config file defaults with fileModel.merge(effects, {}) (empty merge applies all .catch() defaults), and create tasks for user actions. For setup that should run on both install and restore but not container rebuild, check kind !== null. The four init kinds are 'install', 'update', 'restore', and null.

Reference: Initialization · File Models

Examples

See startos/init/ in: spliit, ghost, nextcloud, immich, gitea, synapse, simplex, mcaptcha, vaultwarden

Bootstrap via Temporary Daemon Chain

Some services can only be configured through their own API — they have no CLI for initial setup. During install, you need to start the service temporarily, call its API to bootstrap (create admin users, set config, register apps), then shut everything down before normal startup. The runUntilSuccess pattern handles this.

Solution

In setupOnInit (on install), build a daemon chain with .addDaemon() and .addOneshot() just like in setupMain(), then call .runUntilSuccess(timeout) instead of returning the chain. The daemon starts, its health check passes, then the dependent oneshot runs the bootstrap logic (typically HTTP calls to the service’s API). Once the oneshot completes successfully, all processes are cleaned up automatically. The timeout (in milliseconds) controls how long to wait before giving up.

Reference: Initialization · Main

Examples

See startos/init/ in: nextcloud, actual-budget, immich, jitsi, garage

Handle Version Upgrades

When you release a new version of your package, users upgrading from older versions may need data migrations — transforming config formats, moving files, or updating store schemas. The version graph defines the migration path between versions.

Solution

Define a VersionGraph with a current version and an array of other (previous) versions. Each version has up and down migration functions. Use IMPOSSIBLE for directions that can’t be migrated. The up migration transforms old config, moves files, or runs storeJson.merge(effects, {}) to apply new zod defaults. Only versions that users might be upgrading from need entries in the other array.

Reference: Versions · File Models

Examples

See startos/versions/ in: bitcoin-core, cln, lnd, monerod, nextcloud, simplex, tor, synapse

Handle Restore from Backup

After restoring from backup, a service may need to re-register with external systems, fix file paths, or regenerate ephemeral state. The setupOnInit hook receives kind === 'restore' in this case — distinct from 'install' (fresh) and null (rebuild).

Solution

In setupOnInit, check for kind === 'restore' and run restore-specific logic: re-register with external systems, fix file paths, mark state for reindexing, or create tasks alerting the user to post-restore steps. For setup shared between install and restore but not container rebuild, use kind !== null.

Reference: Initialization · Tasks

Examples

See startos/init/ in: lnd, nextcloud, bitcoin-core, synapse

Run Multiple Containers

Complex services often need multiple processes — an application server plus a database, a web frontend plus a backend API, or an app plus a cache layer. Each container gets its own subcontainer, daemon definition, health check, and dependency chain.

Solution

Create multiple SubContainer instances in setupMain() — one per image (e.g., app, database, cache). Chain .addDaemon() calls for each. Use the requires array to control startup order — daemons wait for their dependencies’ health checks to pass before starting. Each daemon gets its own volume mounts, env vars, and health check.

Reference: Main

Examples

See startos/main.ts in: am-i-exposed (2 containers), bitcoin-core (4), btcpayserver (4), cln (2), ghost (2), immich (4), jitsi (5), mempool (3), monerod (2), nextcloud (3), searxng (3), simplex (2), spliit (2), synapse (3), vaultwarden (2), bitcoin-explorer (2), mcaptcha (3)

Run a PostgreSQL Sidecar

PostgreSQL is the most common database sidecar in StartOS packages. The pattern covers password generation in init, the daemon definition with health check, and backup/restore using the SDK’s built-in pg_dump support.

Solution

Generate a password in setupOnInit and store it in a file model. In setupMain, create a PostgreSQL subcontainer with sdk.useEntrypoint(['--listen_addresses=127.0.0.1']) and pass credentials via env vars. Health-check with pg_isready. The app daemon connects via localhost:5432 and declares requires: ['postgres']. For backups, use sdk.Backups.withPgDump() which handles dump and restore automatically.

Reference: Main · Initialization · File Models

Examples

See startos/main.ts and startos/backups.ts in: btcpayserver, immich, nextcloud, spliit, mcaptcha

Run a MySQL/MariaDB Sidecar

Some upstream services require MySQL or MariaDB instead of PostgreSQL. The pattern is similar but uses MySQL-specific health checks and backup tooling.

Solution

Similar to PostgreSQL but with MySQL-specific health checks and backup. Configure the MySQL daemon with --bind-address=127.0.0.1 and pass MYSQL_ROOT_PASSWORD, MYSQL_DATABASE as env vars. Health-check by execing mysql -e 'SELECT 1' or the MariaDB healthcheck.sh script. For backups, use sdk.Backups.withMysqlDump() with engine: 'mysql' or engine: 'mariadb'.

Reference: Main · Initialization

Examples

See startos/main.ts and startos/backups.ts in: ghost (MySQL), mempool (MariaDB)

Run a Redis/Valkey Cache

Caching layers improve performance for web applications. Valkey (Redis-compatible) runs as a sidecar daemon with no persistent storage — purely ephemeral.

Solution

Add a Valkey daemon with no persistent volume (ephemeral cache). Disable persistence with --save '' --appendonly no. Health-check by execing valkey-cli ping and comparing stdout to "PONG". Use display: null to hide the check from the user since it’s an internal implementation detail. The app daemon declares requires: ['valkey'] to start after the cache is ready.

Reference: Main

Examples

See startos/main.ts in: immich, nextcloud, searxng, mcaptcha, bitcoin-explorer

Create Dynamic Daemons

Some services need a variable number of daemons based on user configuration — one per tunnel, one per website, one per connected node. The daemon chain is built at runtime from a config list.

Solution

Read a variable-length list from a file model in setupMain(), then loop over entries to build the daemon chain with .addDaemon(). All dynamic daemons can share a single subcontainer image. The daemon ID must be unique per entry — derive it from the entry’s data. An alternative approach generates dynamic config files (e.g., nginx server blocks) from the list and runs a single daemon serving all entries.

Reference: Main · Actions · File Models

Examples

See startos/main.ts in: holesail (one daemon per tunnel), start9-pages (dynamic nginx config per website)

Run a One-Shot Command

Before the main daemon starts, you may need to fix file ownership, run database migrations, or perform other idempotent setup. Oneshots run to completion and block dependent daemons until they finish.

Solution

Use .addOneshot() in the daemon chain. Oneshots run to completion and block dependent daemons via the requires array. Use exec.command for simple shell commands (e.g., chown) or exec.fn for complex async logic. Oneshots run on every service start, not just once — they must be idempotent. A post-startup oneshot can depend on a daemon (requires: ['app']) to run after the app is healthy.

Reference: Main

Examples

See startos/main.ts in: ghost (chown-mysql), immich (configure-libraries), nextcloud (chown), btcpayserver

Expose a Web UI

Every service with a browser interface needs at least one HTTP interface. This is the most basic networking pattern — bind a port, create an interface descriptor, and export it.

Solution

In setupInterfaces(), create a MultiHost with sdk.MultiHost.of(effects, 'ui'), bind an HTTP port with multi.bindPort(port, { protocol: 'http', preferredExternalPort: 80 }), create a 'ui' type interface with sdk.createInterface() setting masked: false, and export it. Return the receipt array.

Reference: Interfaces

Examples

See startos/interfaces.ts in: hello-world, actual-budget, filebrowser, uptime-kuma, spliit

Expose Multiple Interfaces

Services often need more than a web UI — RPC endpoints, peer-to-peer connections, WebSocket servers, SSH, or admin dashboards on separate ports. Each interface gets its own MultiHost, port binding, and interface descriptor.

Solution

In setupInterfaces(), create separate MultiHost instances for each interface (web UI, API, peer). Each gets its own bindPort() call with appropriate protocol settings — protocol: 'http' for web, protocol: 'https' with addSsl for APIs, protocol: null with secure: { ssl: false } for raw TCP. Create interfaces with type: 'ui', type: 'api', or type: 'p2p' as appropriate. Use masked: true for interfaces whose URLs contain credentials.

Reference: Interfaces

Examples

See startos/interfaces.ts in: bitcoin-core (RPC, peer, ZMQ, I2P), cln (Web, RPC, peer, gRPC, CLNrest, WebSocket, Watchtower), lnd (REST, gRPC, peer, Watchtower), monerod (peer, RPC, wallet-RPC, ZMQ), simplex (SMP, XFTP), garage (S3 API, S3 Web, Admin API)

Expose an API-Only Interface

Some services have no web UI — they expose only a programmatic API (REST, gRPC, or custom protocol). The URL is shown as a copyable connection string rather than a clickable browser link.

Solution

Same as a web UI but use type: 'api' and masked: true on the interface. This shows the URL as a copyable connection string rather than a clickable browser link. For custom protocol schemes (e.g., lndconnect://, smp://), set schemeOverride: { ssl: 'custom-scheme', noSsl: 'custom-scheme' }.

Reference: Interfaces

Examples

See startos/interfaces.ts in: ollama, phoenixd, simplex (SMP + XFTP with custom schemes), lnd (lndconnect:// URIs)

Depend on Another Service

When your service needs another StartOS service (e.g., Bitcoin Core for a wallet, or PostgreSQL from a shared instance), declare it as a dependency. You can require it to be installed, running, or healthy, and optionally pin a version range.

Solution

In setupDependencies(), return an object mapping dependency package IDs to their requirements: kind: 'running' (must be running and healthy), kind: 'exists' (just installed), a versionRange, and healthChecks specifying which of the dependency’s health checks must pass. Read the dependency’s connection info in setupMain either via sdk.serviceInterface.get() or directly as http://<package-id>.startos:<port>.

Reference: Dependencies

Examples

See startos/dependencies.ts in: electrs, fulcrum, jam, lightning-terminal, lnbits, lnd, mempool, open-webui, public-pool, robosats, bitcoin-explorer, helipad, cln, btcpayserver, albyhub, immich, jellyfin, start9-pages, ride-the-lightning

Enforce Settings on a Dependency

Sometimes your service requires specific configuration on a dependency — Bitcoin Core must have txindex=true, or ZMQ must be enabled. A cross-service task fires on the dependency whenever its config drifts from the required values.

Solution

In setupDependencies(), call sdk.action.createTask() targeting the dependency’s autoconfig action (imported from the dependency’s package). Pass input: { kind: 'partial', value: { ... } } with the required field values, and when: { condition: 'input-not-matches', once: false } so the task re-fires whenever the dependency’s config drifts. The autoconfig action must be exported by the dependency and added to your package.json dependencies.

Reference: Dependencies · Tasks

Examples

See startos/dependencies.ts in: fulcrum (txindex + ZMQ on Bitcoin Core), public-pool (ZMQ on Bitcoin Core)

Mount Volumes from Another Service

Some services need read-only access to files from another service — media files from a file manager, TLS certificates from a Lightning node, or shared data directories. Mount a dependency’s volume into your container.

Solution

In the Mounts chain in setupMain(), use .mountDependency() typed against the dependency’s manifest. Specify the dependency’s volumeId, a subpath (or null for the whole volume), a mountpoint in your container, and readonly: true. In setupDependencies(), declare the dependency with kind: 'exists' (if you just need the files) or kind: 'running' (if the dependency must be active).

Reference: Dependencies · Main

Examples

See startos/main.ts and startos/dependencies.ts in: jellyfin (File Browser + Nextcloud media), helipad (LND macaroons/certs), ride-the-lightning (LND + CLN volumes), lightning-terminal (LND certs), albyhub (LND volume)

Support Alternative Dependencies

Some services can work with multiple backends — LND or Core Lightning for Lightning, File Browser or Nextcloud for media. An action lets the user choose, and setupDependencies reads that choice to declare only the selected dependency.

Solution

Create a selection action with Value.select() that lets the user choose between backends (e.g., LND vs CLN). Persist the choice to a file model. In setupDependencies(), read the choice and conditionally return only the selected dependency. In setupMain(), read the same choice to conditionally mount the selected dependency’s volumes and set the appropriate env vars or config.

Reference: Dependencies · Actions · Main

Examples

See startos/dependencies.ts and startos/actions/ in: btcpayserver (LND/CLN/Monero), lnbits (LND/CLN), ride-the-lightning (LND + CLN + remote nodes), jellyfin (File Browser/Nextcloud), mempool (Fulcrum/Electrs + LND/CLN), albyhub (LND/LDK)

Back Up and Restore Data

Every StartOS package must define a backup strategy. The SDK provides builders for common patterns: simple volume snapshots, PostgreSQL dumps, MySQL dumps, and incremental rsync for large datasets.

Solution

Use sdk.setupBackups() with the appropriate builder. sdk.Backups.ofVolumes('main') for simple volume snapshots. sdk.Backups.withPgDump() for PostgreSQL (handles dump and restore). sdk.Backups.withMysqlDump() for MySQL/MariaDB. Chain .addVolume('name') for additional volumes. Use .addSync({ dataPath, backupPath }) instead of .addVolume() for large, mostly-unchanged datasets (user uploads, media) — rsync is incremental and much faster than full volume copies.

Reference: Main · File Models

Examples

See startos/backups.ts in: hello-world (simple volume), spliit (pg_dump), ghost (mysqldump), nextcloud (pg_dump + rsync), immich (pg_dump + rsync)

Add Standalone Health Checks

Every daemon already includes a ready check that tells StartOS when it’s started. Standalone health checks go beyond that — they monitor ongoing conditions like blockchain sync progress, network reachability, or secondary interface availability. These checks run continuously and are displayed to the user separately from daemon readiness.

Solution

Use .addHealthCheck() on the daemon chain in setupMain(). Each health check has an ID, a ready function that returns a result, and a requires array specifying which daemons must be running first. The check function typically execs a CLI command or calls an API to assess the condition. Health check IDs are what dependency packages reference in their healthChecks array — a dependent service can require that your sync progress check passes before it considers your service ready.

Reference: Main · Dependencies

Examples

See startos/main.ts in: bitcoin-core (sync progress, I2P, Tor, clearnet reachability), lnd (sync progress, reachability), cln (sync status), electrs (sync progress), fulcrum (sync progress), monerod (sync progress), mempool (sync), btcpayserver (UTXO sync), synapse (admin interface)

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)
│   └── ABOUT.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
│   ├── 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
│   └── versions/           # Version management and migrations
├── .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 or entrypoint scripts. Required – the assets/ directory must exist and contain at least one file (e.g. ABOUT.md) for git to track it and for the build to succeed.

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)
manifest/Service metadata (ID, name, description, images) with i18n
versions/Version management and migration logic

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.

versions/

versions/
├── index.ts
├── v1.0.3.2.ts
└── v1.0.2.0.ts

In the versions/ directory, you manage package versions and define migration logic. The index.ts file uses VersionGraph.of() to index the current version and any previous versions of your package. Each version file uses VersionInfo.of() to provide the version number, release notes, and any migrations that should run.

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

See Versions for full details.

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/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/versions/
├── index.ts              # VersionGraph + 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

import { VersionGraph } from '@start9labs/start-sdk'
import { v_X_Y_Z_0_a0 } from './vX.Y.Z.0.a0'

export const versionGraph = VersionGraph.of({
  current: v_X_Y_Z_0_a0,
  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: [],
  });
});

SubContainers

SubContainers are isolated filesystem environments created from Docker images. They provide the rootfs for running daemons, oneshots, and one-off commands.

Creating SubContainers

SubContainer.of() – creates a long-lived subcontainer (for daemons and oneshots):

const appSub = await sdk.SubContainer.of(
  effects,
  { imageId: "my-app" },
  sdk.Mounts.of().mountVolume({
    volumeId: "main",
    subpath: null,
    mountpoint: "/data",
    readonly: false,
  }),
  "my-app-sub",
);

SubContainer.withTemp() – creates a temporary subcontainer that is automatically destroyed after the callback completes. Use this for one-off commands in actions, init functions, or migrations:

await sdk.SubContainer.withTemp(
  effects,
  { imageId: "my-app" },
  mounts,
  "temp-task",
  async (sub) => {
    await sub.execFail(["my-command", "--flag"]);
  },
);

Image Options

The second argument to SubContainer.of() and SubContainer.withTemp() accepts:

OptionTypeDefaultDescription
imageIdstringRequired. The Docker image ID from the manifest images field
sharedRunbooleanfalseBind-mount the host’s /run directory into the subcontainer

By default, subcontainers share /dev and /sys with the host. Setting sharedRun: true additionally shares /run, giving access to host runtime sockets (D-Bus, systemd, PID files). Most services do not need this – only use it when the container must communicate with host system services.

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

There are two kinds of health checks:

Daemon Readiness (ready)

Every daemon has a ready property that tells StartOS when the daemon has started. This is defined inline on the daemon and determines when dependent daemons (via requires) can start:

.addDaemon('app', {
  subcontainer: appSub,
  exec: { command: sdk.useEntrypoint() },
  ready: {
    display: i18n('Web Interface'),
    fn: () =>
      sdk.healthCheck.checkPortListening(appSub, 8080, {
        successMessage: i18n('Ready'),
        errorMessage: i18n('Starting...'),
      }),
    gracePeriod: 30_000,  // optional: delay first check (milliseconds)
  },
  requires: [],
})

Use display: null for internal daemons (databases, caches) whose readiness check should not be shown to the user.

Standalone Health Checks (addHealthCheck)

For ongoing conditions beyond daemon readiness — sync progress, network reachability, secondary interface availability — use .addHealthCheck() in the daemon chain. These run continuously and are displayed to the user. Their IDs are what dependency packages reference in their healthChecks array.

.addHealthCheck('sync-progress', {
  ready: {
    display: i18n('Sync Progress'),
    fn: async () => {
      const res = await appSub.exec(['myapp', 'sync-status'])
      const synced = res.exitCode === 0
      return {
        result: synced ? 'success' : 'loading',
        message: synced ? 'Fully synced' : 'Syncing...',
      }
    },
  },
  requires: ['app'],  // only runs after 'app' daemon is ready
})

A health check can also return result: 'disabled' with an informational message when the check does not apply (e.g., reachability check when no public address is configured).

Standalone health checks can be conditional — return null instead of the config object to skip the check entirely:

.addHealthCheck('optional-feature', () =>
  featureEnabled
    ? { ready: { display: i18n('Feature'), fn: checkFn }, requires: ['app'] }
    : null,
)

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:

import { writeFile } from 'node:fs/promises'

// 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: {
      // Standard PostgreSQL URI
      DATABASE_URL: `postgresql://postgres:${pgPassword}@127.0.0.1:5432/mydb`,
      // Or .NET-style: `User ID=postgres;Password=${pgPassword};Host=127.0.0.1;Port=5432;Database=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
'update'After a package version upgradeRe-apply config, handle post-migration setup
'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 '../versions'
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'),
})

Severity levels: 'critical', 'important', 'optional'

Checking Init Kind

export const initializeService = sdk.setupOnInit(async (effects, kind) => {
  // kind === 'install': Fresh install
  // kind === 'update': After version upgrade
  // 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, update, OR restore (skip container rebuild)
  }

  // No check: runs on ALL init types (install, update, 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:

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

export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
  const multi = sdk.MultiHost.of(effects, 'ui')
  const origin = await multi.bindPort(80, {
    protocol: 'http',
    preferredExternalPort: 80,
  })

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

  return [await origin.export([ui])]
})

Multiple Interfaces

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

export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
  const multi = sdk.MultiHost.of(effects, 'web')
  const origin = await multi.bindPort(80, {
    protocol: 'http',
    preferredExternalPort: 80,
  })

  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: {},
  })

  return [await origin.export([ui, admin])]
})

Expose interfaces on separate ports:

export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
  const receipts = []

  // Web UI — HTTP
  const uiMulti = sdk.MultiHost.of(effects, 'ui')
  const uiOrigin = await uiMulti.bindPort(80, {
    protocol: 'http',
    preferredExternalPort: 80,
  })
  const ui = sdk.createInterface(effects, {
    name: i18n('Web Interface'),
    id: 'ui',
    description: i18n('The main browser interface'),
    type: 'ui',
    masked: false,
    schemeOverride: null,
    username: null,
    path: '',
    query: {},
  })
  receipts.push(await uiOrigin.export([ui]))

  // API — HTTPS with SSL termination
  const apiMulti = sdk.MultiHost.of(effects, 'api')
  const apiOrigin = await apiMulti.bindPort(8080, {
    protocol: 'https',
    preferredExternalPort: 8080,
    addSsl: {
      alpn: null,
      preferredExternalPort: 8080,
      addXForwardedHeaders: false,
    },
  })
  const api = sdk.createInterface(effects, {
    name: i18n('REST API'),
    id: 'api',
    description: i18n('Programmatic access'),
    type: 'api',
    masked: true,
    schemeOverride: null,
    username: null,
    path: '',
    query: {},
  })
  receipts.push(await apiOrigin.export([api]))

  // Peer — raw TCP (not HTTP)
  const peerMulti = sdk.MultiHost.of(effects, 'peer')
  const peerOrigin = await peerMulti.bindPort(9735, {
    protocol: null,
    addSsl: null,
    preferredExternalPort: 9735,
    secure: { ssl: false },
  })
  const peer = sdk.createInterface(effects, {
    name: i18n('Peer Interface'),
    id: 'peer',
    description: i18n('Peer-to-peer network connections'),
    type: 'p2p',
    masked: true,
    schemeOverride: null,
    username: null,
    path: '',
    query: {},
  })
  receipts.push(await peerOrigin.export([peer]))

  return receipts
})

The key steps are:

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

bindPort Options

OptionTypeDescription
protocol'http' | 'https' | nullThe protocol. Use null for raw TCP (non-HTTP).
preferredExternalPortnumberThe port users will see in their URLs.
addSslobject | nullSSL termination options for HTTPS. Set to null for no SSL.
addSsl.alpnstring | nullALPN protocol negotiation (e.g., 'h2'). Usually null.
addSsl.preferredExternalPortnumberExternal port for SSL connections.
addSsl.addXForwardedHeadersbooleanWhether to add X-Forwarded-* headers.
secure{ ssl: boolean } | nullFor non-HTTP protocols, whether the connection is secure.

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', 'api', or 'p2p'
  masked: false,                   // Hide URLs with sensitive credentials?
  schemeOverride: null,            // Override URL scheme (see below)
  username: null,                  // Auth username embedded in URL
  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', 'api', or 'p2p''ui' for browser interfaces, 'api' for programmatic endpoints, 'p2p' for peer-to-peer connections.
maskedbooleanIf true, the interface URL is shown as a copyable secret. Use for URLs containing credentials or tokens.
schemeOverride{ ssl: string | null; noSsl: string | null } | nullOverride the URL scheme for custom protocols. For example, { ssl: 'lndconnect', noSsl: 'lndconnect' } produces lndconnect:// URLs. Use null for standard http/https.
usernamestring | nullUsername embedded in the URL (e.g., for smp://fingerprint:password@host).
pathstringURL path appended to the base address (e.g., '/admin/').
queryobjectURL query parameters as key-value pairs (e.g., { macaroon: 'abc123' }).

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().once();

    return {
      version: "1",
      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', description: null, value: 'admin', masked: false, copyable: true, qr: false },
    { type: 'single', name: 'Password', description: null, 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.

Implementation Examples

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
import { configToml } from '../fileModels/config.toml'

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 });
  },
);

Action With Input

For actions that accept user input, use sdk.Action.withInput() with an InputSpec form, a prefill function, and a handler:

import { sdk } from '../sdk'
import { configFile } from '../fileModels/config'
import { i18n } from '../i18n'
const { InputSpec, Value } = sdk

const inputSpec = InputSpec.of({
  timeout: Value.number({
    name: i18n('Session Timeout'),
    description: i18n('How long before idle sessions expire'),
    required: false,
    default: 30,
    min: 1,
    max: 1440,
    step: 1,
    integer: true,
    units: 'minutes',
  }),
})

export const configure = sdk.Action.withInput(
  'configure',
  {
    name: i18n('Configure'),
    description: i18n('Adjust service settings'),
    warning: null,
    allowedStatuses: 'any',
    group: null,
    visibility: 'enabled',
  },
  inputSpec,
  // Prefill form with current values
  async ({ effects }) => {
    const current = await configFile.read((c) => c.timeout).once()
    return { timeout: current }
  },
  // Handler — write new values
  async ({ effects, input }) => {
    await configFile.merge(effects, { timeout: input.timeout })
  },
)

The five arguments to withInput are: action ID, metadata (static object or async function), input spec, prefill function, and handler.

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 with provider presets (Gmail, Amazon SES, SendGrid, Mailgun, Proton Mail, or custom).

1. Add SMTP to store.json.ts

Use the SDK’s smtpShape zod schema in your store’s shape definition. See File Models for more on file model patterns.

import { FileHelper, smtpShape, z } from "@start9labs/start-sdk";
import { sdk } from "../sdk";

const shape = z.object({
  adminPassword: z.string().optional(),
  secretKey: z.string().optional(),
  smtp: smtpShape,
});

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

2. Create the manageSmtp Action

Use smtpPrefill() in the prefill function to bridge between the stored SmtpSelection type and the input spec’s expected type. These types represent the same data but are structurally different in TypeScript (the store uses a flat union, the input spec uses a distributed discriminated union), so smtpPrefill() handles the conversion.

import { smtpPrefill } from "@start9labs/start-sdk";
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: smtpPrefill(await storeJson.read((s) => s.smtp).const(effects)),
  }),

  // 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().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 from the selected provider
    const { host, from, username, password, security } =
      smtp.value.provider.value;
    smtpCredentials = {
      host,
      port: Number(security.value.port),
      from,
      username,
      password: password ?? null,
      security: security.selection,
    };
  }
  // 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 (returned by sdk.getSystemSmtp()) have this structure:

interface SmtpValue {
  host: string;
  port: number;
  from: string;
  username: string;
  password: string | null | undefined;
  security: "starttls" | "tls";
}

SmtpSelection Type

The stored SMTP selection (from smtpShape) has this structure:

type SmtpSelection =
  | { selection: "disabled"; value: Record<string, never> }
  | { selection: "system"; value: { customFrom?: string | null } }
  | {
      selection: "custom";
      value: {
        provider: {
          selection: string; // "gmail", "ses", "sendgrid", etc.
          value: {
            host: string;
            from: string;
            username: string;
            password?: string | null;
            security: {
              selection: "tls" | "starttls";
              value: { port: 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' | 'important' | 'optional'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 selecting a backend.
  • important — Prominently displayed but does not block the service. Use for post-install reminders like disabling registrations.
  • optional — 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: ['dependency-id'],
    },
  }
})

Parameters

ParameterTypeDescription
effectsEffectsProvided by the calling context
packageIdstringThe dependency’s service ID
actionActionDefinitionImported from the dependency’s package
severity'critical' | 'important' | 'optional'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 using zod schemas. They provide type safety, runtime validation, and automatic enforcement of defaults and hardcoded values throughout your codebase.

Supported Formats

File Models support automatic parsing and serialization for:

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

Custom parser/serializer support is available for non-standard formats via FileHelper.raw().

Core Principle: Lean on File Models

File models are not just type definitions — they are your primary tool for enforcing runtime correctness. The zod schema is both the shape definition and the source of truth for default values. Every key should have a .catch() so that:

  • Missing keys are filled with defaults automatically
  • Invalid values are corrected on the next merge()
  • Files can be seeded with merge(effects, {}) on first install — no separate default object needed
  • Hardcoded values (ports, paths, auth modes) are enforced on every read

When done correctly, the shape itself eliminates the need for separate default constants, defensive checks, and manual file initialization.

Creating a File Model

store.json.ts (Common Pattern)

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

import { FileHelper, z } from "@start9labs/start-sdk";
import { sdk } from "../sdk";

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

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

YAML Configuration

import { FileHelper, z } from "@start9labs/start-sdk";
import { sdk } from "../sdk";

const serverSchema = z.object({
  host: z.string().catch("localhost"),
  port: z.number().catch(8080),
});

const shape = z.object({
  server: serverSchema.catch(() => serverSchema.parse({})),
  features: z.array(z.string()).catch([]),
});

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

TOML Configuration

import { FileHelper, z } from "@start9labs/start-sdk";
import { sdk } from "../sdk";

const shape = z.object({
  api_bind: z.literal("0.0.0.0").catch("0.0.0.0"),
  api_port: z.literal(9814).catch(9814),
  debug: z.literal(false).catch(false),
  subscription_slots: z.literal(10_000).catch(10_000),
});

export const configToml = FileHelper.toml(
  { base: sdk.volumes.main, subpath: "config.toml" },
  shape,
);

XML Configuration

XML support includes options for controlling array detection during parsing:

import { FileHelper, z } from "@start9labs/start-sdk";
import { sdk } from "../sdk";

const knownProxiesSchema = z.object({
  string: z.literal("10.0.3.1").array().catch(["10.0.3.1"]),
});

const networkConfigSchema = z.object({
  KnownProxies: knownProxiesSchema.catch(() => knownProxiesSchema.parse({})),
});

const shape = z.object({
  NetworkConfiguration: networkConfigSchema.catch(() =>
    networkConfigSchema.parse({}),
  ),
});

export const networkXml = FileHelper.xml(
  { base: sdk.volumes.config, subpath: "network.xml" },
  shape,
  {
    parser: {
      // Tell the XML parser which element names should always be treated as arrays
      isArray: (name) => name === "string",
    },
  },
);

Reading File Models

Reading Methods

MethodPurpose
.once()Read once, no reactivity
.const(effects)Read and re-run the enclosing context if value changes
.onChange(effects, callback)Register a callback for value changes
.watch(effects)Create an async iterator of new values
.waitFor(effects, predicate)Block until the value satisfies a predicate

Note

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

Use the Map Function

When reading file models, always use the map function to extract only the fields you need. This is critical for two reasons:

  1. Avoids unnecessary restarts: With .const(effects), the daemon only restarts when the mapped value changes, not when any field in the file changes.
  2. Avoids unnecessary callbacks: With .onChange(effects) or .watch(effects), your callback only fires when the specific field you care about changes.
// BAD: daemon restarts when ANY field changes, even unrelated ones
const store = await storeJson.read().const(effects);
const secretKey = store?.secretKey;

// GOOD: daemon only restarts when 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)).

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 of a specific field - daemon only restarts if secretKey changes
const secretKey = await storeJson.read((s) => s.secretKey).const(effects);

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

// Wait until a condition is met (blocks until predicate returns true)
const syncedStore = await storeJson
  .read((s) => s.fullySynced)
  .waitFor(effects, (synced) => synced === true);

Writing File Models

Prefer merge() Over write()

Use merge() for almost all writes. It has two major advantages:

  1. Preserves unknown keys: merge() only updates the fields you specify, leaving everything else intact — including keys that the upstream service uses but your file model doesn’t define. write() replaces the entire file, destroying any keys not in your schema.
  2. Defaults come from the schema: When every key in your zod schema has a .catch(), the schema is the default. You can seed a file on first install with merge(effects, {}) — the .catch() values fill in every missing field. No need to define a separate defaults object and pass it to write().
// Seed a file on first install — .catch() defaults fill everything in
await configToml.merge(effects, {});

// Update specific fields, preserve everything else
await storeJson.merge(effects, { someFlag: false });

// Update nested fields
await configYaml.merge(effects, { server: { port: 9090 } });

Only use write() when you intentionally want to replace the entire file — for example, when generating a file from scratch during a migration:

// write() replaces the entire file — use only when that's the intent
await storeJson.write(effects, {
  adminPassword: generatedPassword,
  secretKey: generatedKey,
  smtp: { selection: "disabled", value: {} },
});

Exporting Defaults from File Models

When a default value from the file model is also needed elsewhere (e.g., as a placeholder or default in an action’s input spec), define the value as a constant in the file model, use it in the schema, and export it:

// fileModels/config.toml.ts
import { FileHelper, z } from "@start9labs/start-sdk";
import { sdk } from "../sdk";

export const defaultMaxUpload = "50M";

const shape = z.object({
  max_upload_size: z.string().catch(defaultMaxUpload),
  allow_registration: z.boolean().catch(false),
});

export const configToml = FileHelper.toml(
  { base: sdk.volumes.main, subpath: "config.toml" },
  shape,
);
// actions/config.ts
import { defaultMaxUpload } from "../fileModels/config.toml";

const inputSpec = InputSpec.of({
  max_upload_size: Value.text({
    name: i18n("Max Upload Size"),
    default: defaultMaxUpload,
    // ...
  }),
});

This keeps the default defined in exactly one place.

Schema Design

Every Key Should Have .catch()

Give every key a .catch() default. This makes your file model self-healing — invalid or missing values are automatically corrected, and merge(effects, {}) works for initialization.

const shape = z.object({
  host: z.string().catch("localhost"),
  port: z.number().catch(8080),
  debug: z.boolean().catch(false),
  tags: z.array(z.string()).catch([]),
  apiKey: z.string().optional().catch(undefined),
});

Nested Objects Must Also Have .catch()

.catch() does not cascade to child objects. When a parent key is missing entirely (e.g., parsing {}), validation fails at the parent level before any inner defaults can apply.

The problem:

// BROKEN: inner .catch() values never fire when "server" is missing
const shape = z.object({
  server: z.object({
    host: z.string().catch("localhost"),
    port: z.number().catch(8080),
  }),
});

shape.parse({});
// => ZodError: "server" expected object, received undefined

The fix: Extract child schemas into variables and use .catch(() => childSchema.parse({})):

const serverSchema = z.object({
  host: z.string().catch("localhost"),
  port: z.number().catch(8080),
});

const shape = z.object({
  server: serverSchema.catch(() => serverSchema.parse({})),
});

shape.parse({});
// => { server: { host: 'localhost', port: 8080 } }

The .catch() callback delegates back to the child schema, so defaults are defined in exactly one place. Extracting child schemas into variables keeps the code DRY — the shape and its defaults are the same thing.

Note

This pattern only works when all inner fields have .catch() defaults. If a nested object has required fields without defaults (e.g., a password that must be generated at init time), seed the file with complete data using write() instead of relying on merge(effects, {}).

Deep Nesting

When a schema has multiple levels of nesting, extract each level into its own variable. This keeps the top-level shape readable and ensures .catch() works at every depth:

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

// Level 2: nested object
const dbDefault = { path: '/data/app.db', journal_mode: 'wal' }
const dbShape = z
  .object({
    path: z.literal('/data/app.db').catch(dbDefault.path),
    journal_mode: z.string().catch(dbDefault.journal_mode),
  })
  .catch(dbDefault)

// Level 2: array item
const endpointDefault = { port: 8080, tls: false }
const endpointShape = z
  .object({
    port: z.number().catch(endpointDefault.port),
    tls: z.boolean().catch(endpointDefault.tls),
  })
  .catch(endpointDefault)

// Top level
const shape = z.object({
  database: dbShape,
  endpoints: z.array(endpointShape).catch([endpointDefault]),
  log_level: z.string().catch('info'),
  max_upload_size: z.string().catch('50M'),
})

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

The key technique: define each nested level’s default and shape separately, then compose them. Every level has its own .catch() so missing or malformed data at any depth resolves to sane defaults.

Hardcoded Literal Values

For values that should always be a specific literal and never change (e.g., internal ports, paths, auth modes), use z.literal().catch(). If the file ends up with a different value (e.g., user edits it manually), it is corrected on the next merge():

const shape = z.object({
  // Enforced — always corrected back to these values
  api_bind: z.literal("0.0.0.0").catch("0.0.0.0"),
  api_port: z.literal(9814).catch(9814),
  btc_network: z.literal("mainnet").catch("mainnet"),
  debug: z.literal(false).catch(false),

  // Mutable — can be changed by actions
  subscription_slots: z.number().catch(10_000),
});

This pattern is especially useful for upstream config files where you need to lock down certain values while still letting the user configure others through actions.

Using SDK-Provided Schemas

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

import { smtpShape, z } from "@start9labs/start-sdk";

const shape = z.object({
  adminPassword: z.string().optional().catch(undefined),
  smtp: smtpShape,
});

Design Guidelines

Prefer Direct FileModel Over store.json + Environment Variables

When an upstream service reads a config file (TOML, YAML, JSON, XML, 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.
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.

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: ['dependency-id'],
    },
  }
})

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()
      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.

Guiding Principles

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

CLI Reference

The start-cli tool handles both building packages and managing registries. The full command reference lives in the start-cli Reference — these sections are most relevant to service developers:

  • S9PK Packaging — build, inspect, edit, and publish .s9pk packages
  • Registry — manage packages, categories, signers, and OS versions on a registry