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
- Set up your environment — Follow Environment Setup, including the Claude Code section.
- Build your first package — Follow Quick Start to create, build, and install the Hello World template.
- 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.
- What Do You Want To Do? - Browse all recipes by intent
Getting Started
- Environment Setup - Install the required development tools
- Quick Start - Create, build, and install your first package
Reference
- Project Structure - Understand the file layout of a StartOS package
- Manifest - Define your service metadata, release notes, and alerts
- Versions - Handle install, update, and downgrade logic
- Main - Configure daemons, health checks, and the service lifecycle
- Initialization - Run code when your service initializes
- Interfaces - Expose network interfaces to users
- Actions - Define user-facing buttons and scripts
- Tasks - Prompt users to run actions at the right time
- File Models - Represent and validate configuration files
- Dependencies - Declare and configure service dependencies
- Makefile - Automate build and install workflows
- Writing READMEs - Write effective service documentation
Environment Setup
Before building service packages, you need to install several development tools on your workstation. This page lists each prerequisite and how to install it. 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.
Coding with Claude (Recommended)
AI coding tools like Claude Code can dramatically accelerate your packaging workflow. To get the best results, set up a workspace that gives Claude direct access to the packaging guide.
1. Create a workspace directory
Create a directory that will serve as your AI-assisted workspace. This is not inside your package repo — it sits alongside it.
mkdir my-workspace && cd my-workspace
2. Clone the docs
Clone the Start9 docs repo so Claude can read the packaging guide locally:
git clone https://github.com/Start9Labs/docs.git start-docs
3. Add a CLAUDE.md
Important
This is critical. Without this file, Claude will not know how to package a service for StartOS.
Download the provided CLAUDE.md and place it in your workspace root:
This file instructs Claude to use the local packaging guide as its primary reference.
4. Add your package repo
Clone or create your package repo inside the workspace:
git clone https://github.com/user/my-service-startos.git
Your workspace should look like this:
my-workspace/
├── CLAUDE.md ← AI instructions (not committed anywhere)
├── start-docs/ ← packaging guide for Claude to read
└── my-service-startos/ ← your package repo
Note
Do not put
start-docs/orCLAUDE.mdinside your package repo. They live alongside it in the workspace so they don’t pollute your package’s git history.
Quick Start
This guide walks you through creating your own service package repository from the Hello World template, building it, and installing it on StartOS.
Note
Ensure you have completed every step of Environment Setup before beginning.
Create Your Repository
-
Navigate to the Hello World Template on GitHub.
-
In the top right, click “Use this template > Create new repository”.
-
Name your repository using the convention
<service-name>-startos(e.g.,nextcloud-startos). -
Click “Create Repository”.
-
Clone your new repository:
git clone https://github.com/YOUR-USERNAME/YOUR-REPO.git cd YOUR-REPO
Build 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
| Recipe | Description |
|---|---|
| Set Up a Basic Service | Minimal single-container service with a web UI, health check, and backup |
| Create Configuration Actions | Let users configure your service through actions with input forms |
| Generate Config Files | Produce YAML, TOML, INI, JSON, or ENV files from user settings using FileModel |
| Pass Config via Environment Variables | Configure your service through environment variables in the daemon definition |
| Hardcode Config Values | Lock down ports, paths, or auth modes so users cannot change them |
| Set a Primary URL | Let users choose which hostname the service uses for links, invites, and federation |
| Set Up SMTP / Email | Let users configure email sending with disabled/system/custom modes |
Credentials & Access Control
| Recipe | Description |
|---|---|
| Auto-Generate Internal Secrets | Generate passwords or tokens in init for internal use (database auth, secret keys) |
| Prompt User to Create Admin Credentials | Critical task on install that triggers an action to generate and display a password |
| Reset a Password | Action that regenerates credentials and updates the running application |
| Gate User Registration | Toggle action that enables/disables public signups with a dynamic label |
Setup & Lifecycle
| Recipe | Description |
|---|---|
| Require Setup Before Starting | Block service startup with a critical task until the user completes configuration |
| Run One-Time Setup on Install | Generate passwords, seed databases, or bootstrap config on first install only |
| Bootstrap via Temporary Daemon Chain | Start the service during init, call its API to bootstrap, then tear it down |
| Handle Version Upgrades | Migrate data between package versions using the version graph |
| Handle Restore from Backup | Re-register services or fix state after restoring from backup |
Daemons & Containers
| Recipe | Description |
|---|---|
| Run Multiple Containers | App + database, app + cache, app + worker — multi-daemon setups |
| Run a PostgreSQL Sidecar | Password generation, pg_isready health check, pg_dump backup |
| Run a MySQL/MariaDB Sidecar | MySQL daemon, health check, mysqldump backup and restore |
| Run a Redis/Valkey Cache | Ephemeral cache daemon with valkey-cli ping health check |
| Create Dynamic Daemons | Variable number of daemons based on user configuration |
| Run a One-Shot Command | Migrations, file ownership fixes, or setup scripts before the main daemon starts |
Networking
| Recipe | Description |
|---|---|
| Expose a Web UI | Single HTTP interface for browser access |
| Expose Multiple Interfaces | RPC, API, peer, WebSocket, or SSH on different ports |
| Expose an API-Only Interface | Programmatic access with no browser UI |
Dependencies
| Recipe | Description |
|---|---|
| Depend on Another Service | Declare a dependency, read its connection info, and auto-configure |
| Enforce Settings on a Dependency | Create a cross-service task that requires specific dependency configuration |
| Mount Volumes from Another Service | Read-only access to a dependency’s data volume |
| Support Alternative Dependencies | Let users choose between backends (e.g., LND vs CLN) |
Data & Health
| Recipe | Description |
|---|---|
| Back Up and Restore Data | Volume snapshots, pg_dump, mysqldump, and incremental rsync strategies |
| Add Standalone Health Checks | Sync 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:
.gitignoreMakefile- Just includess9pk.mk(see Makefile)s9pk.mk- Shared build logic, copy from template without modificationpackage.json/package-lock.jsontsconfig.json
.github/workflows/
Every package should include two GitHub Actions workflows that delegate to shared-workflows:
buildService.yml – builds the .s9pk on push/PR:
name: Build Service
on:
workflow_dispatch:
pull_request:
paths-ignore: ["*.md"]
branches: ["master"]
push:
paths-ignore: ["*.md"]
branches: ["master"]
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
build:
if: github.event.pull_request.draft == false
uses: start9labs/shared-workflows/.github/workflows/buildService.yml@master
secrets:
DEV_KEY: ${{ secrets.DEV_KEY }}
releaseService.yml – publishes on tag push:
name: Release Service
on:
push:
tags:
- "v*.*"
jobs:
release:
uses: start9labs/shared-workflows/.github/workflows/releaseService.yml@master
with:
REGISTRY: ${{ vars.REGISTRY }}
S3_S9PKS_BASE_URL: ${{ vars.S3_S9PKS_BASE_URL }}
secrets:
DEV_KEY: ${{ secrets.DEV_KEY }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
permissions:
contents: write
CONTRIBUTING.md
Build instructions for contributors. Keep it short – link to the StartOS Packaging Guide for environment setup, then provide npm ci and make as a quick start.
Dockerfile (optional)
It is recommended to pull an existing Docker image as shown in the Quick Start. If necessary, you can define a custom image using a Dockerfile in the project root.
icon.svg
The service’s visual identifier. Maximum size is 40 KiB. Accepts .svg, .png, .jpg, and .webp formats.
LICENSE
The package’s software license, which should always match the upstream service’s license. If your package contains multiple upstream services with different licenses, select the more restrictive license.
If you have a git submodule, symlink to its license:
ln -sf upstream-project/LICENSE LICENSE
If you are pulling a pre-built Docker image (no submodule), copy the license text directly from the upstream repository.
README.md
Service documentation following the structure described in Writing READMEs. Every README should document how the StartOS package differs from the upstream service.
assets/
Stores supplementary files and scripts needed by the service, such as configuration generators 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
| File | Purpose |
|---|---|
main.ts | Daemon runtime configuration and health checks |
interfaces.ts | Network interface definitions and port bindings |
backups.ts | Backup volumes and exclusion patterns |
dependencies.ts | Service dependencies and version requirements |
sdk.ts | SDK initialization (boilerplate) |
utils.ts | Package-specific constants and helper functions |
index.ts | Module exports (boilerplate) |
backups.ts
setupBackups() is where you define what volumes to back up as well as what directories or files to exclude from backups.
dependencies.ts
setupDependencies() is where you define any dependencies of this package, including their versions, whether or not they need to be running or simply installed, and which health checks, if any, need to be passing for this package to be satisfied.
index.ts
This file is plumbing, used for exporting package functions to StartOS.
interfaces.ts
setupInterfaces() is where you define the service interfaces and determine how they are exposed. This function executes on service install, update, and config save. It takes the user’s config input as an argument, which will be null for install and update.
main.ts
setupMain() is where you define the daemons that compose your service’s runtime. It runs each time the service is started. Daemon comes with built-in health checks that can optionally be displayed to the user. You can also use setupMain() to define additional health checks, such as tracking and displaying a sync percentage.
manifest/
The manifest directory defines static metadata about the service, such as ID, name, description, release notes, helpful links, volumes, images, hardware requirements, alerts, and dependencies. See Manifest for details.
sdk.ts
This file is plumbing, used to imbue the generic Start SDK with package-specific type information defined in manifest.ts and store.ts. The exported SDK is what should be used throughout the startos/ directory. It is a custom SDK just for this package.
utils.ts
This file is for defining constants and functions specific to your package that are used throughout the code base. Many packages will not make use of this file.
Subdirectories
| Directory | Purpose |
|---|---|
actions/ | Custom user-facing scripts displayed as buttons in the UI |
fileModels/ | Type-safe representations of config files (.json, .yaml, .toml, etc.) |
i18n/ | Internationalization: default dictionary and translated strings |
init/ | Container initialization logic (install, update, restart) |
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:
- Package install (including fresh install, update, downgrade, and restore)
- Server (not service) restart
- “Container Rebuild” (a built-in Action that must be manually triggered by the user)
Note
Starting or restarting a service does not trigger container initialization. Even if a service is stopped, the container still exists with event listeners still active.
init/index.ts
setupInit() is where you define the specific order in which functions will be executed when your container initializes.
restoreInitandversionGraphmust remain first and second. Do not move them.setInterfaces,setDependencies,actionsare recommended to remain in this order, but could be rearranged if necessary.- Any custom init functions can be appended to the list of built-in functions, or even inserted between them. Most custom init functions are simply appended to the list.
It is possible to limit the execution of custom init functions to specific kinds of initialization. For example, if you only wanted to run a particular init function on fresh install and ignore it for updates and restores, setupOnInit() provides a kind variable (one of install, update, restore) that you can use for conditional logic. kind can also be null, which means the container is being initialized due to a server restart or manual container rebuild, rather than installation.
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– thesetupManifest()calli18n.ts– translated strings fordescriptionandalerts
manifest/i18n.ts
Locale objects for user-facing manifest strings. Each is a record of locale to string:
export const short = {
en_US: 'Brief description (one line)',
es_ES: 'Descripcion breve (una linea)',
de_DE: 'Kurze Beschreibung (eine Zeile)',
pl_PL: 'Krotki opis (jedna linia)',
fr_FR: 'Description breve (une ligne)',
}
export const long = {
en_US:
'Longer description explaining what the service does and its key features.',
es_ES:
'Descripcion mas larga que explica que hace el servicio y sus caracteristicas principales.',
de_DE:
'Langere Beschreibung, die erklart, was der Dienst tut und seine wichtigsten Funktionen.',
pl_PL:
'Dluzszy opis wyjasniajacy, co robi usluga i jej kluczowe funkcje.',
fr_FR:
'Description plus longue expliquant ce que fait le service et ses fonctionnalites principales.',
}
// Export alertInstall, alertUpdate, etc. as needed (or null for no alert)
manifest/index.ts
import { setupManifest } from '@start9labs/start-sdk'
import { short, long } from './i18n'
export const manifest = setupManifest({
id: 'my-service',
title: 'My Service',
license: 'MIT',
packageRepo: 'https://github.com/Start9Labs/my-service-startos',
upstreamRepo: 'https://github.com/original/my-service',
marketingUrl: 'https://example.com/',
donationUrl: null,
docsUrls: ['https://docs.example.com/guides'],
description: { short, long },
volumes: ['main'],
images: {
/* see Images Configuration below */
},
alerts: {
install: null,
update: null,
uninstall: null,
restore: null,
start: null,
stop: null,
},
dependencies: {},
})
Required Fields
| Field | Description |
|---|---|
id | Unique identifier (lowercase, hyphens allowed) |
title | Display name shown in UI |
license | SPDX identifier (MIT, Apache-2.0, GPL-3.0, etc.) |
packageRepo | URL to the StartOS package repository |
upstreamRepo | URL to the original project repository |
marketingUrl | URL for the project’s main website |
donationUrl | Donation URL or null |
docsUrls | Array of URLs to upstream documentation |
description.short | Locale object (see manifest/i18n.ts) |
description.long | Locale object (see manifest/i18n.ts) |
volumes | Storage volumes (usually ['main']) |
images | Docker image configuration (including arch) |
alerts | User notifications for lifecycle events (locale objects or null) |
dependencies | Service dependencies |
License
Check the upstream project’s LICENSE file and use the correct SPDX identifier (e.g., MIT, Apache-2.0, GPL-3.0). If you have a git submodule, symlink to its license. Otherwise, copy the license text directly from the upstream repository:
# With submodule
ln -sf upstream-project/LICENSE LICENSE
# Without submodule -- copy from upstream repo
Icon
Symlink from upstream if available (svg, png, jpg, or webp, max 40 KiB):
ln -sf upstream-project/logo.svg icon.svg
Images Configuration
Each image can include an arch field specifying supported architectures. It defaults to ['x86_64', 'aarch64', 'riscv64'] if omitted, but it is good practice to list architectures explicitly for transparency. The arch field must align with the ARCHES variable in the Makefile.
Pre-built Docker Tag
Use when an image exists on Docker Hub or another registry:
images: {
main: {
source: {
dockerTag: 'nginx:1.25',
},
arch: ['x86_64', 'aarch64'],
},
},
Local Docker Build
Use when building from a Dockerfile in the project:
// Dockerfile in project root
images: {
main: {
source: {
dockerBuild: {},
},
arch: ['x86_64', 'aarch64'],
},
},
If upstream has a working Dockerfile: Set workdir to the upstream directory. If the Dockerfile is named Dockerfile, you can omit the dockerfile field:
images: {
main: {
source: {
dockerBuild: {
workdir: './upstream-project',
},
},
arch: ['x86_64', 'aarch64'],
},
},
For a non-standard Dockerfile name, specify dockerfile relative to project root:
images: {
main: {
source: {
dockerBuild: {
workdir: './upstream-project',
dockerfile: './upstream-project/sync-server.Dockerfile',
},
},
arch: ['x86_64', 'aarch64'],
},
},
If you need a custom Dockerfile: Create one in your project root:
COPY upstream-project/ .
Architecture Support
The arch field accepts these values:
| Value | Architecture |
|---|---|
x86_64 | Intel/AMD 64-bit |
aarch64 | ARM 64-bit |
riscv64 | RISC-V 64-bit |
Most services support ['x86_64', 'aarch64']. Only add riscv64 if the upstream image actually supports it. The ARCHES variable in the Makefile must align (see Makefile).
GPU/Hardware Acceleration
For services requiring GPU access:
images: {
main: {
source: {
dockerTag: 'ollama/ollama:0.13.5',
},
arch: ['x86_64', 'aarch64'],
nvidiaContainer: true, // Enable NVIDIA GPU support
},
},
hardwareAcceleration: true, // Top-level flag
Multiple Images
Services can define multiple images. Each image needs its own arch field:
images: {
app: {
source: { dockerTag: 'myapp:latest' },
arch: ['x86_64', 'aarch64'],
},
db: {
source: { dockerTag: 'postgres:15' },
arch: ['x86_64', 'aarch64'],
},
},
Alerts
Display messages to users during lifecycle events. Use locale objects for translated alerts, or null for no alert:
// In manifest/i18n.ts
export const alertInstall = {
en_US: 'After installation, run the "Get Admin Credentials" action to retrieve your password.',
es_ES: 'Despues de la instalacion, ejecute la accion "Obtener credenciales de administrador" para recuperar su contrasena.',
de_DE: 'Fuhren Sie nach der Installation die Aktion "Admin-Zugangsdaten abrufen" aus, um Ihr Passwort abzurufen.',
pl_PL: 'Po instalacji uruchom akcje "Pobierz dane administratora", aby uzyskac haslo.',
fr_FR: "Apres l'installation, executez l'action 'Obtenir les identifiants admin' pour recuperer votre mot de passe.",
}
// In manifest/index.ts
import { short, long, alertInstall } from './i18n'
alerts: {
install: alertInstall,
update: null,
uninstall: null,
restore: null,
start: null,
stop: null,
},
Volumes
Storage volumes for persistent data. When possible, prefer matching the upstream project’s volume naming convention for clarity:
// If upstream docker-compose uses a volume named "mcaptcha-data"
volumes: ['mcaptcha-data'],
// Simple services can use 'main'
volumes: ['main'],
For services needing separate storage areas:
volumes: ['main', 'db', 'config'],
Reference these in main.ts mounts by the volume ID you chose.
Dependencies
Declare dependencies on other StartOS services. Note that dependency description is a plain string, not a locale object:
dependencies: {
// Required dependency
bitcoin: {
description: 'Required for blockchain data',
optional: false,
},
// Optional dependency with metadata
'c-lightning': {
description: 'Needed for Lightning payments',
optional: true,
metadata: {
title: 'Core Lightning',
icon: 'https://raw.githubusercontent.com/Start9Labs/cln-startos/refs/heads/master/icon.png',
},
},
},
Versions
StartOS uses Extended Versioning (ExVer) to manage package versions, allowing downstream maintainers to release updates without upstream changes.
Version Format
[#flavor:]<upstream>[-upstream-prerelease]:<downstream>[-downstream-prerelease]
| Component | Description | Example |
|---|---|---|
flavor | Optional variant for diverging forks | #libre: |
upstream | Upstream project version (SemVer) | 26.0.0 |
upstream-prerelease | Upstream prerelease suffix | -beta.1 |
downstream | StartOS wrapper revision | 0, 1, 2 |
downstream-prerelease | Wrapper prerelease suffix | -alpha.0, -beta.0 |
Flavor
Flavors are for diverging forks of a project that maintain separate version histories. Example: if a project forks into “libre” and “pro” editions that diverge significantly, each would have its own flavor prefix.
Note
Do NOT use flavors for hardware variants (like GPU types) – those should be handled via build configuration.
Examples
| Version String | Upstream | Downstream |
|---|---|---|
26.0.0:0 | 26.0.0 (stable) | 0 (stable) |
26.0.0:0-beta.0 | 26.0.0 (stable) | 0-beta.0 |
26.0.0-rc.1:0-alpha.0 | 26.0.0-rc.1 | 0-alpha.0 |
0.13.5:0-alpha.0 | 0.13.5 (stable) | 0-alpha.0 |
2.3.2:1-beta.0 | 2.3.2 (stable) | 1-beta.0 |
Version Ordering
Versions are compared by:
- Upstream version (most significant)
- Upstream prerelease (stable > rc > beta > alpha)
- Downstream revision
- Downstream prerelease (stable > rc > beta > alpha)
Example ordering (lowest to highest):
1.0.0-alpha.0:01.0.0-beta.0:01.0.0:0-alpha.01.0.0:0-beta.01.0.0:0(fully stable)1.0.0:1-alpha.01.0.0:11.1.0:0-alpha.0
Choosing a Version
When creating a new package:
- Select the latest stable upstream version – avoid prereleases (alpha, beta, rc) unless necessary.
- Match the Docker image tag – the version in
manifest/index.tsimages.*.source.dockerTagmust match the upstream version. - Match the git submodule – if using a submodule, check out the corresponding tag.
- Start downstream at 0 – increment only when making wrapper-only changes.
- Start downstream as alpha or beta – use
-alpha.0or-beta.0for initial releases.
Version Consistency Checklist
Ensure these all match for upstream version X.Y.Z:
- Version file exists:
startos/versions/vX.Y.Z.0.a0.ts - Version string matches:
version: 'X.Y.Z:0-alpha.0'in VersionInfo - Docker tag matches:
dockerTag: 'image:X.Y.Z'inmanifest/index.ts(if using pre-built image) - Git submodule checked out to
vX.Y.Ztag (if applicable)
File Structure
startos/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
| Version | Filename |
|---|---|
26.0.0:0-beta.0 | v26.0.0.0.b0.ts |
0.13.5:0-alpha.0 | v0.13.5.0.a0.ts |
2.3.2:1 | v2.3.2.1.ts |
Version File Template
import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk'
export const v_X_Y_Z_0_a0 = VersionInfo.of({
version: 'X.Y.Z:0-alpha.0',
releaseNotes: {
en_US: 'Initial release for StartOS',
es_ES: 'Version inicial para StartOS',
de_DE: 'Erstveeroffentlichung fuer StartOS',
pl_PL: 'Pierwsze wydanie dla StartOS',
fr_FR: 'Version initiale pour StartOS',
},
migrations: {
up: async ({ effects }) => {},
down: IMPOSSIBLE, // Use for initial versions or breaking changes
},
})
index.ts
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:
- Update git submodule to new tag
- Update
dockerTagin manifest/index.ts - Create new version file with new upstream version
- Reset downstream to 0
Wrapper-Only Changes
When making changes to the StartOS wrapper without upstream changes:
- Keep upstream version the same
- Increment downstream revision
- Create new version file
Promoting Prereleases
To promote from alpha to beta to stable:
- Create new version file without prerelease suffix (or with next stage)
- Update
index.tsto export new version as current - Move old version to
otherarray
Migrations
Migrations run when users update between versions:
migrations: {
up: async ({ effects }) => {
// Code to migrate from previous version
// Access volumes, update configs, etc.
},
down: async ({ effects }) => {
// Code to rollback (if possible)
},
}
Use IMPOSSIBLE for the down migration when:
- It is the initial version (nothing to roll back to)
- The migration involves breaking changes that cannot be reversed
migrations: {
up: async ({ effects }) => {
// Migration logic
},
down: IMPOSSIBLE,
}
Warning
Migrations are only for migrating data that is not migrated by the upstream service itself.
setupOnInit
Use sdk.setupOnInit() to run setup logic during installation, restore, or container rebuild. It receives a kind parameter:
| Kind | When it runs |
|---|---|
'install' | Fresh install |
'restore' | Restoring from backup |
null | Container rebuild (no data changes) |
Bootstrapping Config Files
Generate passwords, write initial config files, and seed stores on fresh install:
// init/seedFiles.ts
export const seedFiles = sdk.setupOnInit(async (effects, kind) => {
if (kind !== 'install') return
const secretKey = utils.getDefaultString({ charset: 'a-z,A-Z,0-9', len: 32 })
await storeJson.merge(effects, { secretKey })
await configToml.merge(effects, { /* initial config */ })
})
Creating Tasks
Tasks reference actions, so they must be created in a setupOnInit that runs after actions are registered in the init sequence:
// init/initializeService.ts
export const initializeService = sdk.setupOnInit(async (effects, kind) => {
if (kind !== 'install') return
await sdk.action.createOwnTask(effects, toggleRegistrations, 'important', {
reason: 'After creating your admin account, disable registrations.',
})
})
Main
setupMain() defines the runtime behavior of your service – daemons, health checks, volume mounts, environment variables, and config file generation. It runs each time the service is started.
Basic Structure
import { i18n } from "./i18n";
import { sdk } from "./sdk";
import { uiPort } from "./utils";
export const main = sdk.setupMain(async ({ effects }) => {
/**
* ======================== Setup (optional) ========================
*
* In this section, we fetch any resources or run any desired preliminary commands.
*/
console.info(i18n("Starting Hello World!"));
/**
* ======================== Daemons ========================
*
* In this section, we create one or more daemons that define the service runtime.
*
* Each daemon defines its own health check, which can optionally be exposed to the user.
*/
return sdk.Daemons.of(effects).addDaemon("primary", {
subcontainer: await sdk.SubContainer.of(
effects,
{ imageId: "hello-world" },
sdk.Mounts.of().mountVolume({
volumeId: "main",
subpath: null,
mountpoint: "/data",
readonly: false,
}),
"hello-world-sub",
),
exec: { command: ["hello-world"] },
ready: {
display: i18n("Web Interface"),
fn: () =>
sdk.healthCheck.checkPortListening(effects, uiPort, {
successMessage: i18n("The web interface is ready"),
errorMessage: i18n("The web interface is not ready"),
}),
},
requires: [],
});
});
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:
| Option | Type | Default | Description |
|---|---|---|---|
imageId | string | — | Required. The Docker image ID from the manifest images field |
sharedRun | boolean | false | Bind-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:
| Method | Returns | Behavior on Change |
|---|---|---|
.once() | Parsed content only | Nothing – value is stale |
.const(effects) | Parsed content | Re-runs the setupMain context, restarting daemons |
// Reactive: re-runs setupMain when value changes (restarts daemons)
const store = await storeJson.read().const(effects);
// One-time: read once, no re-run on change
const store = await storeJson.read().once();
Subset Reading
Use a mapper function to read only specific fields. This is more efficient and limits reactivity to only the fields you care about:
// Read only secretKey - re-runs only if secretKey changes
const secretKey = await storeJson.read((s) => s.secretKey).const(effects);
Other Reading Methods
| Method | Purpose |
|---|---|
.onChange(effects, callback) | Register callback for value changes |
.watch(effects) | Create async iterator of new values |
Getting Hostnames
Use a mapper function to extract only the data you need. The service only restarts if the mapped result changes, not if other interface properties change:
// With mapper - only restarts if hostnames change
const allowedHosts =
(await sdk.serviceInterface
.getOwn(effects, "ui", (i) =>
i?.addressInfo?.format("hostname-info").map((h) => h.hostname.value),
)
.const()) || [];
// Without mapper - restarts on any interface change (not recommended)
const uiInterface = await sdk.serviceInterface.getOwn(effects, "ui").const();
const allowedHosts =
uiInterface?.addressInfo
?.format("hostname-info")
.map((h) => h.hostname.value) ?? [];
Oneshots (Runtime)
Oneshots are tasks that run on every startup before daemons. Use them for idempotent operations like migrations:
// change ownership of a directory
.addOneshot('chown', {
subcontainer,
exec: {
command: ['chown', '-R', 'user:user', '/data',],
user: 'root',
},
requires: [],
})
.addOneshot('collectstatic', {
subcontainer: appSub,
exec: { command: ['python', 'manage.py', 'collectstatic', '--noinput'] },
requires: ['migrate'],
})
Warning
Do NOT put one-time setup tasks (like
createsuperuser) inmain.tsoneshots – they run on every startup and will fail on subsequent runs. Useinit/initializeService.tsinstead. See Initialization Patterns for details.
Exec Command
Using Upstream Entrypoint
If the upstream Docker image has a compatible ENTRYPOINT/CMD, use sdk.useEntrypoint() instead of specifying a custom command. This is the simplest approach and ensures compatibility with the upstream image:
.addDaemon('primary', {
subcontainer: appSub,
exec: {
command: sdk.useEntrypoint(),
},
// ...
})
You can pass an array of arguments to override the image’s CMD while keeping the ENTRYPOINT:
.addDaemon('postgres', {
subcontainer: postgresSub,
exec: {
command: sdk.useEntrypoint(['-c', 'listen_addresses=127.0.0.1']),
},
// ...
})
When to use sdk.useEntrypoint():
- Upstream image has a working entrypoint that starts the service correctly
- You want to use the entrypoint but optionally override CMD arguments
- Examples: Ollama, Jellyfin, Vaultwarden, Postgres
Custom Command
Use a custom command array when you need to bypass the entrypoint entirely:
.addDaemon('primary', {
subcontainer: appSub,
exec: {
command: ['/opt/app/bin/start.sh', '--port=' + uiPort],
},
// ...
})
Environment Variables
Pass environment variables to a daemon or oneshot via the env option on exec:
.addDaemon('main', {
subcontainer: appSub,
exec: {
command: sdk.useEntrypoint(),
env: {
DATABASE_URL: 'sqlite:///data/db.sqlite3',
SECRET_KEY: store?.secretKey ?? '',
},
},
// ...
})
Health Checks
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:
| Method | Behavior on Non-zero Exit |
|---|---|
exec() | Returns result with exitCode, stdout, stderr – does NOT throw |
execFail() | Throws an error on non-zero exit code |
// exec() - manual error handling (good for optional/warning cases)
const result = await appSub.exec(["update-ca-certificates"], { user: "root" });
if (result.exitCode !== 0) {
console.warn("Failed to update CA certificates:", result.stderr);
}
// execFail() - throws on error (good for required commands)
// Uses the default user from the Dockerfile (no need to specify { user: '...' })
await appSub.execFail(["git", "clone", "https://github.com/user/repo.git"]);
// Override user when needed (e.g., run as root)
await appSub.exec(["update-ca-certificates"], { user: "root" });
The user option is optional. If omitted, commands run as the default user defined in the Dockerfile (USER directive). Only specify { user: 'root' } when you need elevated privileges.
Use execFail() when:
- The command must succeed for the service to work correctly
- You are in
initializeService.tsand want installation to fail if setup fails - You want automatic error propagation
Use exec() when:
- The command failure is not critical (warnings, optional setup)
- You need to inspect the exit code or output regardless of success/failure
- You want custom error handling logic
PostgreSQL Sidecar
Many services require a PostgreSQL database. Run it as a sidecar daemon within the same service package.
Security Model
Use password authentication with localhost-only binding. Auto-generate the password on install and store it in your store.json FileModel.
Password generation (in utils.ts):
import { utils } from "@start9labs/start-sdk";
export function getDefaultPgPassword(): string {
return utils.getDefaultString({ charset: "a-z,A-Z,0-9", len: 22 });
}
Store schema (in fileModels/store.json.ts):
const shape = z.object({
pgPassword: z.string().catch(""),
// ...other fields
});
Seed on install (in init/seedFiles.ts):
export const seedFiles = sdk.setupOnInit(async (effects, kind) => {
if (kind !== "install") return;
await storeJson.merge(effects, {
pgPassword: getDefaultPgPassword(),
});
});
Seed on upgrade (in version migration):
// Generate pgPassword for users upgrading from a version that didn't have one
const existing = await storeJson.read((s) => s.pgPassword).once();
await storeJson.merge(effects, {
pgPassword: existing || getDefaultPgPassword(),
});
Daemon Configuration
import { sdk } from "./sdk";
import { i18n } from "./i18n";
// Read password from store
const pgPassword = store.pgPassword;
// Define mounts for PostgreSQL data
const pgMounts = sdk.Mounts.of().mountVolume({
volumeId: "main",
subpath: "postgresql",
mountpoint: "/var/lib/postgresql",
readonly: false,
});
// Create subcontainer
const postgresSub = await sdk.SubContainer.of(
effects,
{ imageId: "postgres" },
pgMounts,
"postgres",
);
// Add as daemon
.addDaemon('postgres', {
subcontainer: postgresSub,
exec: {
command: sdk.useEntrypoint(['-c', 'listen_addresses=127.0.0.1']),
env: {
POSTGRES_PASSWORD: pgPassword,
},
},
ready: {
display: null, // Internal service, not shown in UI
fn: async () => {
const result = await postgresSub.exec([
'pg_isready', '-q', '-h', '127.0.0.1',
'-d', 'postgres', '-U', 'postgres',
])
if (result.exitCode !== 0) {
return {
result: 'loading',
message: i18n('Waiting for PostgreSQL to be ready'),
}
}
return {
result: 'success',
message: i18n('PostgreSQL is ready'),
}
},
},
requires: [],
})
Key points:
listen_addresses=127.0.0.1: Restricts connections to localhost only — no external accessPOSTGRES_PASSWORD: Auto-generated password, stored instore.jsondisplay: null: Internal sidecar health checks are typically not shown to the user
Connection Strings
When the upstream service needs a PostgreSQL connection string, include the password:
.addDaemon('app', {
subcontainer: appSub,
exec: {
command: sdk.useEntrypoint(),
env: {
// 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
postgresimage handles initial database creation automatically. You do not need to runcreatedborinitdbmanually on fresh installs.
Querying PostgreSQL from Actions
Some actions need to query PostgreSQL directly (e.g., resetting a user password). Read the password from the store:
import { Client } from 'pg'
const pgPassword = (await storeJson.read((s) => s.pgPassword).once()) || ''
const client = new Client({
user: 'postgres',
password: pgPassword,
host: '127.0.0.1',
database: 'mydb',
port: 5432,
})
try {
await client.connect()
await client.query(
`UPDATE "Users" SET "PasswordHash"=$1 WHERE "Id"=$2`,
[hash, userId],
)
} finally {
await client.end()
}
Warning
When interpolating values into raw SQL strings (e.g., for
psql -c), always escape single quotes to prevent SQL injection:function sqlLiteral(value: string): string { return `'${value.replace(/'/g, "''")}'` } // Use in psql commands await sub.execFail( ['psql', '-c', `ALTER USER myuser PASSWORD ${sqlLiteral(password)}`], { user: 'postgres' }, )Prefer parameterized queries (the
$1syntax above) whenever possible — they handle escaping automatically.
Config File Generation
A common pattern is to define a helper function that generates a config file string from your service’s configuration values:
function generateConfig(config: {
secretKey: string;
allowedHosts: string[];
}): string {
const hostsList = config.allowedHosts.map((h) => `'${h}'`).join(", ");
return `
SECRET_KEY = '${config.secretKey}'
ALLOWED_HOSTS = [${hostsList}]
DATABASE = '/data/db.sqlite3'
`;
}
Initialization
setupOnInit runs during container initialization. The kind parameter indicates why init is running:
| Kind | When | Use For |
|---|---|---|
'install' | Fresh install | Generate passwords, create admin users, bootstrap via API |
'update' | After a package version upgrade | Re-apply config, handle post-migration setup |
'restore' | Restoring from backup | Re-register triggers, skip password generation |
null | Container rebuild, server restart | Register long-lived triggers (e.g., .const() watchers) |
Init Kinds
Install Only
For one-time setup that generates new state:
export const initializeService = sdk.setupOnInit(async (effects, kind) => {
if (kind !== 'install') return
// Create a critical task for the user to perform before they can start the service
await sdk.action.createOwnTask(effects, createPassword, 'critical', {
reason: i18n('Create your admin password'),
})
})
Restore
For setup that should also run when restoring from backup (but not on container rebuild):
export const initializeService = sdk.setupOnInit(async (effects, kind) => {
if (kind === null) return // Skip on container rebuild
// Runs on both install and restore
await sdk.action.createOwnTask(effects, getAdminPassword, 'critical', {
reason: i18n('Retrieve the admin password'),
})
})
Always (Container Lifetime)
For registering .const() triggers that need to persist for the container’s lifetime. These re-register on container rebuild:
export const initializeService = sdk.setupOnInit(async (effects, kind) => {
// Runs on install, restore, AND container rebuild
// Register a watcher that lives for the container lifetime
someConfig.read((c) => c.setting).const(effects)
// Install-specific setup
if (kind === 'install') {
const adminPassword = utils.getDefaultString({
charset: 'a-z,A-Z,0-9',
len: 22,
})
await storeJson.write(effects, { adminPassword })
}
})
Basic Structure
// init/initializeService.ts
import { utils } from '@start9labs/start-sdk'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
import { storeJson } from '../fileModels/store.json'
import { getAdminPassword } from '../actions/getAdminPassword'
export const initializeService = sdk.setupOnInit(async (effects, kind) => {
if (kind !== 'install') return
// Generate and store password
const adminPassword = utils.getDefaultString({
charset: 'a-z,A-Z,0-9',
len: 22,
})
await storeJson.write(effects, { adminPassword })
// Create task prompting user to retrieve password
await sdk.action.createOwnTask(effects, getAdminPassword, 'critical', {
reason: i18n('Retrieve the admin password'),
})
})
Registering initializeService
Add your custom init function to init/index.ts:
import { sdk } from '../sdk'
import { setDependencies } from '../dependencies'
import { setInterfaces } from '../interfaces'
import { versionGraph } from '../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:
- The daemon starts and runs its health check
- Once healthy, the dependent oneshot executes
- When the oneshot completes successfully,
runUntilSuccessreturns - All processes are cleaned up automatically
Making HTTP Calls Without curl
Many slim Docker images do not have curl. Use the runtime’s built-in HTTP capabilities instead.
Node.js (v18+):
command: [
'node',
'-e',
`fetch('http://127.0.0.1:${port}/api/endpoint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'value' })
}).then(r => r.ok ? process.exit(0) : process.exit(1))
.catch(() => process.exit(1))`,
]
Python:
command: [
'python',
'-c',
`import urllib.request, json
req = urllib.request.Request(
'http://127.0.0.1:${port}/api/endpoint',
data=json.dumps({'key': 'value'}).encode(),
headers={'Content-Type': 'application/json'},
method='POST'
)
urllib.request.urlopen(req)`,
]
Common Patterns
Generate Random Password
import { utils } from '@start9labs/start-sdk'
const password = utils.getDefaultString({
charset: 'a-z,A-Z,0-9',
len: 22,
})
Create User Task
Prompt the user to run an action after install:
await sdk.action.createOwnTask(effects, getAdminPassword, 'critical', {
reason: i18n('Retrieve the admin password'),
})
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:
- Create a
MultiHostand bind a port with protocol and options - Create one or more interfaces using
sdk.createInterface() - Export the interfaces from the origin and return the receipt(s)
bindPort Options
| Option | Type | Description |
|---|---|---|
protocol | 'http' | 'https' | null | The protocol. Use null for raw TCP (non-HTTP). |
preferredExternalPort | number | The port users will see in their URLs. |
addSsl | object | null | SSL termination options for HTTPS. Set to null for no SSL. |
addSsl.alpn | string | null | ALPN protocol negotiation (e.g., 'h2'). Usually null. |
addSsl.preferredExternalPort | number | External port for SSL connections. |
addSsl.addXForwardedHeaders | boolean | Whether to add X-Forwarded-* headers. |
secure | { ssl: boolean } | null | For 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
})
| Option | Type | Description |
|---|---|---|
name | string | Display name shown to the user. Wrap with i18n(). |
id | string | Unique identifier. Used to retrieve this interface in main.ts via sdk.serviceInterface.getOwn(). |
description | string | Description shown to the user. Wrap with i18n(). |
type | 'ui', 'api', or 'p2p' | 'ui' for browser interfaces, 'api' for programmatic endpoints, 'p2p' for peer-to-peer connections. |
masked | boolean | If true, the interface URL is shown as a copyable secret. Use for URLs containing credentials or tokens. |
schemeOverride | { ssl: string | null; noSsl: string | null } | null | Override the URL scheme for custom protocols. For example, { ssl: 'lndconnect', noSsl: 'lndconnect' } produces lndconnect:// URLs. Use null for standard http/https. |
username | string | null | Username embedded in the URL (e.g., for smp://fingerprint:password@host). |
path | string | URL path appended to the base address (e.g., '/admin/'). |
query | object | URL query parameters as key-value pairs (e.g., { macaroon: 'abc123' }). |
Tip
The
idyou assign to an interface is what you use inmain.tsto retrieve hostnames for that interface. For example, if you setid: 'ui', you would callsdk.serviceInterface.getOwn(effects, 'ui')to get its address information. See Main for details.
Actions
Actions are user-triggered operations that appear in the StartOS UI for your service. They can display information, accept user input, modify configuration, and more.
Action Without Input
The simplest action type retrieves or computes a result and displays it to the user.
import { i18n } from "../i18n";
import { sdk } from "../sdk";
import { storeJson } from "../fileModels/store.json";
export const getAdminCredentials = sdk.Action.withoutInput(
// ID
"get-admin-credentials",
// Metadata
async ({ effects }) => ({
name: i18n("Get Admin Credentials"),
description: i18n("Retrieve admin username and password"),
warning: null,
allowedStatuses: "any", // 'any', 'only-running', 'only-stopped'
group: null,
visibility: "enabled", // 'enabled', 'disabled', 'hidden'
}),
// Handler
async ({ effects }) => {
const store = await storeJson.read().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:
- Start with registrations enabled in the initial config.
- Create an important task in
setupOnInitadvising the user to disable registrations after creating their admin account. - Provide a toggle action that reads the current registration state, flips it, and writes back.
// In init/initializeService.ts
export const initializeService = sdk.setupOnInit(async (effects, kind) => {
if (kind !== "install") return;
await sdk.action.createOwnTask(effects, toggleRegistrations, "important", {
reason:
"After creating your admin account, disable registrations to prevent unauthorized signups.",
});
});
// In actions/toggleRegistrations.ts
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
| Parameter | Type | Description |
|---|---|---|
effects | Effects | Provided by the calling context |
action | ActionDefinition | The 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
| Parameter | Type | Description |
|---|---|---|
effects | Effects | Provided by the calling context |
packageId | string | The dependency’s service ID |
action | ActionDefinition | Imported from the dependency’s package |
severity | 'critical' | 'important' | 'optional' | How urgently the task is surfaced |
options | object | See below |
Options
| Field | Type | Description |
|---|---|---|
input | { kind: 'partial', value: Partial<InputSpec> } | Pre-fill fields in the action’s input form |
when | { condition: 'input-not-matches', once: boolean } | Re-trigger until the action’s input matches the provided values |
reason | string | Human-readable explanation shown to the user |
replayId | string (optional) | Prevents duplicate tasks across restarts |
Note
The dependency must be listed in your
package.jsonso the action can be imported (e.g.,"synapse-startos": "file:../synapse-wrapper"). See Dependencies for more on cross-service integration.
File Models
File Models represent configuration files as TypeScript definitions 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
| Method | Purpose |
|---|---|
.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
nullif 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:
- Avoids unnecessary restarts: With
.const(effects), the daemon only restarts when the mapped value changes, not when any field in the file changes. - 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:
- 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. - 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 withmerge(effects, {})— the.catch()values fill in every missing field. No need to define a separate defaults object and pass it towrite().
// 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 usingwrite()instead of relying onmerge(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.replayIdprevents duplicate tasks across restarts.
Reading Dependency Interfaces
Use sdk.serviceInterface.get() in main.ts to read a dependency’s interface at runtime:
const url = await sdk.serviceInterface
.get(
effects,
{ id: 'interface-id', packageId: 'dependency-id' },
(i) => {
const urls = i?.addressInfo?.format()
if (!urls || urls.length === 0) return null
return urls[0]
},
)
.const() // re-runs setupMain if the interface changes
Alternatively, services are reachable directly by hostname at http://<package-id>.startos:<port>:
const url = 'http://bitcoind.startos:8332'
Mounting Dependency Volumes
Mount a dependency’s volume for direct file access in main.ts:
const mounts = sdk.Mounts.of()
.mountVolume({ volumeId: 'main', subpath: null, mountpoint: '/data', readonly: false })
.mountDependency({
dependencyId: 'bitcoind',
volumeId: 'main',
subpath: null,
mountpoint: '/mnt/bitcoind',
readonly: true,
})
Init Order
Dependencies are resolved during initialization in this order:
restoreInit -> versionGraph -> setInterfaces -> setDependencies -> actions -> setup
setInterfaces runs before setDependencies, so your service’s interfaces are available when creating cross-service tasks.
Makefile Build System
StartOS packages use a two-file Makefile system that separates reusable build logic from project-specific configuration.
File Structure
my-service-startos/
├── Makefile # Project-specific configuration (minimal)
└── s9pk.mk # Shared build logic (copy from template)
s9pk.mk
The s9pk.mk file contains all the common build logic shared across StartOS packages. Copy this file from hello-world-startos/s9pk.mk without modification.
Targets
| Target | Description |
|---|---|
make or make all | Build for all architectures (default) |
make x86 | Build for x86_64 only |
make arm | Build for aarch64 only |
make riscv | Build for riscv64 only |
make universal | Build a single package containing all architectures |
make install | Install the most recent .s9pk to your StartOS server |
make clean | Remove build artifacts |
Variables
| Variable | Default | Description |
|---|---|---|
ARCHES | x86 arm riscv | Architectures to build by default |
TARGETS | arches | Default build target |
VARIANT | (unset) | Optional variant suffix for package name |
Makefile
The project Makefile is minimal and just includes s9pk.mk:
include s9pk.mk
Adding Custom Targets
For services with variants (e.g., GPU support), extend the Makefile:
TARGETS := generic rocm
ARCHES := x86 arm
include s9pk.mk
.PHONY: generic rocm
generic:
$(MAKE) all_arches VARIANT=generic
rocm:
ROCM=1 $(MAKE) all_arches VARIANT=rocm ARCHES=x86_64
This produces packages named myservice_generic_x86_64.s9pk and myservice_rocm_x86_64.s9pk.
Overriding Defaults
Override variables before include s9pk.mk:
# Build only for x86 and arm
ARCHES := x86 arm
include s9pk.mk
Build Commands
# Build for all architectures
make
# Build for a specific architecture
make x86
make arm
# Install to StartOS server (requires ~/.startos/config.yaml)
make install
# Clean build artifacts
make clean
Chaining Commands
You can chain multiple targets in a single invocation:
make clean arm # Clean, then build ARM package
make clean x86 install # Clean, build x86 package, then install
make clean install # Clean, build universal, then install
Prerequisites
The build system checks for:
start-cli– StartOS CLI toolnpm– Node.js package manager~/.startos/developer.key.pem– Developer key (auto-initialized if missing)
See Environment Setup for installation instructions.
Installation
To install a package directly to your StartOS server, configure the server address in ~/.startos/config.yaml:
host: http://your-server.local
Then run:
make install
This builds the package and sideloads it to your device.
Example Output
Building an ARM package:
$ make arm
Re-evaluating ingredients...
Packing 'albyhub_aarch64.s9pk'...
Build Complete!
Alby Hub v1.19.3:1-alpha.0
Filename: albyhub_aarch64.s9pk
Size: 7M
Arch: aarch64
SDK: 0.4.0-beta.36
Git: 78c30ec776f6a9d55be3701e9b82093c866a382c
Note
If you have uncommitted changes, the Git hash will be shown in red.
Installing a package:
$ make arm install
Installing to working-finalist.local ...
Sideloading 100%
Uploading...
Validating Headers...
Unpacking...
Writing Service READMEs
Every StartOS package README should document how your service on StartOS differs from the upstream version. Users should be able to read your README and understand exactly what is different – everything else, they can find in the upstream docs.
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:
- Humans – clear, scannable, with practical examples
- AI assistants – structured data that can be parsed programmatically
Recommended Structure
# [Service Name] on StartOS
> **Upstream docs:** <https://docs.example.com/>
>
> Everything not listed in this document should behave the same as upstream
> [Service Name] [version]. If a feature, setting, or behavior is not mentioned
> here, the upstream documentation is accurate and fully applicable.
[Brief description of what the service does and link to upstream repo]
---
## Table of Contents
[Links to each section]
---
## Image and Container Runtime
[Image source, architectures, entrypoint modifications]
## Volume and Data Layout
[Mount points, data directories, StartOS-specific files like store.json]
## Installation and First-Run Flow
[How setup differs from upstream -- skipped wizards, auto-configuration, initial credentials]
## Configuration Management
[Which settings are managed by StartOS vs configurable via upstream methods]
## Network Access and Interfaces
[Exposed ports, protocols, access methods]
## Actions (StartOS UI)
[Each action: name, purpose, availability, inputs/outputs]
## Backups and Restore
[What's backed up, restore behavior]
## Health Checks
[Endpoint, grace period, messages]
## Limitations and Differences
[Numbered list of key limitations compared to upstream]
## What Is Unchanged from Upstream
[Explicit list of features that work exactly as documented upstream]
## Contributing
[Link to CONTRIBUTING.md]
---
## Quick Reference for AI Consumers
```yaml
package_id: string
upstream_version: string
image: string
architectures: [list]
volumes:
volume_name: mount_path
ports:
interface_name: port_number
dependencies: [list or "none"]
startos_managed_env_vars:
- VAR_NAME
actions:
- action-id
```
Sections to Document
Image and Container Runtime
| What to Document | Example |
|---|---|
| Image source | Upstream unmodified, or custom Dockerfile |
| Architectures | x86_64, aarch64, riscv64 |
| Entrypoint | Default or custom |
Volume and Data Layout
| What to Document | Example |
|---|---|
| Volume names | main, data, config |
| Mount points | /data, /config |
| StartOS files | store.json for persistent settings |
| Database | Embedded SQLite vs external |
Installation and First-Run Flow
Document if your package:
- Skips an upstream setup wizard
- Auto-generates credentials
- Pre-configures settings
- Creates tasks for initial setup
Configuration Management
Use a table to clarify the division of responsibility:
| StartOS-Managed | Upstream-Managed |
|---|---|
| Settings controlled via actions/env vars | Settings configurable via app’s own UI/config |
Actions
For each action, document:
- Name: What users see in the StartOS UI
- Purpose: What it does
- Visibility: Visible, hidden, or conditional
- Availability: Any status, only running, only stopped
- Inputs: What users provide
- Outputs: Credentials, confirmation, etc.
Network Interfaces
For each interface:
- Port number
- Protocol (HTTP, SSH, etc.)
- Purpose (UI, API, etc.)
- Access methods (LAN IP, .local, .onion, custom domains)
Backups
- What volumes/data are included
- Data NOT backed up (if any)
- Restore behavior
Health Checks
- Endpoint or method
- Grace period
- Success/failure messages
Limitations
Be explicit about:
- Features that do not work or work differently
- Unavailable configuration options
- Unsupported dependencies
- Version-specific limitations
AI Quick Reference
End every README with a YAML block for machine parsing. This block should contain the package ID, upstream version, image, architectures, volumes, ports, dependencies, managed environment variables, and action IDs.
Pre-Publish Checklist
- Upstream docs linked at the top
- Upstream version stated
- All volumes and mount points documented
- All actions documented with their purpose
- All StartOS-managed settings/env vars listed
- All limitations listed explicitly
- “What Is Unchanged” section included
- YAML quick reference block for AI consumers
- Tested that documented features match actual behavior
-
CONTRIBUTING.mdexists with build instructions
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
.s9pkpackages - Registry — manage packages, categories, signers, and OS versions on a registry