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, scaffolded by start-cli s9pk init-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
- Development Workflow - How to behave while working on a 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 — Set Up Your Packaging Workspace — scaffolds the AI-assisted workspace 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://start9.com/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.
Set Up Your Packaging Workspace
StartOS packaging is designed to be done with an AI coding agent. start-cli scaffolds an AI-ready packaging workspace in one command — a directory that holds the packaging guide and an agent-context file, so any assistant you open there already knows how to build a StartOS package. If you use Claude Code, Start9 recommends the Opus 4.7 or later model.
Create the workspace
start-cli s9pk init-workspace my-workspace
cd my-workspace
This clones the packaging guide into start-docs/, sets up the agent-context files (AGENTS.md, your own AGENTS.local.md, and a CLAUDE.md that loads both), and creates a .startos/ directory that marks the workspace and holds your package-signing key and host/registry config:
my-workspace/
├── .startos/ ← workspace marker: build-key (signs your packages) + config.yaml (hosts, registries)
├── AGENTS.md ← agent context (symlink into start-docs), read by AI assistants
├── AGENTS.local.md ← your own notes, kept across guide updates
├── CLAUDE.md ← loads AGENTS.md + AGENTS.local.md (Claude Code)
└── start-docs/ ← the packaging guide, read locally
The context lives once, at the workspace root — it is never copied into your package repos. Open the workspace in your AI tool and it picks up AGENTS.md / CLAUDE.md automatically.
Create a package
From the workspace root, scaffold a new package:
start-cli s9pk init-package "My Service"
This creates my-service-startos/ (the name is normalized to the package ID) as a barebones, buildable hello-world clone with a TODO.md checklist. Point your agent at that TODO.md and work it top to bottom to take the package from clone to release-ready. Your workspace now looks like:
my-workspace/
├── .startos/
├── AGENTS.md
├── AGENTS.local.md
├── CLAUDE.md
├── start-docs/
└── my-service-startos/ ← your new package
To work on an existing package instead, clone it into the workspace alongside start-docs/.
Hosts and registries
The .startos/config.yaml created with the workspace defines named host targets (your StartOS boxes) and registry targets:
schema: 1
host:
default: https://dev-vm.local
prod: https://prodbox.local
registry:
default: https://alpha-registry-x.start9.com
beta: https://beta-registry.start9.com
prod: https://registry.start9.com
The registry entries are Start9’s, pre-filled; edit the host entries to point at your own boxes.
Any start-cli command takes -H/--host and -r/--registry. Pass a profile name to use one of these entries, or a URL to target something directly:
start-cli -H prod <command> # uses host.prod
start-cli -r beta <command> # uses registry.beta
start-cli -H https://my-box.local <command> # a URL works too
With no flag, the default entry is used. start-cli finds this config by walking up from the current directory, so it works anywhere inside the workspace.
Note
make installandmake publishread a singlehost:/registry:URL from the global~/.startos/config.yamlinstead of these per-workspace profiles. See Makefile.
Keep it current
The guide, the package template, and the agent context all live in start-docs/, so syncing it refreshes everything at once. Pull it at the start of each session:
git -C start-docs pull --ff-only
There’s no separate update command — re-running init-workspace on an existing workspace just fills in anything missing, and your AGENTS.local.md is never touched.
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 a packaging workspace during environment setup, point your agent at the recipe for your first task and let it work from there.
Development Workflow
This page covers how to behave while working on a package — the disciplines that apply to every change, no matter which SDK constructs you touch. The rest of the guide describes what to build; this page describes how to work while building it. These rules are the canonical home for the working discipline an AI coding agent should follow on every task.
Keep README and instructions in sync
README.md and instructions.md are part of the package, not afterthoughts. Any change that affects user-visible behavior — a new or renamed action, an added or removed volume/port/interface/dependency, a changed default, a new feature or limitation — must update both files in the same change.
Apply this loop on every task:
- Make the code change.
- Open
README.mdandinstructions.md. Read what each says about the area you touched. - If either no longer matches the code, update it in the same change.
- If a file is silent on the area and doesn’t need to speak to it, leave it.
Don’t skip step 2 on the theory that a change was “internal.” If you’re unsure whether a change was user-visible, the doc check is the answer: if neither file mentions the area, it was internal; if one does, your change probably affects that file.
See Writing READMEs and Writing Instructions for the content rules.
Iterate with a dirty working tree
start-cli s9pk pack appends a -modified suffix to the version hash when the working tree is dirty. This is purely informational — the .s9pk works exactly the same. Do not commit between test attempts just to get a clean hash.
- Leave the tree dirty while iterating.
- When the package works end-to-end, make one clean commit — not a trail of
fix: X,fix: Y,fix: Zfixup commits. - If you’ve already accumulated fixups during a debug session,
git reset --soft HEAD~Ncollapses them so you can recommit as one.
Pre-existing errors are still errors
If tsc, a test, or the pack step fails — even on something unrelated to your change — the package does not pass. “Pre-existing” is not a pass condition; it is a signal that nobody has fixed the problem yet. Either fix it, or stop and flag it explicitly. Never report a run as green when any check was red.
Don’t create unnecessary version files
Most version bumps edit startos/versions/current.ts in place — change the version and releaseNotes, leave index.ts and the filename alone. A new file is only spun off when the bump carries a migration. See Versions — When to Create a New Version File for the rule, and Release Notes for how to write the notes that accompany a bump.
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 that points to a “set admin password” action — the action generates, stores, and returns the credential on each invocation (first-set + rotation) |
| 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 |
| Run a Nested OCI Runtime | Rootless Podman or Docker inside the service for CI runners, build daemons, sandboxed jobs |
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 |
User Communication
| Recipe | Description |
|---|---|
| Post a Notification to the User | Send a plain or markdown-detailed notification to the StartOS panel when a long-running action finishes or a sync completes |
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(), Value.triState(), 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
Most services need admin credentials before the user can sign in. The standard pattern pairs a setupOnInit watcher with a setAdminPassword action: the watcher surfaces a critical task when no password is stored, and the action — when the user runs it — generates, stores, and returns the credential. The same action handles later rotation.
Solution
In setupOnInit, read the file model where the admin password lives. When it is unset, call sdk.action.createOwnTask() with severity 'critical' pointing to the setAdminPassword action. The action is sdk.Action.withoutInput, visibility: 'enabled' so users can reach it for rotation, and its handler calls utils.getDefaultString(), writes the result to the store, and returns it as a group result (username unmasked + copyable, password masked + copyable).
The shape gives you:
- One source of truth. The action is the only place that generates and stores; the init watcher only decides whether to surface the task.
- Rotation for free. Re-running the action overwrites the stored password and returns the new one — the same action covers first-set and reset.
- Idempotent inits. Task creation is idempotent on its replay key, so
setupOnInitcan run on every container rebuild without spamming tasks.
When the upstream service requires the password to be applied via CLI or API (rather than read from the store at startup), wrap the work in sdk.SubContainer.withTemp() inside the action handler and run the upstream command before returning — see the Reset a Password recipe for the temp-subcontainer shape.
Reference: Initialization · Tasks · Actions
Examples
See startos/init/ and startos/actions/ in: canary (cleanest reference — watchCredentials.ts + setAdminPassword.ts), openclaw (setPassword.ts), vaultwarden (admin-token.ts), bisq, helipad, btcpayserver, lnbits, actual-budget, gitea (uses withInput to also take username/email; generates the password server-side).
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.
The latest version always lives in startos/versions/current.ts. Adding a migration is the one case where you create a new file: rename the existing current.ts to the version it holds (e.g. v2.3.2_1.ts), add that version to other, then write a fresh current.ts carrying the new version and its up/down migration. Bumps that need no migration just edit current.ts in place. See Versions — When to Create a New Version File.
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
Run a Nested OCI Runtime
Some services run their own containers — CI runners (gitea-act-runner, Forgejo Runner, Drone) execute every job inside a fresh OCI container; build daemons (buildkitd) launch sandboxes per build; emulator services pull and run arbitrary images on demand. Without a real container engine inside the service, those workloads can’t be sandboxed properly and the service can’t isolate untrusted user code.
StartOS supports running a rootless OCI engine — Podman or Docker — inside an opt-in service. A nested engine needs two manifest flags: userspaceFilesystems: true exposes /dev/fuse for fuse-overlayfs storage, and virtualNetworking: true exposes /dev/net/tun for slirp4netns/pasta networking. The service’s own LXC remains userns-mapped and AppArmor-confined; nothing about the host’s posture changes.
Solution
- Set both
userspaceFilesystems: trueandvirtualNetworking: trueat the manifest top level — fuse for storage, tun for rootless networking. - Bake the OCI engine and its rootless prerequisites into the service image.
- Add a non-root user and
/etc/subuid//etc/subgidranges that fit inside the subcontainer’s user namespace. - (Docker only) Drop a tiny
runcwrapper into the image and pointdefault-runtimeat it viadaemon.json— Docker injects anet.ipv4.ip_unprivileged_port_startsysctl by default that runc fails to apply across the nested-userns boundary.
Podman works out of the box once the prerequisites are in place. Docker needs the wrapper.
Reference: Manifest · Project Structure
Manifest
import { setupManifest } from '@start9labs/start-sdk'
import { short, long } from './i18n'
export const manifest = setupManifest({
id: 'gitea-runner',
title: 'Gitea Actions Runner',
// ...
volumes: ['main'],
images: {
main: {
source: { dockerBuild: { workdir: '.' } },
arch: ['x86_64', 'aarch64'],
},
},
alerts: { /* ... */ },
dependencies: {},
userspaceFilesystems: true,
virtualNetworking: true,
})
What StartOS provides
With userspaceFilesystems and virtualNetworking set, the per-service LXC gets:
/dev/fuse— char device 10:229, world-RW (viauserspaceFilesystems). Required byfuse-overlayfsfor rootless layered storage. Kernel overlayfs-on-overlayfs is denied for unprivileged users, so fuse-overlayfs is the only viable rootless storage driver inside a userns LXC./dev/net/tun— char device 10:200, world-RW (viavirtualNetworking). Required byslirp4netnsandpastafor rootless container networking.
virtualNetworking additionally grants CAP_NET_ADMIN (scoped to the container’s user namespace). A rootless OCI engine using slirp4netns/pasta doesn’t strictly need it, but the tun device and the capability are bundled under the one flag; the grant is namespaced and harmless here.
Both devices are bind-mounted from the host (via the same machinery that handles hardwareAcceleration for GPU nodes). The host’s fuse and tun kernel modules are auto-loaded at boot.
The host-level sysctls kernel.unprivileged_userns_clone=1 and user.max_user_namespaces=28633 are pinned at install time so unprivileged user-namespace creation is allowed and headroom for nested namespaces is reserved.
Image: Podman
FROM debian:trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
podman fuse-overlayfs uidmap iproute2 iptables \
slirp4netns ca-certificates \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/containers && \
printf 'unqualified-search-registries = ["docker.io"]\n' \
> /etc/containers/registries.conf
# Subordinate UIDs/GIDs for nested user namespaces. The range MUST live
# inside the subcontainer's userns (mapped 0..65535) and MUST NOT overlap
# with the calling user's own UID — kernel rejects uid_map writes with
# EINVAL when outside ranges overlap.
RUN useradd --create-home --uid 1000 --shell /bin/bash app \
&& echo 'app:1001:64535' > /etc/subuid \
&& echo 'app:1001:64535' > /etc/subgid
USER app
WORKDIR /home/app
Inside setupMain, run Podman as app:
podman --root=$HOME/.local/share/containers/storage \
--runroot=$XDG_RUNTIME_DIR/containers \
--cgroup-manager=cgroupfs \
run --network=slirp4netns --rm docker.io/library/alpine echo ok
(--cgroup-manager=cgroupfs is required because there’s no user systemd session inside the subcontainer.)
Image: Docker
Docker rootless needs the same prerequisites — subuid, fuse-overlayfs, slirp4netns — plus one workaround. Docker’s container spec includes a net.ipv4.ip_unprivileged_port_start=0 sysctl by default; runc opens that proc file in the parent userns and re-opens the file descriptor inside the nested userns, where it EPERMs. The kernel itself is fine with the write — unshare -Urn from the same shell can do it — but runc’s reopen-after-pivot pattern breaks under nested userns. Setting --sysctl net.ipv4.ip_unprivileged_port_start=… on the command line doesn’t help: runc still does the reopen.
The fix is a thin runc wrapper that strips that sysctl from the OCI bundle before exec’ing real runc. Drop it in the image, register it as the default runtime in /etc/docker/daemon.json. Skipping the sysctl is harmless — ports < 1024 just stay privileged inside the container, which is the upstream Linux default anyway.
FROM debian:trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl gnupg fuse-overlayfs uidmap iproute2 iptables \
slirp4netns jq ca-certificates \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/debian/gpg \
-o /etc/apt/keyrings/docker.asc \
&& chmod a+r /etc/apt/keyrings/docker.asc \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian trixie stable" \
> /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
docker-ce docker-ce-cli containerd.io docker-ce-rootless-extras \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --uid 1000 --shell /bin/bash app \
&& echo 'app:1001:64535' > /etc/subuid \
&& echo 'app:1001:64535' > /etc/subgid
# runc wrapper — strips the sysctl runc can't apply across the nested
# userns boundary. See https://github.com/Start9Labs/start-os/pull/3209.
COPY runc-nested.sh /usr/local/bin/runc-nested
RUN chmod +x /usr/local/bin/runc-nested
# Tell dockerd to use it as the default runtime.
RUN mkdir -p /etc/docker
COPY daemon.json /etc/docker/daemon.json
runc-nested.sh:
#!/bin/sh
# Strip net.ipv4.ip_unprivileged_port_start from the OCI spec — runc's
# reopen of that sysctl across a nested userns boundary EPERMs in a
# StartOS service subcontainer. Skipping it is harmless; ports < 1024
# just stay privileged inside the container.
set -e
bundle=""
prev=""
for arg in "$@"; do
case "$prev" in --bundle|-b) bundle="$arg"; break;; esac
case "$arg" in --bundle=*) bundle="${arg#--bundle=}"; break;; esac
prev="$arg"
done
cfg="${bundle}/config.json"
if [ -n "$bundle" ] && [ -f "$cfg" ]; then
tmp=$(mktemp "${cfg}.XXXXXX")
jq 'del(.linux.sysctl["net.ipv4.ip_unprivileged_port_start"])' \
"$cfg" > "$tmp" && mv "$tmp" "$cfg"
fi
exec /usr/bin/runc "$@"
daemon.json:
{
"storage-driver": "fuse-overlayfs",
"default-runtime": "runc-nested",
"runtimes": {
"runc-nested": { "path": "/usr/local/bin/runc-nested" }
}
}
Once dockerd is running (rootful, since dockerd-rootless.sh requires the calling user to not be uid 0 and there’s no user systemd session inside the subcontainer), default docker run and docker build work with bridge networking and no extra flags.
Caveats
- No daemon manager. There’s no systemd-user session inside the subcontainer, so engines that prefer systemd cgroups need
--cgroup-manager=cgroupfs(Podman) or run rootful (Docker). Either is fine; just don’t expectloginctl enable-lingeror user-scoped systemd units. - Subordinate-UID range overlap.
/etc/subuid//etc/subgidranges must live inside the subcontainer’s userns (UIDs 0..65535) AND must not overlap with the calling user’s own UID. Withuseradd --uid 1000, the subordinate range must skip 1000 —app:1001:64535works,app:1:65535does not. fuse-overlayfsonly. Kernel overlayfs-on-overlayfs is denied for unprivileged users, so don’t try--storage-driver=overlay2.fuse-overlayfsis the only rootless option.- No bridge IPv6 by default. Rootless networking via slirp4netns is IPv4-only out of the box. If you need IPv6 inside nested containers, configure pasta (
--network=pasta) instead. - The capability flags are independent.
userspaceFilesystems,virtualNetworking, andhardwareAccelerationare orthogonal opt-ins. A nested OCI engine needs the first two; an LLM-driven CI runner that also wants GPU access sets all three.
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.
If the app has no login of its own, gate it at the edge with addSsl.auth instead of building auth into the service — see Authenticating at the Proxy.
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' (the dependency should be running), kind: 'exists' (just installed), a versionRange, and healthChecks listing which of the dependency’s daemons or standalone health checks the user should expect to be passing.
These declarations drive the warning UI StartOS shows the user when a dependency isn’t installed, isn’t running, or has a listed health check failing. They do not gate your service’s startup — your service starts whenever the user starts it, regardless of dependency state. If your service genuinely cannot operate before a dependency reaches a particular state, handle that at runtime in setupMain (poll, retry, or surface your own error); don’t expect the dependency declaration to block startup for you.
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. StartOS always runs the backup with the service stopped — when a backup begins it first stops the service if it was running, performs the backup, then restarts it afterward, but only if it had been running.
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.
Note
Because the service is stopped for the duration of the backup, your backup logic runs against a quiescent volume — nothing is writing to the data while it is copied or dumped. StartOS restarts the service automatically once the backup finishes, but only if it was running when the backup began; a service that was already stopped stays stopped.
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 CLf1040sdI command or calls an API to assess the condition. Return result: 'loading' with a progress message for ongoing work (e.g. syncing), result: 'success' when complete, or result: 'disabled' when the check doesn’t apply. 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.
For checks that call expensive RPCs or APIs, set a trigger to control polling frequency. The default polls every 1 s while pending, which can overload a service doing heavy work (block validation, indexing). Use sdk.trigger.cooldownTrigger(ms) for a fixed interval, or sdk.trigger.statusTrigger({ starting: 5_000, loading: 30_000, ... }) for per-status intervals.
Reference: Main (Health Checks, Polling Triggers) · Dependencies
Examples
See startos/main.ts in: bitcoin-core (sync progress with trigger, 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)
Post a Notification to the User
Surface information to the user in the StartOS notifications panel — the same panel where StartOS shows backup-completion notices, install failures, and similar OS-generated events. Use this sparingly, only for information the user genuinely needs to know about — most commonly that a long-running action has finished (a sync health check that finally passes, a lengthy reindex or migration completing). Notifications are not a changelog feed or an activity log. If you need the user to do something, use a Task instead.
Solution
Call sdk.notification.create(effects, options) from any context that has effects (init, main, an action handler, a health-check body). options is { level, title, message, data? }. Omit data for a plain panel entry; pass markdown text for data to attach a long-form body that the UI renders in a “View Details” modal — use this for a completion summary or a structured error report, not for short status strings. The host attributes the notification to the calling service automatically; a package cannot post on behalf of another package.
Notifications are not idempotent — every call creates a new entry. Gate posts behind a one-shot condition (a flag in your store, a health check flipping to passing, etc.) so a polling loop doesn’t fill the panel.
Reference: Notifications · Tasks (when the user must act)
Examples
See the Notifications reference page for code samples covering the common patterns: a one-shot success notice when a sync completes, and a recoverable-error report with markdown details.
Hosting a Registry
A registry is the server that hosts, indexes, and distributes .s9pk packages and StartOS updates. Anyone can run one. This chapter covers running your own — from installing the packaged service on a StartOS device through day-to-day administration.
StartOS is built around an open registry model: no single entity controls what services are available, and packages can be distributed through any number of independent registries. Running your own makes you a distribution point in that ecosystem — useful for private testing, distributing to a specific audience (friends, customers, an organization), or maintaining packages indefinitely outside Start9’s pipeline. Plenty of packages live this way permanently.
What’s in this chapter
- Setup — install the
startos-registryservice from the marketplace, walk through first-run setup (registry name, first admin, signing keys), and connect a localstart-clito the registry. - Administration — day-to-day tasks: managing signers, publishing and removing packages, organizing categories, registering StartOS releases. Links out to the start-cli registry reference for command details.
When you don’t need to host your own
If you’re publishing through the Start9 Community pipeline, you don’t need your own registry to ship — that pipeline runs registries on your behalf. See Publishing. The two paths aren’t exclusive: developers often run a personal registry for alpha builds while a more stable version is promoted through Start9 Community.
Setup
The packaged startos-registry service is the supported way to run a registry. Install it from any registry that carries it (the Start9 registry does), complete two first-run actions, and you’re ready to publish.
1. Install the service
From StartOS, open the Marketplace, find StartOS Registry, and install. The service has no external dependencies. Once installed, start it.
On first install, StartOS surfaces two setup tasks under the service. Both must be completed before the registry is usable.
2. Configure Registry
Run the Configure Registry action to set the registry’s display name (max 32 characters) and an optional icon. This is what users will see when they browse your registry from another StartOS device.
The registry’s hostnames, listen address, Tor proxy, and data directory are managed by StartOS automatically — you don’t configure them by hand. As the service’s network addresses change (e.g. you add a clearnet domain to the API interface), the configured hostnames update to match.
3. Add the first administrator
Run Add Administrator to register the first admin. You’ll need a PEM-encoded Ed25519 public key, a label, and contact info (email or Matrix handle). Admins can manage signers, publish packages, register OS versions, and edit categories.
To generate a key on your workstation:
start-cli init-key
start-cli pubkey
init-key creates an Ed25519 keypair at ~/.startos/developer.key.pem (or /run/startos/developer.key.pem if running on a StartOS device). pubkey prints the public half — that’s what you paste into the Public Key field of the Add Administrator action.
Treat the private key like an SSH key: it authenticates every admin and publish action you take against the registry. Back it up.
4. Point start-cli at the registry
All registry operations go through start-cli registry (or start-cli s9pk publish for uploading packages). Pass the registry’s URL with --registry:
start-cli registry --registry https://my-registry.example.com index
If you’ll be running many commands against the same registry, set the URL once via ~/.startos/config.yaml:
registry-url: https://my-registry.example.com
…and drop the flag.
5. Smoke-test
Confirm the service is reachable and your admin credentials work:
start-cli registry index
start-cli registry admin list
The first lists registry metadata and packages (empty on a fresh install). The second should show the administrator you added in step 3. If either fails, check that the service is running, the API interface is reachable from your workstation, and your developer key matches the public key you registered.
You’re now ready to add signers, publish packages, and register StartOS versions. See Administration.
Administration
Day-to-day registry administration happens through start-cli registry from an admin’s workstation. This page walks through the common tasks; for full command syntax see the start-cli registry reference.
All commands below assume your start-cli is pointed at your registry — either via --registry <url> on each invocation or via registry-url in ~/.startos/config.yaml. See Setup if you haven’t configured that yet.
Signers
A signer is a public key authorized to publish a specific package, OS version, or asset. Admins are themselves signers — when you added the first admin, you registered a signer identity with admin privileges.
Register a new signer (without admin rights):
start-cli registry admin signer add \
--name "Alice" \
--contact "alice@example.com" \
--key "$(cat alice.pub.pem)"
The registry returns the signer’s ID. Use that ID with start-cli registry admin signer edit to update contact info or keys, or start-cli registry admin signer list to see everyone registered.
To grant a signer admin privileges (or revoke them), use start-cli registry admin add <SIGNER_ID> / ... admin remove <SIGNER_ID>.
Packages
Authorizing a signer for a package
Before a non-admin signer can publish a package, an admin (or an already-authorized signer for the same package) must scope them to it:
start-cli registry package signer add <PACKAGE_ID> <SIGNER_ID> \
--versions ">=1.0.0"
--versions is a version range — Alice can publish any version in the range you grant her. Admins can publish any package at any version without an explicit scope.
Publishing a package
From the directory containing the .s9pk:
start-cli s9pk publish \
--url https://my-registry.example.com \
myservice_1.2.0_x86_64.s9pk
publish signs the .s9pk with the local developer key, uploads it to the registry, and registers it in the index. If your registry already has the same (package id, version, sighash) indexed, the upload is a no-op except for any new signatures merging in.
Removing a package
Remove a specific version:
start-cli registry package remove <PACKAGE_ID> <VERSION>
Remove an entire package (all versions):
start-cli registry package remove <PACKAGE_ID>
The second form refuses to run if the package has versions, unless you pass --force.
Mirrors
A mirror is an alternate download URL for the same .s9pk. The registry indexes mirrors per-version; downloads try mirrors in order until one succeeds.
start-cli registry package add-mirror <S9PK_FILE> <MIRROR_URL>
start-cli registry package remove-mirror <PACKAGE_ID> <VERSION> --url <MIRROR_URL>
You can’t remove the last remaining URL for a package — every indexed version needs at least one reachable URL.
Categories
Categories are flat tags that group packages in the marketplace UI. Create and assign:
start-cli registry package category add bitcoin "Bitcoin"
start-cli registry package category add-package bitcoin <PACKAGE_ID>
A package can be in multiple categories. start-cli registry package category list enumerates them.
StartOS versions
If your registry distributes StartOS images (not just service packages), register each release so devices can find upgrade paths:
start-cli registry os version add \
<VERSION> \
<HEADLINE> \
<RELEASE_NOTES> \
<SOURCE_VERSION_RANGE>
<SOURCE_VERSION_RANGE> is a version range describing which prior OS versions can upgrade to this one. After registering the version, upload the install images:
start-cli registry os asset add <FILE> <URL> \
--platform x86_64 --version <VERSION>
Repeat per platform (x86_64, aarch64, riscv64) and per asset type (img, iso, squashfs).
Inspecting the registry
start-cli registry index # registry metadata + every package
start-cli registry package index # packages and categories only
start-cli registry os index # OS versions
start-cli registry admin list # admins
start-cli registry admin signer list # all signers
All listing commands accept --format json for machine-readable output.
Low-level database access
For debugging or scripted recovery, you can read and patch the registry’s patch-db directly:
start-cli registry db dump -p /index/package/packages
start-cli registry db apply '<jq-style expression>'
These are powerful and easy to misuse — there’s no schema validation on apply. Prefer the higher-level commands above unless you’re recovering from a bug.
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/
│ ├── build.yml # CI build on PR
│ ├── tagAndRelease.yml # Version check, tag, and release on merge
│ └── release.yml # Release on manual 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 (optional - not in barebones scaffold)
│ ├── main.ts # Daemon runtime and health checks
│ ├── sdk.ts # SDK initialization (boilerplate)
│ ├── utils.ts # Package-specific utilities (empty in barebones scaffold)
│ └── versions/ # Version management and migrations
├── .gitignore
├── AGENTS.md # Pointer for AI coding agents — read CONTRIBUTING.md first
├── CLAUDE.md # One-line `@AGENTS.md` import for Claude Code
├── CONTRIBUTING.md # Doc-sync rule, environment setup, build, CI/CD, contribution flow
├── Dockerfile # Optional - for custom images
├── icon.svg # Service icon (max 40 KiB)
├── instructions.md # User-facing instructions packed into the .s9pk (see Writing Instructions)
├── 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)
├── TODO.md # Pending work on the package
├── tsconfig.json
├── UPDATING.md # Per-package upstream-version tracking
└── 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 three GitHub Actions workflows that delegate to shared-workflows. The CI pipeline has two automatic stages, plus an optional manual path:
PR opened/updated ──> Build
PR merged to master ──> Version check ──> Tag ──> Build ──> Release ──> Publish
Manual tag push ──> Build ──> Release ──> Publish (bypasses version check)
Tags created by GitHub Actions (via GITHUB_TOKEN) do not trigger other workflows. The tag pushed by tagAndRelease will not trigger the standalone release.yml — instead, tagAndRelease calls release directly as a reusable workflow. The standalone release.yml only runs when a tag is pushed manually.
build.yml – builds the .s9pk on PR to verify it compiles:
name: Build
on:
workflow_dispatch:
pull_request:
branches: ["master"]
paths-ignore: ["*.md"]
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/build.yml@master
secrets:
DEV_KEY: ${{ secrets.DEV_KEY }}
tagAndRelease.yml – on merge to master, checks the version against the production registry. If the version already exists, the workflow exits gracefully without building. Otherwise, creates a release tag, then builds and publishes to the test registry. If a new commit arrives while a previous run is still in progress, the old run is cancelled:
name: Tag and Release
on:
push:
branches: ["master"]
paths-ignore: ["*.md"]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
tag-and-release:
uses: start9labs/shared-workflows/.github/workflows/tagAndRelease.yml@master
with:
REFERENCE_REGISTRY: ${{ vars.REFERENCE_REGISTRY }}
RELEASE_REGISTRY: ${{ vars.RELEASE_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
release.yml – publishes on manual tag push, for re-releases or testing. This workflow only triggers on manually pushed tags — tags created by tagAndRelease (via GITHUB_TOKEN) do not trigger it:
name: Release
on:
push:
tags:
- "v*.*"
jobs:
release:
uses: start9labs/shared-workflows/.github/workflows/release.yml@master
with:
RELEASE_REGISTRY: ${{ vars.RELEASE_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
AGENTS.md and CLAUDE.md
These two files are pointers, not content. Generic packaging knowledge — SDK patterns, the disciplines on the Development Workflow page, the rules throughout this guide — lives in one canonical place: the packaging guide. It is not copied into each package repo, where 40+ duplicates would drift out of sync. AGENTS.md and CLAUDE.md only identify the repo and point an agent at the repo’s own CONTRIBUTING.md.
AGENTS.md is a short, repo-identical pointer: it states that this is a StartOS service-package repo and tells any AI coding agent to read CONTRIBUTING.md (and the documents it links) before doing anything. Keep it to a few lines; carry no substantive rules here, and do not turn it into a web-fetch driver — don’t instruct the agent to pull guide pages over the web up front. Developers are expected to work with the guide checked out locally alongside the package (see Environment Setup). The local-first navigation — read start-docs/packaging/src/ directly, fall back to https://docs.start9.com/packaging only when no local copy exists — is set up once by the workspace-level CLAUDE.md, not repeated per repo.
CLAUDE.md is a one-line import of that same file:
@AGENTS.md
Claude Code auto-loads CLAUDE.md when it opens the repo, and the @AGENTS.md import pulls in the pointer so the same entry point covers both Claude and any other agent that reads AGENTS.md by convention. Don’t duplicate anything into CLAUDE.md; keep the content in AGENTS.md and let the import do the work.
CONTRIBUTING.md
The per-repo home for how to work in this repo — the one committed file that travels with the package and carries its contribution workflow, for both humans and AI agents. Keep it thin and link-heavy: state the repo-specific facts inline (the package’s own files, its CI workflow names, its build command) and link out to the packaging guide for everything generic, rather than restating it. Because this file is committed and browsable on GitHub, those links use the public docs.start9.com URLs; they are on-demand references (followed when a task needs them, resolved against the local checkout first), not pages to fetch up front. It contains:
- Keep these in sync — a doc-sync pointer naming
README.md,instructions.md, andTODO.md, with the rule “Read all three before starting any work” and the requirement that any code change affecting user-visible behavior updatesREADME.mdandinstructions.mdin the same change. - Environment setup — links to Environment Setup.
- Building —
npm ci && make, linking to the Makefile reference. - Updating the upstream version — points at the package’s
UPDATING.mdfor per-package bump steps, and references Versions for the rule on when to create a new version file versus renaming the existing one in place. - CI/CD — a short summary of the three workflows under
.github/workflows/(see above) and the manual promotion tobeta/prod. - How to contribute — fork, branch from
master, open a PR back tomaster.
Match the template that every existing package follows — copy CONTRIBUTING.md from a recent package (e.g. hello-world-startos) and adjust the per-package details.
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.
instructions.md
User-facing instructions packed into the .s9pk and rendered on the Instructions tab in StartOS after install. Required at the package root — the build fails if missing. See Writing Instructions for what belongs in this file (and what does not).
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.
TODO.md
A running list of pending work on this package. Add items when you defer work; remove them when complete. An empty TODO.md (just the # TODO heading) is fine — keep the file present so contributors know where to record items.
UPDATING.md
Per-package upstream-version tracking. Each package wraps one or more upstream sources (a Docker image, a git submodule, a Start9-built image), and the exact registry, tag format, and pinned field differs. UPDATING.md captures that detail so a bump can be applied without rediscovering it each time.
It has two sections:
- Determining the upstream version — for each upstream this package pulls, the canonical place to find the latest version (e.g.
gh release view -R <org>/<repo> --json tagName -q .tagName, a Docker Hub tags listing, etc.) and the manifest field where the current pin lives (typicallyimages.<name>.source.dockerTaginstartos/manifest/index.ts). - Applying the bump — the exact file and field to edit, including any tag-format quirks (e.g. drop the leading
v, append-alpine, keep the major version aligned with a sibling image).
Packages with multiple upstream sources (e.g. a service plus its database sidecar) get one subsection per source under each heading. CONTRIBUTING.md’s “Updating the upstream version” section points here for the per-package detail and adds the cross-cutting rule about renaming the file in startos/versions/ versus creating a new one.
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 (optional) |
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 (optional)
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.
The barebones scaffold ships no interfaces.ts — many services (background workers, sidecars) expose nothing on the network. When a service does, add this file and wire its setInterfaces into init/index.ts (conventionally before setDependencies).
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
├── current.ts # The latest version — always this filename
└── v1.0.2_0.ts # A historical version kept for its migration
In the versions/ directory, you manage package versions and define migration logic. The latest version always lives in current.ts; historical versions kept for migrations sit beside it under version-named files. 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,
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 |
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
Hardware requirements and variants
A package that targets several accelerators (NVIDIA, AMD, CPU-only, …) ships one variant per accelerator: a separate .s9pk built with a different VARIANT in the Makefile (see Makefile), all published under a single version. The manifest reads process.env.VARIANT to pick per-variant settings, including hardwareRequirements.device — a list of device filters telling StartOS which hardware a variant needs:
const variant = process.env.VARIANT || 'cpu'
// inside setupManifest({ ... })
hardwareRequirements: {
device:
variant === 'nvidia'
? [{ class: 'display', product: null, vendor: null, driver: 'nvidia', description: 'An NVIDIA GPU' }]
: variant === 'rocm'
? [{ class: 'display', product: null, vendor: null, driver: 'amdgpu', description: 'An AMD GPU' }]
: [], // cpu: runs anywhere
},
The registry stores a version’s variants together and disambiguates them by hardware requirement — on a given machine StartOS offers the variant whose requirement the detected hardware satisfies.
Warning
Every variant must declare a distinct hardware requirement, and at most one variant may have an empty requirement (
[], the catch-all fallback). Two variants presenting the same requirement — most often two with an emptydevicearray — collide when the second is published, and the registry rejects it:Invalid Request: package.add: package metadata mismatch: remove the existing version first, then re-addIn particular an
nvidiavariant must carry an NVIDIAdevicefilter, not[]—nvidiaContainer: truewires up the GPU runtime but does not set a hardware requirement, so without the filter the NVIDIA variant is indistinguishable from the CPU fallback and one of the two fails to publish.
Virtual Networking (VPN / kernel tun interfaces)
For services that bring up their own kernel tunnel interface — VPNs, WireGuard, or any tun-class workload — set virtualNetworking: true at the manifest top level:
virtualNetworking: true,
When set, StartOS exposes /dev/net/tun inside the service’s container and grants CAP_NET_ADMIN (scoped to the container’s user namespace) so the service can create and configure tunnel interfaces. This is a meaningful privilege escalation — enable it only when the service genuinely needs a kernel tunnel interface.
Nested OCI Runtimes (Docker / Podman inside a service)
For services that need to run their own OCI containers — e.g. CI runners like gitea-act-runner that spawn build containers per job — set both userspaceFilesystems and virtualNetworking at the manifest top level:
userspaceFilesystems: true, // /dev/fuse for fuse-overlayfs storage
virtualNetworking: true, // /dev/net/tun for slirp4netns / pasta networking
userspaceFilesystems exposes /dev/fuse so a rootless engine (Podman or Docker) can use fuse-overlayfs for layered storage. virtualNetworking exposes /dev/net/tun so it can use slirp4netns (or pasta) for networking (and also grants CAP_NET_ADMIN). Both are opt-in. Service authors are still responsible for installing the OCI engine in the image and configuring it for rootless mode — see Run a Nested OCI Runtime for the full recipe (subuid setup, daemon configuration, and the runc wrapper required when using Docker).
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>
| 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 |
Note
ExVer allows a prerelease suffix on the downstream revision too (e.g.
:0-beta.0), but Start9 packages don’t use it — the downstream revision is always a plain integer. Prerelease suffixes appear only on the upstream side, when wrapping an upstream alpha/beta/rc.
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 |
26.0.0-rc.1:0 | 26.0.0-rc.1 | 0 |
0.13.5:0 | 0.13.5 (stable) | 0 |
2.3.2:1 | 2.3.2 (stable) | 1 |
Version Ordering
Versions are compared by:
- Upstream version (most significant)
- Upstream prerelease (stable > rc > beta > alpha)
- Downstream revision
Example ordering (lowest to highest):
1.0.0-alpha.0:01.0.0-beta.0:01.0.0-rc.0:01.0.0:0(fully stable)1.0.0:11.1.0: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.
Version Consistency Checklist
Ensure these all match for upstream version X.Y.Z:
- The current version lives in
startos/versions/current.ts - Version string matches:
version: 'X.Y.Z: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
The latest version always lives in startos/versions/current.ts. The filename never changes as you bump — only its contents do. Historical versions that a migration needs to upgrade from are kept as version-named files alongside it.
startos/versions/
├── index.ts # VersionGraph: imports current, lists historical versions in `other`
├── current.ts # The latest version (always this filename)
├── v1.0.0_0.ts # Historical version 1.0.0:0, kept because a later migration upgrades from it
└── v1.1.0_0.ts # Historical version 1.1.0:0, ditto
A brand-new package has only index.ts and current.ts — no historical files until a migration forces one out (see When to Create a New Version File).
current.ts Template
current.ts exports its VersionInfo under the stable name current. Keeping the export name fixed is what makes an in-place bump touch only this file — index.ts never changes.
import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk'
export const current = VersionInfo.of({
version: 'X.Y.Z: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 { current } from './current'
export const versionGraph = VersionGraph.of({
current,
other: [], // Add historical versions here so migrations run when upgrading through them
})
Historical Version File Naming
When a migration forces a version out of current.ts (see below), the spun-off file is named after the version it holds, in the same form as its git tag: prefix with v, replace the : with _, and add .ts. The upstream portion keeps its dots; prerelease suffixes are left as-is.
| Version | Filename |
|---|---|
26.0.0:0 | v26.0.0_0.ts |
26.0.0-rc.1:0 | v26.0.0-rc.1_0.ts |
2.3.2:1 | v2.3.2_1.ts |
A historical file’s export is renamed to match the version, with every ., :, and - becoming _ — e.g. 2.3.2:1 → v_2_3_2_1. Only current.ts uses the stable current export.
Incrementing Versions
When to Create a New Version File
The deciding question is does this bump need a migration?
No migration (the common case): bump current.ts in place. Edit version and releaseNotes in startos/versions/current.ts and you’re done. Don’t rename the file, don’t touch the export name, don’t touch index.ts, leave other as it is. Git history of current.ts preserves the prior release notes automatically, so there is no separate “keep the old notes” step.
Migration needed: spin the old version off, then write a fresh current.ts.
- Rename
current.tsto the version it currently holds — e.g.v2.3.2_1.ts(see Historical Version File Naming), and rename its export fromcurrentto the matchingv_2_3_2_1. - Add that historical version to the
otherarray inindex.tsso its migration runs when users upgrade through it. - Create a new
startos/versions/current.tsexportingcurrentwith the new version string, release notes, and theup/downmigration.
This keeps versions/ lean: only versions that a migration upgrades from survive as their own files; everything else is just the latest state of current.ts.
Upstream Update
When the upstream project releases a new version:
- Update git submodule to new tag
- Update
dockerTagin manifest/index.ts - Update
current.tsto the new upstream version (spin off a historical file only if the bump needs a migration — see above) - 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
- Apply the migration rule — most wrapper-only bumps need no migration, so just edit
current.tsin place
Release Notes
releaseNotes renders as markdown in the StartOS UI. Match the length to the content — if it fits on one line, write one line. A single-change release doesn’t need bullets, headers, or a backtick template literal. Reach for bullets when there are several items in one category; add bold section headers (**Bumps**, **Features**, **Fixes**, **Internal**) only when the release spans more than one category. Localize the headers in every locale; don’t leave them in English.
// One change: plain string.
releaseNotes: { en_US: 'Bumps Ghost → 6.38.0.', /* …other locales */ },
// Several items, one category: bullets, no header.
releaseNotes: {
en_US: `- Ghost → 6.38.0
- MySQL → 8.4.9`,
// …
},
// Multiple categories: headers + bullets.
releaseNotes: {
en_US: `**Bumps**
- Ghost → 6.38.0
**Fixes**
- Crash on backup restart.`,
// …
},
Use a template literal (backticks) only when the note actually spans multiple lines, and never indent its content lines.
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.',
})
})
Git Tag Conventions
Releases are published via git tags. The StartOS tag format is:
v{upstream_version}[-upstream-prerelease]_{wrapper_revision}
| Package version | Git tag |
|---|---|
26.0.0:0 | v26.0.0_0 |
26.0.0-rc.1:0 | v26.0.0-rc.1_0 |
0.13.5:2 | v0.13.5_2 |
Conventions:
- Underscore between upstream and wrapper. The
:from the version string becomes_in the tag — tags can’t contain colons. - No package-name prefix. The tag is just the version, not
myservice-v26.0.0_0. - Keep the upstream prerelease suffix (
-alpha.N/-beta.N/-rc.N) when wrapping an upstream prerelease — it stays inline in the upstream portion. The downstream revision is always a plain integer with no suffix. - Push tags individually (
git push origin <tag>), not withgit push --tags.
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.
Convention: Inline SubContainer.of()
When a subcontainer is only used by one daemon, inline the SubContainer.of() call directly inside addDaemon() rather than extracting it into a separate variable. Only extract to a variable when the same subcontainer is reused across multiple daemons, oneshots, or exec calls. See the basic example at the top of this page.
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(effects, 8080, {
successMessage: i18n('Ready'),
errorMessage: i18n('Starting...'),
}),
gracePeriod: 30_000, // optional: treat failures as "starting" for this long (ms)
},
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,
)
Health Check Result States
The fn returns an object with result and message:
| Result | Meaning | When to use |
|---|---|---|
success | Healthy and fully operational | Service is ready and serving |
loading | Operational but catching up | Syncing blocks, indexing data |
disabled | Intentionally inactive | Feature excluded by config (e.g. onlynet) |
starting | Not yet ready | Still initializing (also set automatically during gracePeriod) |
failure | Unhealthy | Process crashed, port not listening, dependency unreachable |
loading and failure require a message string. Other states accept an optional message.
Built-in Health Check Helpers
Available on sdk.healthCheck:
checkPortListening(effects, port, { successMessage, errorMessage })— checks if a TCP/UDP port is bound by reading/proc/net. Lightweight, no network I/O. Preferred for daemon readiness checks.checkWebUrl(effects, url, { successMessage, errorMessage })— fetches a URL, succeeds on any HTTP response.runHealthScript(command, subcontainer, { errorMessage })— runs a command in a subcontainer, succeeds on exit code 0.
Polling Triggers
By default, health checks poll every 1 s while the daemon is pending, then every 30 s once it reports a non-pending result (success, loading, or disabled). Override this with the trigger option on ready:
ready: {
display: i18n('Sync Progress'),
trigger: sdk.trigger.cooldownTrigger(30_000), // fixed 30s interval
fn: async () => { /* ... */ },
}
Available triggers on sdk.trigger:
cooldownTrigger(ms)— fixed interval between checks, regardless of status.statusTrigger(defaultMs, { success?, loading?, disabled?, starting?, waiting?, failure? })— per-status polling intervals in milliseconds. The first argument is the default interval for any status not explicitly listed.
Use a slower trigger for expensive checks (RPC calls during heavy processing) to reduce load on the service:
trigger: sdk.trigger.statusTrigger(30_000, {
starting: 5_000,
failure: 5_000,
}),
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
});
Warning
sdk.Mountsis an immutable builder. EverymountVolume/mountAssets/mountDependency/mountBackupscall returns a newMountsinstance — the original is unchanged. Discarded return values silently drop the mount.// BROKEN — conditional mount is lost const mounts = sdk.Mounts.of().mountVolume({ /* ... */ }); if (needsCookie) { mounts.mountDependency({ /* ... */ }); // ← return value discarded } // CORRECT — reassign each time let mounts = sdk.Mounts.of().mountVolume({ /* ... */ }); if (needsCookie) { mounts = mounts.mountDependency({ /* ... */ }); }Chained calls (
.mountVolume(...).mountDependency(...)) are fine — the returned instance flows into the next call. The trap is conditional mutation with the return thrown away. Symptom: the file you expected at the mountpoint isn’t there, so aFileHelper.string(...).read()returnsnullor a subcontainer read fails.
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 internal secrets, seed file-model defaults, create critical tasks for user setup actions, bootstrap via API |
'update' | After a package version upgrade | Re-apply config, handle post-migration setup |
'restore' | Restoring from backup | Re-register triggers; credentials are already present from the restored store |
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. Internal-only secrets (DB password, JWT secret, etc.) are generated here, because no user interaction is involved:
export const initializeService = sdk.setupOnInit(async (effects, kind) => {
if (kind !== 'install') return
// Internal secret consumed by setupMain — never shown to the user
await storeJson.merge(effects, {
jwtSecret: utils.getDefaultString({ charset: 'a-z,A-Z,0-9', len: 64 }),
})
})
User-facing admin credentials follow a different pattern — see Watch State and Prompt below.
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 — e.g. re-register a webhook with an
// upstream service that was issued against a hostname that may have changed.
await registerWebhook(effects)
})
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') {
await storeJson.merge(effects, {
jwtSecret: utils.getDefaultString({ charset: 'a-z,A-Z,0-9', len: 64 }),
})
}
})
Watch State and Prompt (the admin-credentials pattern)
For state the user owns — admin passwords, API tokens, primary URL — pair a setupOnInit watcher with an action. The watcher reads the store and, when the field is unset, surfaces a critical task pointing to the action. The action handles generation, storage, and display, so first-set and later rotation share one code path.
// init/watchCredentials.ts
import { setAdminPassword } from '../actions/setAdminPassword'
import { storeJson } from '../fileModels/store.json'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
export const watchCredentials = sdk.setupOnInit(async (effects) => {
const store = await storeJson.read().const(effects)
if (!store?.adminPassword) {
await sdk.action.createOwnTask(effects, setAdminPassword, 'critical', {
reason: i18n('Set the admin password before signing in'),
})
}
})
The matching setAdminPassword action lives in startos/actions/ and looks like:
// actions/setAdminPassword.ts
import { utils } from '@start9labs/start-sdk'
import { storeJson } from '../fileModels/store.json'
import { i18n } from '../i18n'
import { sdk } from '../sdk'
export const setAdminPassword = sdk.Action.withoutInput(
'set-admin-password',
async () => ({
name: i18n('Set Admin Password'),
description: i18n(
'Generate a new random password for the admin account. Replaces any existing password.',
),
warning: null,
allowedStatuses: 'any',
group: null,
// `'enabled'` keeps the action reachable from the Actions tab so the user
// can rotate the password later.
visibility: 'enabled',
}),
async ({ effects }) => {
const adminPassword = utils.getDefaultString({
charset: 'a-z,A-Z,0-9',
len: 32,
})
await storeJson.merge(effects, { adminPassword })
return {
version: '1',
title: i18n('Login Credentials'),
message: i18n('Use these credentials to sign in.'),
result: {
type: 'group',
value: [
{
type: 'single',
name: i18n('Username'),
description: null,
value: 'admin',
masked: false,
copyable: true,
qr: false,
},
{
type: 'single',
name: i18n('Password'),
description: null,
value: adminPassword,
masked: true,
copyable: true,
qr: false,
},
],
},
}
},
)
If the upstream service needs the password applied via CLI or API rather than just read from the store at startup, wrap the work in sdk.SubContainer.withTemp() inside the action handler — see the Reset a Password recipe.
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 — typically when state init detects is missing:
await sdk.action.createOwnTask(effects, setAdminPassword, 'critical', {
reason: i18n('Set the admin password before signing in'),
})
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) return
// Reached only on install/update/restore — skips container rebuild.
// No check: runs on ALL init types (install, update, restore, container rebuild)
})
Tip
if (!kind) returnis the common guard for “install, update, or restore — but not a plain container rebuild.” The inverse (if (kind) return) would mean “only on rebuild” — almost never what you want.
Empty-Seed Inits: Drop the kind Parameter
When a setupOnInit does nothing but seed file models with their schema defaults (fileModel.merge(effects, {})), drop the kind parameter entirely — the overhead of running on every init is negligible, and it keeps the logic trivially correct:
// init/seedFiles.ts
export const seedFiles = sdk.setupOnInit(async (effects) => {
await storeJson.merge(effects, {})
await configToml.merge(effects, {})
})
Reach for the kind check only when the body needs to behave differently between install / update / restore / rebuild.
Note
Always use
merge()(notwrite()) to seed file models, even on first install. With every key in your zod schema carrying a.catch(),merge(effects, {})is enough to create the file and populate every default. See File Models — Prefer merge() Over write().
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. |
addSsl.auth | ProxyAuth | null | Optional auth gate enforced by the OS reverse proxy. See Authenticating at the Proxy. |
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.
TLS Termination
StartOS terminates TLS at the platform edge and proxies plain HTTP to your container. This has two important consequences any time your service generates URLs or makes scheme decisions:
1. Inside the container, every request arrives over HTTP. A reverse proxy like nginx will see $scheme == "http", the X-Forwarded-Proto header is not authoritative by default, and there is no TLS certificate to terminate. Do not configure in-container HTTPS — StartOS is already doing it.
2. The browser loaded the page over https://. Any URL your service emits for the browser to consume (login redirects, API endpoints in a config.json, OAuth callbacks, absolute links in HTML) must use https://. If you emit http:// or derive the scheme from $scheme, the browser will block the request as mixed active content.
Hardcode https:// for browser-facing URLs rather than interpolating $scheme or reading the protocol from the incoming request:
# BAD — $scheme is always "http" inside the container
return 200 '{"api_url":"$scheme://$host/api"}';
# GOOD — match what the browser actually sees
return 200 '{"api_url":"https://$host/api"}';
This applies to any configuration file generated in setupMain or any runtime response that includes absolute URLs — not just nginx. When in doubt, hardcode https://.
Authenticating at the Proxy
For protocols that StartOS fronts with its reverse proxy (http, https, ws, wss), you can gate an interface with HTTP authentication by setting addSsl.auth. The OS reverse proxy validates the Authorization header on every incoming request before forwarding it to your container. Requests that fail get 401 Unauthorized with a WWW-Authenticate challenge and never reach your service. You do not need to build auth into the service or run a sidecar proxy — the platform enforces it at the edge.
auth takes a ProxyAuth, which is one of two shapes:
// Basic — one or more username/password pairs; any match passes
const uiOrigin = await uiMulti.bindPort(uiPort, {
protocol: 'http',
addSsl: {
auth: {
type: 'basic',
credentials: [{ username: 'admin', password }],
realm: null, // advertised in the WWW-Authenticate challenge; defaults to "StartOS"
},
},
})
// Bearer — any of the listed tokens is accepted as `Authorization: Bearer <token>`
const apiOrigin = await apiMulti.bindPort(apiPort, {
protocol: 'https',
addSsl: {
auth: { type: 'bearer', tokens: [apiToken], realm: null },
},
})
ProxyAuth field | Type | Description |
|---|---|---|
type | 'basic' | 'bearer' | The auth scheme the proxy enforces. |
credentials (basic) | Array<{ username, password }> | Accepted pairs. Any match passes. The matched username is forwarded upstream as X-Forwarded-User. |
tokens (bearer) | Array<string> | Accepted bearer tokens. Any match passes. |
realm | string | null | Realm advertised in the 401 WWW-Authenticate challenge. Defaults to "StartOS". Use a stable realm across bindings that share credentials so browsers reuse them. |
Setting auth implies HTTP-aware proxying, so it is only valid on the SSL-variant protocols above — not on raw TCP (protocol: null).
Note
The
usernamefield oncreateInterfaceis unrelated to this gate — it only embeds a username in the displayed URL (e.g.https://user@host/). The enforced credential check isaddSsl.auth.
Generating and rotating credentials
Don’t hard-code the password. Generate it at install time and let the user rotate it through an action. Store the credential in a file model such as store.json and read it reactively in setupInterfaces — when the action rewrites the stored value, setupInterfaces re-runs and the proxy picks up the new credential automatically:
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
const password = await storeJson.read((s) => s.uiPassword).const(effects)
const uiMulti = sdk.MultiHost.of(effects, 'ui-multi')
const uiOrigin = await uiMulti.bindPort(uiPort, {
protocol: 'http',
addSsl: {
auth: { type: 'basic', credentials: [{ username: 'admin', password }], realm: null },
},
})
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: {},
})
return [await uiOrigin.export([ui])]
})
Seed uiPassword with a generated value during install init so the gate is active from first start, and pair it with a reset-password action that rewrites the stored value and surfaces it to the user once. See Reset Password.
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 does its work and returns a result to display. The canonical “set admin password” action generates a random password, writes it to the store, and returns the new credential — the same action serves first-set (surfaced by a critical task on install) and later rotation:
import { utils } from "@start9labs/start-sdk";
import { i18n } from "../i18n";
import { sdk } from "../sdk";
import { storeJson } from "../fileModels/store.json";
export const setAdminPassword = sdk.Action.withoutInput(
// ID
"set-admin-password",
// Metadata
async () => ({
name: i18n("Set Admin Password"),
description: i18n(
"Generate a new random password for the admin account. Replaces any existing password.",
),
warning: null,
allowedStatuses: "any", // 'any', 'only-running', 'only-stopped'
group: null,
visibility: "enabled", // 'enabled', 'disabled', 'hidden'
}),
// Handler
async ({ effects }) => {
const adminPassword = utils.getDefaultString({
charset: "a-z,A-Z,0-9",
len: 32,
});
await storeJson.merge(effects, { adminPassword });
return {
version: "1",
title: i18n("Login Credentials"),
message: i18n("Use these credentials to sign in."),
result: {
type: "group",
value: [
{
type: "single",
name: i18n("Username"),
description: null,
value: "admin",
masked: false,
copyable: true,
qr: false,
},
{
type: "single",
name: i18n("Password"),
description: null,
value: adminPassword,
masked: true,
copyable: true,
qr: false,
},
],
},
};
},
);
The action is paired with a setupOnInit watcher that surfaces a critical task when no password is stored — generation, storage, and display all live in this one handler, so first-set and rotation share a single code path. See Prompt User to Create Admin Credentials.
Registering Actions
All actions must be registered in actions/index.ts:
import { sdk } from "../sdk";
import { setAdminPassword } from "./setAdminPassword";
export const actions = sdk.Actions.of().addAction(setAdminPassword);
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
The standard shape for password actions: the handler generates the password with utils.getDefaultString(), writes it where the service reads it from, and returns it as a masked, copyable result. Server-side generation produces strong passwords and means the same action covers first-set and rotation. The primary example above (setAdminPassword) is the canonical shape — see also the Reset a Password recipe for variants that apply the new password through the upstream service’s CLI or API.
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.
Conventions
Wrap User-Facing Strings in i18n()
Every string that a user will see — action name, description, warning, reason on tasks, messages on health checks and action results — must be wrapped in i18n(). Raw strings bypass translation and leak English into non-English locales. The existing examples on this page illustrate the pattern: name: i18n('Configure SMTP'), not name: 'Configure SMTP'.
Thrown errors are the exception. throw new Error(...) messages are developer-facing diagnostics that surface in logs and stack traces, not translated UI copy — leave them as plain strings and do not wrap them in i18n().
Mirror File-Model Keys in InputSpec When Appropriate
When an action’s job is “set these fields on this file-model section,” name the InputSpec keys to match the file-model keys exactly — same casing, same spelling. The prefill and handler collapse to one-liners:
// fileModels/config.json uses uppercase snake_case keys under MEMPOOL
const spec = InputSpec.of({
BLOCKS_SUMMARIES_INDEXING: Value.toggle({ /* ... */ }),
GOGGLES_INDEXING: Value.toggle({ /* ... */ }),
AUDIT: Value.toggle({ /* ... */ }),
CPFP_INDEXING: Value.toggle({ /* ... */ }),
})
sdk.Action.withInput(
'configure-indexing',
{ /* metadata */ },
spec,
async ({ effects }) => configJson.read((c) => c.MEMPOOL).once(),
async ({ effects, input }) => configJson.merge(effects, { MEMPOOL: input }),
)
Benefits:
- Prefill and write collapse to direct pass-throughs — no manual object-literal mapping on either side.
- If the file model later adds or removes a field the action exposes, TypeScript flags the mismatch instead of silently dropping it.
When not to mirror: if the action transforms values, combines multiple inputs, writes to multiple sections, or writes to a section where the file-model keys aren’t a good user-facing vocabulary. In those cases, use human-readable camelCase input names and do the mapping in the handler.
Note
Action prefills use
.once(), not.const(effects)..const()sets up a reactive watcher meant forsetupMain— it’s wasted overhead in a prefill, which is a one-shot read at the moment the form opens.
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 { setAdminPassword } from "./setAdminPassword";
import { manageSmtp } from "./manageSmtp";
export const actions = sdk.Actions.of()
.addAction(setAdminPassword)
.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 alongside any internal-only secrets the service needs. The admin password is set by the setAdminPassword action when the user runs its critical task (see Prompt User to Create Admin Credentials):
await storeJson.merge(effects, {
secretKey: utils.getDefaultString({ charset: "a-z,A-Z,0-9", len: 64 }),
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, setAdminPassword, 'critical', {
reason: i18n('Set the admin password before signing in'),
})
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 creating admin credentials 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 When Credentials Are Unset
The standard admin-credentials pattern: init reads the store and surfaces a critical task when the password is unset. Generation lives in the matching action, which covers both first-set and later rotation. The watcher runs on every init kind; the prompt is idempotent (see Idempotency and replayId), so a container rebuild after the password is set is a no-op:
export const watchCredentials = sdk.setupOnInit(async (effects) => {
const store = await storeJson.read().const(effects)
if (!store?.adminPassword) {
await sdk.action.createOwnTask(effects, setAdminPassword, 'critical', {
reason: i18n('Set the admin password before signing in'),
})
}
})
See the Prompt User to Create Admin Credentials recipe for the matching action.
Prompt for Required Configuration
Ask the user to configure something before the service can function:
await sdk.action.createOwnTask(effects, manageSmtp, 'important', {
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) | Overrides the default idempotency key (see below) |
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.
Idempotency and replayId
Tasks are idempotent by default. The SDK computes a default replayId of [package-id]:[action-id], so calling createOwnTask / createTask multiple times with the same action does not create duplicate tasks — subsequent calls are no-ops against the same replay key. You can safely re-run your init function on every container rebuild without accumulating stale tasks.
Provide a custom replayId only when you need to intentionally create multiple distinct tasks for the same action (e.g., one-per-peer setup prompts). Each unique replayId becomes a separate task.
To cancel a task programmatically, clear it by its replay key:
await sdk.action.clearTask(effects, 'my-service:set-admin-password')
Notifications
Notifications are messages your service can post to the StartOS notifications panel — the same panel where StartOS shows backup-completion notices, install failures, and similar OS-generated events. Use them sparingly, only for information the user genuinely needs to know about — most commonly that a long-running action has finished: a sync health check that finally passes, a lengthy reindex or migration completing. They are not a changelog feed or an activity log; the vast majority of what your service does should not produce one. If you need the user to do something, use a Task instead.
The host attributes every notification to the calling service automatically — a package cannot post notifications on behalf of another package.
Plain Notification
Omit data for a notification with no extra payload. The notifications panel shows the title and message directly in the row.
await sdk.notification.create(effects, {
level: 'info',
title: 'Sync Complete',
message: 'Initial block download finished.',
})
Notification With Markdown Details
Pass data as markdown text when the notification carries long-form content that doesn’t belong inline. The panel still shows title and message in the row, and a “View Details” button opens data rendered as markdown in a large modal. Typical uses: a completion summary for a long-running operation, or a diagnostic report for a recoverable error.
data should be markdown text — not a short status string.
await sdk.notification.create(effects, {
level: 'success',
title: 'Reindex Complete',
message: 'The transaction index finished rebuilding. Tap for a summary.',
data: [
'## Reindex summary',
'',
'- Blocks processed: 812,043',
'- Duration: 3h 14m',
'- Index size: 4.2 GiB',
'',
'No further action is needed — the service is fully synced.',
].join('\n'),
})
Parameters
| Parameter | Type | Description |
|---|---|---|
effects | Effects | Provided by the calling context |
level | 'success' | 'info' | 'warning' | 'error' | Severity, controls the icon and color in the panel |
title | string | Short headline shown in the row |
message | string | One-line body shown in the row beneath the title |
data | string | null (optional) | Optional markdown body rendered in the “View Details” modal. Omit for a plain (panel-row-only) notification |
Common Patterns
Notify on Sync Completion
Post a one-time success notification from a daemon’s health check or main flow when long-running work finishes:
await sdk.notification.create(effects, {
level: 'success',
title: i18n('Sync Complete'),
message: i18n('Bitcoin Core has finished initial block download.'),
})
Report a Recoverable Error With Details
Pair a short message with full diagnostic output in data so the user gets context without dumping a wall of text into the panel row:
await sdk.notification.create(effects, {
level: 'warning',
title: i18n('Backup Skipped'),
message: i18n('A non-critical backup step was skipped. Tap for details.'),
data: [
'## Skipped: optional thumbnail cache',
'',
'`/data/cache/thumbnails` was not present, so it was skipped during this backup.',
'No data was lost — the cache will be regenerated on next use.',
'',
'```',
err.stack ?? String(err),
'```',
].join('\n'),
})
Note
Notifications are not idempotent — every call creates a new entry. If a daemon’s health loop calls
sdk.notification.create()on every poll, the panel will fill up. Gate on a one-shot condition (a flag in your store, a state transition, etc.) so you only post when something actually changed.
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. See Unknown Key Preservation for details and migration implications. - 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.
Reparse raw Through Shape in formToFile
When a FileHelper.ini uses an InputSpec’s partialValidator as its validator and exposes the raw file as raw: Value.hidden(shape), formToFile must reparse rawInput through shape before spreading it. Otherwise, the first install seed writes an empty file — the enforced .catch() defaults in shape never fire, and the daemon starts with upstream defaults instead of the locked-down values.
export const shape = z.object({
"rpc-bind-ip": z.literal("0.0.0.0").catch("0.0.0.0"),
"rpc-bind-port": z.literal(18081).catch(18081),
// ...more enforced + configurable keys
});
export const fullConfigSpec = InputSpec.of({
raw: Value.hidden(shape),
// ...user-facing form fields
});
function formToFile(
input: T.DeepPartial<typeof fullConfigSpec._TYPE>,
): Conf {
const { raw: rawInput, ...rest } = input;
// Reparse through shape so .catch() defaults fire when rawInput is undefined.
const raw = shape.parse(rawInput ?? {});
return {
...raw,
// ...form-derived fields
};
}
export const confFile = FileHelper.ini(
{ base: sdk.volumes.main, subpath: "my.conf" },
fullConfigSpec.partialValidator,
{ bracketedArray: false },
{
onRead: (a) => fileToForm(shape.parse(a)),
onWrite: (a) => formToFile(a),
},
);
Why it matters: partialValidator makes every field of fullConfigSpec optional — including raw. On first install (confFile.merge(effects, {}) from seedFiles), rawInput arrives undefined, so ...raw spreads nothing. zod’s .catch() defaults only fire under shape.parse(). Calling shape.parse(rawInput ?? {}) is what forces them. On subsequent writes, onRead has already produced a fully populated conf, so the reparse is idempotent.
Alternative: Some packages (bitcoin-core, cln) hardcode the enforced values in both shape (z.literal(X).catch(X)) and again inside formToFile. That works but duplicates the source of truth — two places to update if a value changes. The reparse keeps shape as the single source.
Unknown Key Preservation
The SDK patches z.object() to use loose mode by default — unknown keys in the parsed data are preserved, not stripped. This is intentional: upstream config files often contain keys your schema doesn’t model (auto-generated secrets, internal state, plugin settings, etc.), and stripping them would break the service.
This has two important consequences:
merge()never removes keys you don’t mention. Only keys explicitly passed tomerge()are updated. Everything else — including keys outside your schema — passes through untouched.- Stale keys from previous versions persist. If an earlier version of your package wrote keys that the current version no longer uses, those keys survive across updates. They are not automatically cleaned up by
merge()or by the zod schema.
To delete a stale key, pass it as undefined in a merge() call:
// Remove keys that no longer exist in the current version
await configToml.merge(effects, {
old_deprecated_key: undefined,
removed_plugin_setting: undefined,
});
When stale keys are outside your schema’s type, cast the merge data:
await configToml.merge(effects, {
legacy_key: undefined,
} as any);
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,
});
Don’t Call .strip() on Your Shape
The SDK intentionally patches z.object() to loose mode (see Unknown Key Preservation) so unknown keys from the upstream service survive. Calling .strip() on your shape disables that protection and will silently destroy user data on the next merge() — keys outside your schema get discarded. Leave the default alone; only use .strict() if you have a specific reason to reject unknowns.
Migration Gotchas
Parser / Separator Transitions Can Wipe Data
If you change a FileHelper’s parser or separator on an already-released package — e.g. switching from FileHelper.ini (npm ini, = separator) to FileHelper.raw with a custom parser that uses : — existing on-disk files may silently decay under the new code. The old format isn’t recognized, every section parses as {}, zod .catch() defaults fill in, and the defaulted object is stringified back in the new format. Real user data (passwords, custom settings) gets quietly replaced with defaults.
.catch() defaults are great for new installs but mask exactly this class of error — there is no parse failure to observe.
Before shipping a parser change:
- Verify the new parser actually reads what the old code and the upstream service wrote. If not, plan a one-shot migration that rewrites the file in the new format as part of the version upgrade.
- When diagnosing a “field silently became empty / default” bug after an update, check git history for parser, separator, or FileHelper implementation changes on the affected file model.
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,
},
}
What setupDependencies Returns
The object you return from setupDependencies() declares what state each dependency should be in for your service to be considered “fully operational.” It drives the warning UI the user sees on the service detail page — if a listed dependency isn’t installed, isn’t running, or has a listed health check failing, StartOS shows them a warning indicator and links them to the offending service.
It does not gate your service’s startup. Your service starts whenever the user starts it, regardless of dependency state. The fields:
kind: 'running'— user should have this dependency running.kind: 'exists'— user only needs it installed.versionRange— semver range the dependency must satisfy.healthChecks— names of the dependency’s daemons (theirreadyIDs) or standalone health checks (addHealthCheckIDs) that should be passing.
If your service genuinely cannot operate before a dependency reaches a particular state (a file exists, an RPC responds, a config is generated), handle that at runtime in setupMain — poll the dependency, retry, or surface your own error. Don’t rely on the dependency declaration to block startup for you.
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.
Warning
Each variant must declare a distinct hardware requirement in the manifest (with at most one empty fallback), or publishing the second variant fails with a registry metadata mismatch. See GPU/Hardware Acceleration.
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
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
<p align="center">
<img src="icon.svg" alt="[Service Name] Logo" width="21%">
</p>
# [Service Name] on StartOS
> **Upstream docs:** <https://docs.example.com/>
>
> Everything not listed in this document should behave the same as upstream
> [Service Name]. 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 — must include all sections present in the README]
---
## 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]
## Dependencies
[Required and optional dependencies — version constraints, health checks, mounted volumes, purpose]
## 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
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
```
> [!IMPORTANT]
> Do not include `upstream_version`, image tags, or dependency version constraints in the YAML block (or anywhere else in the README). The manifest is the single source of truth for versions — README version references go stale on every bump and create misinformation. If a user wants to know the version, they can look at the manifest or the service page.
Sections to Document
Logo
Every README should begin with the service icon centered above the title. Use the standard format:
<p align="center">
<img src="icon.svg" alt="[Service Name] Logo" width="21%">
</p>
Adjust the src to match the actual icon filename (e.g., icon.png if the icon is a PNG).
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
Dependencies
For each dependency, document:
- Service name and whether it is required or optional
- Version constraint (e.g.
>= 28.3) - Health checks that must pass before this service starts
- Mounted volumes — if a dependency volume is mounted, note the mount point and whether it is read-only
- Purpose — why this dependency is needed (e.g. “blockchain data via RPC”, “Electrum lookups”)
If the service has no dependencies, state “None” explicitly.
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
- Centered logo header at the top of the file
- Upstream docs linked at the top
- All volumes and mount points documented
- All actions documented with their purpose
- All StartOS-managed settings/env vars listed
- All dependencies documented (or “None” stated explicitly)
- All limitations listed explicitly
- “What Is Unchanged” section included
- YAML quick reference block for AI consumers
- No specific version numbers anywhere (image tags, upstream version, dep version constraints — all stale the moment you bump)
- Tested that documented features match actual behavior
-
CONTRIBUTING.mdexists with build instructions
Writing Service Instructions
instructions.md is a required file at the root of every StartOS package, alongside README.md. Its contents are packed into the s9pk archive and surfaced to the user under the Instructions tab on the service details page in StartOS, beneath the Dashboard.
Instructions are for the human running the service — not for developers, not for AI assistants. They pick up where the marketplace listing left off: by the time someone reads this tab they have seen the short and long description and clicked Install, so don’t reintroduce the service. Orient them to what it does on StartOS, walk them through getting it usefully running, and point them at upstream documentation when they need to go deeper.
Instructions vs. README — they are not the same file
It is tempting to treat instructions.md as a copy of the README. Resist this. The two files serve different audiences and answer different questions.
| README | instructions.md | |
|---|---|---|
| Audience | Developers, AI assistants, contributors | End users running the service on StartOS |
| Question it answers | “How does this package work, and how does it differ from running the upstream service directly?” | “I just installed this — now what? How do I use it on StartOS?” |
| Tone | Technical, structured, scannable for parsing | Practical, instructional, written in second person |
| Versions / image tags | Avoided (manifest is source of truth) | Avoided for the same reason |
| Upstream behavior | “Anything not listed here behaves as upstream documents” | Linked from the Documentation section; never duplicated |
| Surfaced where | The package repository on GitHub | Inside the StartOS UI, post-install |
If your README is a reference manual, your instructions are a quick-start guide for a non-developer who just clicked Install.
What belongs in instructions
A good instructions.md covers, roughly in this order:
-
A brief orientation — usually skip it. The reader already saw the marketplace short and long description before clicking Install, so don’t restate them. The default is to omit this section and go straight to Documentation. Add a line only if there is genuinely new context the listing did not cover — a hard ordering constraint, a permanent decision the user is about to make, or similar. “You’ve installed X” framing is not useful; the reader knows. Don’t pad.
-
Documentation links. A
## Documentationsection. Port exactly the URLs the manifest previously carried in itsdocsUrlsarray, each with a few words on what it is (“the upstream admin guide”, “the official Foo configuration reference”). Do not add marketing, donation, project-home, or support-channel links — those live elsewhere and were deliberately omitted fromdocsUrls. Link to canonical, stable URLs the upstream maintains — not specific commits, not your own README. -
What it gives you on StartOS — the practical answer to “why did I just install this?” Keep it concrete: the interfaces it exposes, the data it manages, the experience the StartOS package adds on top of upstream.
-
Getting set up — the smallest sequence of steps that takes a fresh install to a usable state. The reader has already installed the service — don’t include download or install steps. Start from first launch. Use numbered lists. Reference real action names, real interfaces, and real screens that exist in the StartOS UI for this service. If setup requires a dependency, say so plainly: “Install Bitcoin Core first” rather than “satisfy the dependency.”
-
Using the available features — once the service is running, what can the user actually do with it? Describe the interfaces (web UI, RPC, etc.) and the user-visible actions. Hidden actions (
visibility: 'hidden'in the package source — typically those invoked by the platform or by another service rather than by a human) do not belong here; the user never sees them. Likewise, do not parrotallowedStatusesfrom action source code (“the service must be running”, “the service must be stopped”): describe what the user actually encounters in the UI, and omit the qualifier when it’s noise. -
Important limitations — usually omit. The default is no Limitations section at all. Add one only if there is a specific, consequential thing the user will be surprised by: a deliberately disabled feature they may go looking for, a hard data caveat, an incompatibility worth flagging up front. Generic caveats (“performance depends on your hardware”, “encryption keys are sensitive”) are not limitations and do not belong here.
Note
Older StartOS manifests carried a
docsUrlsarray for upstream documentation links. That field has been removed — those links belong in the## Documentationsection of this file now, where you can give each one the context a bare URL in the manifest never had.
What does not belong in instructions
- A restatement of the marketplace description. The reader saw the short and long description before installing — opening with “Foo is a self-hosted bar” wastes their time. Start from “now what.”
- “You’ve installed X” or any other orientation that tells the reader something they already know. They installed it; that’s why they’re on this tab.
- Install or download steps. They’ve already installed the service. Begin at first launch.
- How StartOS itself works. The interface panel’s copy-address / QR-code / LAN-Tor-domain controls, the Dashboard and Instructions tabs, how backups and updates work, how to start or stop a service — these are platform features a user learns once, not per-package. Mention only what’s specific to this service: which interfaces it exposes and what each is for, which actions it adds and when to run them. Naming a screen to send the user to (“open it from the Dashboard tab”) is fine; explaining what that screen is, isn’t.
- Invented navigation paths. Don’t guess at how to reach a UI surface. Reference only screens, tabs, and tables that actually exist in StartOS for this service. “Set X in the network settings” is wrong if there is no such page; “add the domain on the Homeserver interface” is right if that’s where it actually lives.
- Hidden actions. Actions marked
visibility: 'hidden'in source — typically those invoked by the platform or by another package’s plugin handshake — are not user-facing. Do not list them, even to “explain” them. - Status preconditions for critical tasks. A critical task suspends every other control: the user does not see a Start / Stop / Run button while the task is required, only the task. Telling them “the service must be stopped” or “start the service first” before running a critical task is not just noise, it’s wrong.
- Platform plumbing the user can’t act on. “Registration is typically triggered automatically by the bridge service” tells the reader nothing they can do with the information. If they’d never act on a sentence, cut it.
- The full configuration reference. Link to upstream for that.
- Version numbers and image tags. They go stale every release; the manifest is the source of truth.
- Architectural detail about how the package is built. That is the README’s job.
- Reasons the package was structured a particular way. Users do not care.
- Internal terminology from the StartOS codebase (“ABI”, “task”, “manifest”, “subcontainer”). Use the words a user sees in the UI.
- Secrets, default passwords, or API keys hard-coded into the markdown. Generate those at install time and surface them via actions.
Style
- Write in the second person. “You will see…”, “When you click…”, “Before you start, make sure…”.
- Prefer numbered lists for any multi-step procedure.
- Use code blocks for commands the user might run, hostnames they might paste, or RPC calls — not for prose.
- Keep paragraphs short. Many users will scan, not read.
- Use H2 (
##) for top-level sections; reserve H1 for the service name at the top of the file. - StartOS will render the markdown through the same pipeline as release notes and licenses, so standard CommonMark + GFM tables work; exotic HTML may not.
Suggested structure
Use the sections that apply — a trivial service might be two paragraphs and a Documentation list; a complex one might need every section below and more. Don’t include a section just to have it (if the service has no actions, you usually needn’t say so).
# [Service Name]
[Optional, usually omit. Add one or two sentences only if there is genuinely new context the marketplace listing didn't cover — for example, a hard ordering constraint or a permanent decision the user is about to make. Otherwise delete this line and start with the section below.]
## Documentation
- [Upstream documentation](https://docs.example.org) — what it is in a few words (the config reference, the upstream README, etc.).
(Port exactly the URLs the manifest previously carried in `docsUrls`. Don't add marketing, project-home, donation, or support links here.)
## What you get on StartOS
[Concrete description of the StartOS experience: which interfaces are exposed, what data it manages, what the package adds on top of upstream.]
## Getting set up
1. [First concrete step the user should take after install.]
2. [Second step…]
3. [Until the service is in a usable state.]
> If your service depends on another, list the dependency explicitly here and tell the user to install it first.
## Using [Service Name]
[Describe the day-to-day experience. Interfaces, actions, common workflows. One short subsection per major capability is fine.]
### Web interface
[What this interface is for and what the user sees first — a login screen, a setup wizard, an empty dashboard. Not how the universal interface-panel controls work; those are identical in every service.]
### Actions
[Each StartOS action: what it does, when to run it.]
### [Other capability]
[…]
## Limitations
[Usually omit this section entirely. Include only if there is a specific, consequential surprise — a deliberately disabled feature the user may go looking for, an incompatibility worth flagging.]
Pre-publish checklist
- File exists at
instructions.mdat the package root (the build will fail otherwise). - Written for the user, not the developer — no internal SDK terminology.
- Does not restate the marketplace short/long description, contains no install or download steps, no “You’ve installed X” framing, and doesn’t explain StartOS platform features (interface controls, tabs, backups) the user already knows.
- All navigation references point at UI surfaces that actually exist for this service — no invented network-settings pages or pretend tabs.
- Setup steps walk from first launch to a usable state.
- Every action and interface mentioned actually exists in the package, and every action mentioned is
visibility: 'enabled'(hidden actions are not listed). - Status preconditions are described as the user actually sees them — critical tasks are not qualified with “stop the service first”;
allowedStatusesis not parroted when its gate is invisible to the user. - Every sentence is something the user could act on — no “this is typically triggered automatically by …” plumbing notes.
- No hard-coded version numbers, image tags, or secrets.
- Limitations section is omitted unless there is a specific, consequential surprise to flag.
- A
## Documentationsection ports the URLs the manifest’sdocsUrlspreviously carried, each with a few words of context. No added marketing / donation / project-home / support links. - Renders cleanly in the StartOS Instructions tab on a real install.
Publishing
Every .s9pk needs a registry to live in before it can be installed on a StartOS device. StartOS is deliberately flexible about which registry that is — you can run your own forever, submit to the Start9 Community Registry, or do both in parallel. Nothing about the packaging workflow requires you to distribute through Start9.
Self-Hosted Registry
The fastest and most autonomous path is to run your own registry — install the startos-registry service on a StartOS device, point start-cli at it, and publish. See Hosting a Registry for the full walkthrough (install, first-run setup, administration).
You can run a self-hosted registry in parallel with a Start9 Community submission: developers often keep an alpha/testing registry of their own while a more stable build is promoted through the community pipeline.
Start9 Community Registry
If you want your package on Start9’s official community registry, the current flow is email-driven. A developer portal with self-service submission and promotion is on the roadmap; until it ships, this is the interface.
The community registries, in promotion order:
- community-alpha — https://community-alpha-registry-x.start9.com — receives every PR-merge build automatically
- community-beta — https://community-beta-registry.start9.com — promoted from alpha on your request
- community (production) — https://community-registry.start9.com — promoted from beta on your request
Initial Submission
- Email submissions@start9.com with a link to your public GitHub repository.
- Start9 forks your repo into the Start9-Community GitHub organization and replies with any feedback.
- Address feedback by opening PRs against the Start9-Community fork, not your original repo. The fork becomes the upstream for the community pipeline from that point on.
The Pipeline
Once your fork exists inside Start9-Community:
- Open a PR against the fork with your changes.
- Merge — when Start9 merges the PR, a workflow automatically builds, tags, and deploys the package to community-alpha. You don’t run any publish commands yourself; the automation handles it.
- Promote to beta — when you’re ready for wider testing, email submissions@start9.com or open an issue on the fork. Start9 promotes the current alpha build to community-beta.
- Promote to production — when the beta has soaked and you’re ready to ship broadly, same signal (email or issue). Start9 promotes to community.
Every subsequent change or version bump is another PR through the same cycle — merge publishes to alpha, email/issue promotes onward.
Note
The email / issue loop is clunky — we know. A developer portal with self-service submission management and one-click promotion is actively being built. Until it ships, email and issues are how the pipeline is operated.
Pre-Publish Checklist
Before publishing to your own registry — or before opening / updating a PR on the Start9-Community fork — walk through this. For community submissions, these checks must pass before you open the PR: the merge triggers the build, and anything wrong will ship directly to community-alpha.
- Tag convention followed. Your version tag matches Git Tag Conventions.
- All checks pass.
tsc --noEmit, tests, and the pack step must be green. - README is current. Every action, volume, port, dependency, and limitation matches the code. No version numbers anywhere — see Writing READMEs.
- Tested end-to-end on StartOS. Installed cleanly, service started, UI loaded (if applicable), health checks went green. Uninstall and reinstall to confirm teardown works.
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