Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Packaging Guide

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

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

Designed for AI-Assisted Development

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

The recommended setup is Claude Code with this guide and your package in the same workspace, scaffolded by start-cli s9pk init-workspace. See Environment Setup and the Quick Start to get going.

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

Where to Start

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

Recipes

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

Getting Started

  1. Environment Setup - Install the required development tools
  2. Quick Start - Create, build, and install your first package
  3. Development Workflow - How to behave while working on a package

Reference

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

Environment Setup

Before building service packages, you need to install several development tools on your workstation. This page lists each prerequisite and how to install it. The final section — Set Up Your Packaging Workspace — scaffolds the AI-assisted workspace that all packaging is designed around.

Note

The Linux examples below install packages with apt, for Debian-based distros (Debian, Ubuntu, Mint, PopOS, …). On another distro, use your package manager to install the same packages.

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.

Docker must be running when you build a package, and your user must be able to use it:

The daemon runs as a service — start it with sudo systemctl start docker. By default only root can talk to it, so add your user to the docker group once (then log out and back in), otherwise every build fails with permission denied ... /var/run/docker.sock:

sudo usermod -aG docker $USER

Tip

Confirm it works with docker run --rm hello-world before continuing.

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

sudo apt install build-essential

Node.js v22 (Latest LTS)

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

The recommended installation method is nvm. If you don’t already have nvm, install it, then close and reopen your terminal (or source ~/.bashrc / source ~/.zshrc) so the nvm command is available:

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash

Then install and select Node.js v22:

nvm install 22
nvm use 22

Alternatively, download Node.js v22 (or newer) directly from nodejs.org — make sure node --version reports v22+ afterward.

SquashFS

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

sudo apt install squashfs-tools squashfs-tools-ng

cURL

cURL downloads the start-cli installer script in the next step. It is pre-installed on macOS and most Linux systems; install it if missing.

sudo apt install curl

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

Git

Git is used by start-cli s9pk init-workspace to fetch the Start9 monorepo — the packaging guide, and the SDK and OS source behind it — and to keep it up to date afterward.

sudo apt install git

jq

The build uses jq to read your package’s manifest and print the build summary, so it must be installed.

sudo apt install jq

Verification

After installation, verify all tools are available:

docker --version
docker run --rm hello-world   # confirms the daemon is running and you have access
make --version
node --version                # must be v22 or newer
npm --version
mksquashfs -version
git --version
curl --version
jq --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. If docker run --rm hello-world fails, re-read the Docker note above (the daemon must be running, and on Linux your user must be in the docker group).

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 start9-workspace
cd start9-workspace

This clones the Start9 monorepo into start-technologies/, 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:

start9-workspace/
├── .startos/              ← workspace marker: build-key (signs your packages) + config.yaml (hosts, registries)
├── AGENTS.md              ← agent context (symlink to the guide's Agent Context page), 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-technologies/    ← the monorepo: the guide, the SDK source, the OS source

You get the whole monorepo, not just the guide. That’s deliberate: when the guide can’t settle a question, the SDK source (projects/start-sdk/lib) and the StartOS source (projects/start-os, shared-libs/) are right there to read — and if you find a bug, you’re already in a repo you can open a pull request from. The clone is --filter=blob:none, so file contents are fetched on demand: it lands in a few seconds and takes ~75 MB, while git log, git blame, and rebase all behave normally.

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. You can read exactly what it contains on the Agent Context page.

Already have the monorepo?

If you already keep a start-technologies checkout — you work on StartOS itself, or you’ve cloned it for another reason — don’t let the workspace clone a second copy. Point at the one you have before running init-workspace, and it will use it:

mkdir start9-workspace && cd start9-workspace
ln -s /path/to/your/start-technologies start-technologies
start-cli s9pk init-workspace .

init-workspace skips the clone whenever start-technologies already resolves to a directory, so the symlink is left alone and everything else is provisioned around it. The workspace AGENTS.md links through it, and s9pk init-package scaffolds from its package template, exactly as with a fresh clone.

Important

A symlinked checkout is yours to maintain. Skip the git pull in Keep it current: that repo has its own branches and its own work in progress, and a blind pull would fast-forward whatever branch happens to be checked out rather than refresh the guide. Update it on your own schedule instead.

Nested workspaces and config resolution

Workspaces can be nested — running init-workspace inside another workspace is fine. When start-cli needs a workspace’s signing key or targets (building, signing, reading host/registry), it walks up from the current directory and uses the nearest .startos/. So an inner workspace transparently overrides an outer one, and settings you don’t override are inherited from above — conceptually a deep merge of every .startos/ on the path, innermost first.

The one thing init-workspace refuses is running inside a package repo: a workspace is the directory that holds package repos, not a package itself. If you already have package repos, run init-workspace in the directory that contains them (their parent); building, signing, and publishing then walk up to find the workspace. Starting fresh, run it in a new directory, then start-cli s9pk init-package inside it.

Until a workspace exists, make / s9pk pack / s9pk publish fail with a message pointing you to init-workspace — packaging is designed around the workspace (and its AI guide), so there is no build-key to sign with until you create one.

Note

There’s no automatic migration from an older global ~/.startos. To reuse a previous signing key, copy it into a workspace yourself: cp ~/.startos/developer.key.pem <workspace>/.startos/build-key.

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 — you only need them if you plan to publish a package, so you can ignore them while testing locally. The host entries are the StartOS devices you install to; edit host.default to point at your own box.

Your device’s address is shown in the StartOS web interface (it looks like https://adjective-noun.local, or use its IP such as https://192.168.1.100). Set it as host.default, for example:

host:
  default: https://adjective-noun.local

Tip

Setting host.default lets you install with make install — the recommended way to work on a package, since it builds and pushes to your device in one repeatable command. It also requires logging in once with start-cli auth login (it prompts for your StartOS master password). If you’d rather not set up the CLI yet, you can sideload the .s9pk through the web interface instead — see Quick Start.

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

As of @start9labs/start-sdk 2.0, make install and make publish resolve their target through start-cli — the workspace .startos/config.yaml profiles, or -H / -r. (Older s9pk.mk parsed a single host: / registry: URL from the global ~/.startos/config.yaml.) See Makefile.

Keep it current

The guide, the package template, and the agent context all live in start-technologies/, so syncing it refreshes everything at once. Pull it at the start of each session:

git -C start-technologies 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.

Your environment is ready. Continue to Quick Start to scaffold and build your first package inside the workspace.

Quick Start

This guide walks you through scaffolding a new service package, building it, and installing it on StartOS. The scaffold is a working Hello World service — your starting point for packaging any app.

Note

Complete Environment Setup first — including creating your packaging workspace. start-cli s9pk init-package only runs inside a workspace.

Scaffold the Package

From the root of the workspace you created during Environment Setup, scaffold a new package:

start-cli s9pk init-package "Hello World"

init-package normalizes the display name to a package ID, creates hello-world-startos/ from the bundled template — a barebones, buildable Hello World clone — and runs npm install for you. It leaves a TODO.md checklist that takes the package from clone to release-ready.

Your workspace now looks like:

start9-workspace/
├── .startos/
├── AGENTS.md
├── AGENTS.local.md
├── CLAUDE.md
├── start-technologies/
└── hello-world-startos/    ← your new package

Tip

Already have a package repo? Clone it into the workspace alongside start-technologies/ and build it the same way.

Make sure Docker is running first (docker ps should succeed — see Environment Setup), then build for your StartOS device’s architecture — use x86 for a typical Intel/AMD server or VM, or arm for a Raspberry Pi or other ARM board:

cd hello-world-startos
make x86        # or: make arm

Dependencies were already installed by init-package, so this goes straight to building. The first build pulls the service’s container image, so it can take a few minutes. Building a single architecture is the fast path for development; it produces hello-world_x86_64.s9pk (or hello-world_aarch64.s9pk). Building every architecture (make) or one multi-arch package (make universal) is slower and only needed when you publish to a registry — see Makefile for all build targets.

Install to StartOS

You need a device running StartOS (from Environment Setup) on the same network.

This is the way to work on a package: build and push to your device in a single command, repeated on every change. Set it up once:

  1. Point your workspace at the device — set host.default in .startos/config.yaml to your device’s address (see Hosts and registries).
  2. Log in — start-cli auth login (enter your StartOS master password).

Then build and install for your device’s architecture in one step, from the package directory:

make x86 install        # or: make arm install

Every later change is just another make x86 install. See Makefile — Installation for details (including the one-time certificate trust make install needs).

Alternative: sideload via the web interface

No command-line setup — a good way to get your first .s9pk onto a device, or if you haven’t configured the CLI yet:

  1. Open your StartOS device in a browser and log in.
  2. Click Sideload in the top navigation bar.
  3. Select the .s9pk you just built (hello-world_x86_64.s9pk or hello-world_aarch64.s9pk).

See Sideloading for details.

Next Steps

With Hello World running on your server, you’re ready to package your own service. Open hello-world-startos/ in your AI assistant and point it at the TODO.md checklist — it takes the package from Hello World clone to a real service (descriptions, image, icon, interfaces, daemons, docs).

Then browse the Recipes to find the patterns your service needs — each describes an approach and points you to reference docs and real package code.

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, and they track different things. README.md is the architectural reference for developers and AI — update it for any change to how the package is built, structured, or behaves (a new or renamed action, an added or removed volume/port/interface/dependency, a changed default, a new feature or limitation). instructions.md is the end-user guide — update it whenever a change affects what the user sees or does. When a change touches both, update both in the same change.

Apply this loop on every task:

  1. Make the code change.
  2. Open README.md and instructions.md. Read what each says about the area you touched.
  3. If either no longer matches the code, update it in the same change.
  4. 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 is worth documenting, 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: Z fixup commits.
  • If you’ve already accumulated fixups during a debug session, git reset --soft HEAD~N collapses 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.

Verify against reality, not against tsc

A clean tsc and a successful start-cli s9pk pack prove the code type-checks and the package builds. They prove nothing about whether the service runs, the web UI loads, logins work, or data persists. Type-checking a credential flow that has never accepted a login, or a daemon that mounts the wrong path, passes just as green as one that works.

Before reporting a feature as done, exercise it against a running service:

  • Install on a StartOS box (or run the image directly) and confirm the daemon stays up — not just that it starts.
  • Use the actual feature. If you wired up admin credentials, log in with them. If you mounted a data volume, write data and restart to confirm it survives. If you exposed a port, connect to it.
  • A feature you have only compiled is unverified. Say so plainly — “builds clean; not yet installed/tested” — rather than implying it works.

Don’t fabricate — verify or flag

When you don’t know a fact, find it; don’t invent it and move on. The failure mode to avoid is stating a guess with the confidence of a checked fact. Three places this bites hardest:

  • Image names and tags. Confirm the repository and tag exist in the registry before pinning dockerTag — don’t guess org/name from memory. (See Package a Prebuilt Docker Image.)
  • Upstream internals — config-file formats, credential hashing schemes, file paths. Read them from the app or its docs, or apply them through the app’s own CLI/API. Hand-writing a format you assumed (e.g. a bare hash where the app expects salted PBKDF2) fails silently.
  • Brand assets. Never ship an invented icon.svg or logo. Fetch the real asset from upstream, or leave the placeholder and flag that it still needs the real icon.

When you can’t verify something, surface it as an open question or a TODO.md item — don’t paper over it with confident prose in the README.

Search the SDK before deciding something is impossible

Before concluding the SDK can’t do what you need — or working around a limitation you’ve assumed — grep the installed type definitions: node_modules/@start9labs/start-sdk/**/*.d.ts. The SDK exposes far more than the recipes show, and the option you want is often a field on a type you’re already using (this is how runAsInit is found, for example). “The SDK doesn’t support X” is a claim to verify in the types, not a conclusion to reach from the docs alone. If it genuinely isn’t there, say so and explain the workaround — don’t silently route around a capability that exists.

A comment is not evidence

A comment asserting what an SDK call does — in a package you’re reading, in a code review, in this guide’s own prose — is a claim, not a fact. Confirm it against the reference page, the installed types, or the SDK source before you accept it, repeat it, or write code that depends on it. Wrong claims about semantics propagate: one plausible sentence gets copied into the next package, then quoted in a review, then built into a plan.

merge(effects, {}) is the standing example. It has variously been described as rewriting the file, as cleaning or stripping it, and as a no-op against an existing one. Every reading was plausible; none was correct — see What an Empty merge() Does.

Read the monorepo source only when the guide can’t answer

Your workspace’s start-technologies/ is a checkout of the whole Start9 monorepo, so the SDK source (projects/start-sdk/lib) and the StartOS source (projects/start-os, and the shared core in shared-libs/) are already on disk — behind the recipes, the reference pages, real packages, and the installed @start9labs/start-sdk types.

This is a last resort, not a starting point. Drop into the source only to answer a specific question those layers can’t — exactly what an SDK call does, how an OS effect behaves — and read the one file that settles it instead of browsing.

When the answer turns out to be a bug rather than a misunderstanding, fix it there: that checkout is a full git repo, so you can branch, commit, and open a pull request without leaving the workspace.

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.

StartOS Packaging — Agent Context

Note

This page is the AGENTS.md that start-cli s9pk init-workspace links into every packaging workspace. Your workspace copy is a symlink to this file, so syncing the guide keeps it current.

You are an AI assistant working in a StartOS packaging workspace. You help create, maintain, and update .s9pk service packages for StartOS. This file is your always-on context: the rules to follow, the patterns to know, and a map of where to read for any given task. The substance lives in the packaging guide under start-technologies/projects/start-sdk/docs/ — read those pages locally, on demand, as the task requires. Do not load everything at once.

Workspace layout

<workspace>/
├── .startos/              ← workspace marker: build-key (signs your packages) + config.yaml (hosts, registries)
├── AGENTS.md              ← this file (symlink → start-technologies/projects/start-sdk/docs/src/agent-context.md)
├── AGENTS.local.md        ← your workspace-specific notes (never overwritten by a sync)
├── CLAUDE.md              ← loads AGENTS.md + AGENTS.local.md (Claude Code bridge)
├── start-technologies/    ← checkout of the Start9 monorepo: the packaging guide, plus the SDK and OS source
└── <id>-startos/ …        ← one or more package repos

Each package repo holds: README.md (what it is / how it differs from upstream), instructions.md (end-user docs shown in StartOS), UPDATING.md (upstream-version tracking), TODO.md (pending work), and startos/ (the SDK code).

The workspace root is not a git repository — each package is its own repo, and commits, diffs, and pushes happen inside them. Files at the workspace root (AGENTS.local.md, .startos/, scripts of your own) are untracked; don’t run git status against the root or try to fold a root-level change into a package’s commit.

Keeping the workspace current

The guide, the package template, and this file all live in start-technologies/, so syncing it refreshes everything at once. At the start of every session, fast-forward it against its remote:

git -C start-technologies pull --ff-only

To track a different source (e.g. a fork), repoint start-technologies’s remote first — the sync follows whatever remote is configured.

Keep workspace-specific notes in AGENTS.local.md; a sync never touches it. That file is for what is true of your setup — your box, your registry, your packages, any departure from the scaffolded layout. Anything that would help every packager belongs in the guide instead: open a PR against start-technologies rather than letting it drift in one workspace.

If start-technologies/ is a symlink to a checkout maintained outside this workspace, skip the sync: that repo has its own branches and its own work in progress, so its state is the owner’s to manage, not this workspace’s.

How to use the guide (local-first)

The guide has two layers:

  • Recipes — intent-driven pages: what to do and which constructs to combine. Start here. Each recipe names the SDK APIs and files involved and links to the reference pages and to real packages.
  • Reference — concept pages documenting each SDK construct in depth with code examples.

Workflow for any task:

  1. Find the recipe. Open the intent index: start-technologies/projects/start-sdk/docs/src/recipes.md.
  2. Follow its reference links for API details and code examples.
  3. Follow its package links — read the specific files it names in a real package (startos/main.ts, startos/actions/, …) for working production code.
  4. Read only what the task needs.

Read pages from your local checkout (start-technologies/projects/start-sdk/docs/src/<page>.md). Only if start-technologies/ is missing, fall back to the web (https://docs.start9.com/packaging/<page>.html).

Where to read for X

NeedRead
Find the right recipe for a taskstart-technologies/projects/start-sdk/docs/src/recipes.md
How to behave on every task (the disciplines below)start-technologies/projects/start-sdk/docs/src/workflow.md
File/directory layout of a packagestart-technologies/projects/start-sdk/docs/src/project-structure.md
Service metadata, descriptionsstart-technologies/projects/start-sdk/docs/src/manifest.md
Versions, migrations, release notesstart-technologies/projects/start-sdk/docs/src/versions.md
Daemons, health checks, oneshots, lifecyclestart-technologies/projects/start-sdk/docs/src/main.md
Install / update / restore init logicstart-technologies/projects/start-sdk/docs/src/init.md
Network interfaces and portsstart-technologies/projects/start-sdk/docs/src/interfaces.md
User-facing actionsstart-technologies/projects/start-sdk/docs/src/actions.md
Prompting the user to run actionsstart-technologies/projects/start-sdk/docs/src/tasks.md
Config files as typed modelsstart-technologies/projects/start-sdk/docs/src/file-models.md
Service dependenciesstart-technologies/projects/start-sdk/docs/src/dependencies.md
Build / install commandsstart-technologies/projects/start-sdk/docs/src/makefile.md
Writing the READMEstart-technologies/projects/start-sdk/docs/src/writing-readmes.md
Writing user instructionsstart-technologies/projects/start-sdk/docs/src/writing-instructions.md
Publishing / registriesstart-technologies/projects/start-sdk/docs/src/publishing.md
start-cli referencestart-technologies/projects/start-sdk/docs/src/cli.md

Reading the SDK and OS source (last resort)

start-technologies/ is a checkout of the whole Start9 monorepo, so beyond the guide you already have the SDK source (start-technologies/projects/start-sdk/lib) and the StartOS source (start-technologies/projects/start-os, plus the shared core in start-technologies/shared-libs/) on disk. Nothing to fetch.

Reach for them only when the recipes, reference pages, real packages, and the installed SDK types (node_modules/@start9labs/start-sdk) don’t answer the question — e.g. to confirm exactly what an SDK call does, or how an OS effect behaves. Open one file to settle one question; don’t browse the monorepo to “understand the system.”

If what you find there is a bug, say so. You are standing in a git repo you can branch from and open a pull request against.

Key patterns

Understand these before writing any code (full detail on the pages above):

  • Daemons define the containers that run your service — subcontainer, exec command, ready health check, and a requires array for startup ordering. (main.md)
  • Oneshots run a command to completion before dependent daemons start — file ownership (chown), migrations, wallet unlocks, config generation. Chained with .addOneshot() alongside .addDaemon() in setupMain(). (recipe-oneshot.md, main.md)
  • Health checks come in two forms: the ready property on every daemon, and standalone .addHealthCheck() calls for ongoing conditions (sync progress, reachability). (main.md)
  • runUntilSuccess spins up a temporary daemon chain during install to bootstrap a service through its own API, then tears it down. (recipe-run-until-success.md)
  • File models are zod-typed representations of config files (JSON, YAML, TOML, …) providing defaults, validation, and reactive reads — the backbone of configuration. merge(effects, {}) fills missing fields from their .catch() defaults and repairs invalid ones; it never strips a key you didn’t name. It is not a way to clean or regenerate a config. (file-models.md)
  • Interfaces declare what your service exposes; the user decides where it’s reachable. type ('ui'/'api'/'p2p') is a label, not a control, and Tor is a service the user installs and enables per interface. Never claim a service is on Tor or the public internet. (interfaces.md)

Golden rules

  • Start from intent, not from API. Find the recipe before diving into reference pages.
  • Code lives in reference pages and packages, not recipes. Recipes describe the pattern; reference pages have the API; real packages have production implementations.
  • Match existing patterns — but a neighbouring package is not the authority. Read a package’s code before introducing a new pattern. Then check it against the recipe: the fleet is mid-migration, so the package you happened to grep may itself be non-conformant. “It matches the package next door” is not a quality bar. A recipe and its named reference implementation outrank a package you found by searching.

Working discipline (every task)

The full rules are in start-technologies/projects/start-sdk/docs/src/workflow.md; this is the digest.

  • Verify facts; don’t assert from memory. Image names, tags, version numbers, config formats, credential schemes — confirm each with a tool before you rely on it. “I know that X” is a cue to check X, not to write it down. Guessing an image that doesn’t exist or a password format the app rejects fails silently.
  • A comment is not evidence. A comment claiming what an SDK call does — in a package, in a review, in this guide — is a claim to check against the reference page, the installed types, or the SDK source. Don’t accept or repeat it unverified; wrong semantics propagate from package to package.
  • Compiling is not working. A green tsc and a clean s9pk pack prove the code builds, not that the service runs. Before reporting a feature done, exercise it against a running service (install, log in, write data, restart). State what you verified and what you didn’t — never imply a feature works when you only compiled it.
  • Don’t fabricate; verify or flag. Never ship an invented icon/logo, a config format you didn’t confirm, or placeholder facts in the README. Fetch the real thing, or leave it and flag the gap in TODO.md.
  • Search before declaring impossible. Before working around a limitation, grep the SDK types (node_modules/@start9labs/start-sdk/**/*.d.ts) and existing packages. “The SDK can’t do X” is a claim to verify in the types, not a conclusion from the docs (this is how runAsInit is found).
  • Keep README.md and instructions.md in sync. README.md tracks architecture/behavior (for developers and AI); instructions.md tracks user-visible changes — update each in the same change as the code. Content rules: writing-readmes.md, writing-instructions.md.
  • Iterate with a dirty tree; commit once. The -modified pack-hash suffix is informational — don’t commit between test attempts. One clean commit when the package works; git reset --soft HEAD~N collapses accumulated fixups.
  • Pre-existing errors are still errors. A red tsc, test, or pack step means the package doesn’t pass, even if unrelated to your change. Fix it or flag it; never report green when a check was red.
  • Don’t create unnecessary version files. The latest version always lives in startos/versions/current.ts; most bumps just edit that file in place. A new file is spun off only when the bump carries a migration — a version having been released is not a reason to declare it. VersionGraph synthesizes a range vertex beneath current, so any lower installed version migrates up in one hop without its own node; canMigrateFrom is derived from that graph, not authored. See versions.md (When to Create a New Version File, Why Released Versions Don’t Need to Be Declared, Release Notes).

Starting a new package

Scaffold first — run start-cli s9pk init-package "<Name>". Do not hand-assemble a package by copying files out of another one. Scaffolding produces a barebones hello-world clone with a TODO.md checklist. Then work TODO.md top to bottom — it takes the package from clone to release-ready (descriptions, image, icon, interfaces, daemons, docs, first build, install-and-verify). Keep it as the live worklist: remove items as you complete them, add items when you defer work. Wrapping an existing upstream Docker image? Read recipe-prebuilt-image.md first.

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.

Starting a brand-new package? Scaffold it first with start-cli s9pk init-package "My Service", then work the generated TODO.md from top to bottom — don’t hand-assemble files by copying another package. If you’re wrapping an existing upstream Docker image (the common case), read Package a Prebuilt Docker Image before you start.

Configuration

RecipeDescription
Set Up a Basic ServiceMinimal single-container service with a web UI, health check, and backup
Package a Prebuilt Docker ImageWrap an upstream linuxserver/* or official image — verify the image, mount every data path, expose all ports, handle init systems and credentials
Create Configuration ActionsLet users configure your service through actions with input forms
Generate Config FilesProduce YAML, TOML, INI, JSON, or ENV files from user settings using FileModel
Pass Config via Environment VariablesConfigure your service through environment variables in the daemon definition
Hardcode Config ValuesLock down ports, paths, or auth modes so users cannot change them
Set a Primary URLLet users choose which hostname the service uses for links, invites, and federation
Set Up SMTP / EmailLet users configure email sending with disabled/system/custom modes

Credentials & Access Control

RecipeDescription
Auto-Generate Internal SecretsGenerate passwords or tokens in init for internal use (database auth, secret keys)
Prompt User to Create Admin CredentialsCritical task that points to a “set admin password” action — the action generates, stores, and returns the credential on each invocation (first-set + rotation)
Reset a PasswordAction that regenerates credentials and updates the running application
Gate User RegistrationToggle action that enables/disables public signups with a dynamic label

Setup & Lifecycle

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

Daemons & Containers

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

Networking

RecipeDescription
Expose a Web UISingle HTTP interface for browser access
Expose Multiple InterfacesRPC, API, peer, WebSocket, or SSH on different ports
Expose an API-Only InterfaceProgrammatic access with no browser UI
Reach Another ServiceDial a dependency over the host bridge (getOsIp + assigned port)

Dependencies

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

Data & Health

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

User Communication

RecipeDescription
Post a Notification to the UserSend 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.

Wrapping an upstream Docker image (a linuxserver/* or official org/app image) rather than building your own? Start here for the shape, then read Package a Prebuilt Docker Image for the image-specific concerns — verifying the image, mounting every data path, init systems, and credentials.

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

Package a Prebuilt Docker Image

The most common packaging task is wrapping an existing upstream Docker image — linuxserver/*, an official org/app image, a community image — rather than building your own from a Dockerfile. It looks simple, and the happy path is. But the same handful of mistakes sink these packages over and over: the image name is guessed instead of verified, only one of the image’s data paths gets mounted, non-UI ports are forgotten, an image with its own init system crashes because it isn’t PID 1, and credentials are “set” by hand-editing a config format that was never confirmed. This recipe is the checklist that keeps those from happening.

This page assumes the service shape from Set Up a Basic Service — daemon, interface, health check, backup — and covers only what’s different when you don’t control the image. If you’re starting a brand-new package, scaffold first (start-cli s9pk init-package "My Service") and work its TODO.md top to bottom; this recipe expands the “replace the hello-world image” line of that checklist.

Solution

Build the basic-service skeleton first, then apply these prebuilt-image concerns:

  1. Verify the image before you pin it. Do not guess a org/name. Confirm the exact repository exists, the tag you want is published, and it ships the architectures StartOS needs (x86_64 and aarch64 at minimum). Pin images.<id>.source.dockerTag to that confirmed image:tag and set arch accordingly. See Verify the image below.
  2. Mount every path the image persists. Inspect the image (or its docs) for all data and config paths — there is usually more than one (e.g. a config dir and a downloads/data dir). Mount each one, or that data lands on the container’s ephemeral filesystem and is lost on every restart. See Mount all data paths.
  3. Expose every port the service needs — not just the web UI. A torrent client needs its peer port; a mail server needs SMTP/IMAP; a database needs its wire port. Bind the UI in setupInterfaces() and add the others via Expose Multiple Interfaces. A constant like peerPort that is declared but never bound is a tell that a port was forgotten.
  4. Run the image’s entrypoint, and make it PID 1 if it has its own init system. Use sdk.useEntrypoint() to keep the upstream startup behavior. If the image bundles an init/supervisor — s6-overlay (every linuxserver/* image), tini, dumb-init, supervisord — set runAsInit: true on the daemon’s exec, or the supervisor crashes because it is not PID 1. See Images with their own init system.
  5. Pass the env vars the image expects. Many community images are configured through environment variables — linuxserver/* images read PUID, PGID, and TZ to drop privileges and set ownership; others take APP_* settings. Set them via exec.env. See Pass Config via Environment Variables.
  6. Apply credentials through the app’s own mechanism — never a hand-written hash. If the service needs an admin password, follow Prompt User to Create Admin Credentials. Do not invent the on-disk credential format; see Credentials.
  7. Verify by installing, not by compiling. A clean tsc and a successful s9pk pack prove the code type-checks — not that the service runs. Install on a StartOS box, open the UI, and exercise the actual feature (log in, add data) before calling it done. See Development Workflow — Verify against reality.

Reference: Set Up a Basic Service (the underlying skeleton) · Main · Manifest · Interfaces

Verify the image

Before writing dockerTag, confirm three things from the registry — never from memory:

  • The repository exists at the name you think it does. Image names are easy to misremember (qbittorrentserver/qbittorrent does not exist; linuxserver/qbittorrent does). Pulling, or fetching the registry’s tags endpoint, tells you for sure.
  • The tag is published. latest almost always exists; a specific X.Y.Z may not, or may be spelled differently (5.2.1, v5.2.1, version-5.2.1).
  • It is multi-arch. Inspect the manifest list for amd64/x86_64 and arm64/aarch64. An image that only ships amd64 cannot target StartOS’s ARM hardware.
# List published tags (Docker Hub library/community image):
curl -s "https://hub.docker.com/v2/repositories/linuxserver/qbittorrent/tags?page_size=25" \
  | jq -r '.results[].name'

# Confirm the tag is multi-arch:
docker manifest inspect linuxserver/qbittorrent:5.2.1 \
  | jq -r '.manifests[].platform.architecture'
images: {
  qbittorrent: {
    source: { dockerTag: 'linuxserver/qbittorrent:5.2.1' },
    arch: ['x86_64', 'aarch64'],
  },
},

Mount all data paths

Enumerate the paths the image writes to and persists — its documentation lists them, or you can run the image and watch where it creates files. Mount each path that must survive a restart. Missing a data mount does not produce an error; it silently discards that data on every restart, which is far worse.

subcontainer: sdk.SubContainer.of(
  effects,
  { imageId: 'qbittorrent' },
  sdk.Mounts.of()
    .mountVolume({ volumeId: 'main', subpath: 'config',    mountpoint: '/config',    readonly: false })
    .mountVolume({ volumeId: 'main', subpath: 'downloads', mountpoint: '/downloads', readonly: false }),
  'qbittorrent-sub',
),

A torrent client that mounts /config but not /downloads “works” in every quick test and loses every download the moment the service restarts. Map the data path, not just the config path.

Images with their own init system

linuxserver/* images (and anything built on s6-overlay, tini, dumb-init, or supervisord) expect their init system to run as PID 1. In a StartOS subcontainer the daemon command is not PID 1 by default, so the supervisor aborts (s6 logs s6-overlay-suexec: fatal: can only run as pid 1). Set runAsInit: true:

exec: {
  command: sdk.useEntrypoint(),
  runAsInit: true, // image bundles s6-overlay, which must be PID 1
  env: { PUID: '1000', PGID: '1000', TZ: 'Etc/UTC' },
},

See Main — runAsInit for the full description. If an image’s bundled init system genuinely cannot be made to work, the fallback is to build your own image from a Dockerfile and invoke the binary directly — but reach for runAsInit first; it resolves the common case.

Credentials

If the service has a web login, follow Prompt User to Create Admin Credentials: a setupOnInit watcher surfaces a critical task, and a setAdminPassword action generates, stores, and returns the credential.

The trap specific to prebuilt images is how the password reaches the application. Do not assume the on-disk format. Many apps store the web password as a salted PBKDF2 or bcrypt value with app-specific framing — not as a bare hash you can compute and drop into a config key. Writing the wrong format does not error; the app silently rejects the login. So:

  • Apply the credential through the app’s own API or CLI (run it in sdk.SubContainer.withTemp() from the action — see Reset a Password), or
  • If you must write the config directly, first confirm the real format by setting a password through the app once and reading back exactly what it wrote.

Either way, verify a real login succeeds before shipping. A credential flow that has never been logged into is not done.

Two more traps surface only when you actually test the login:

  • Reverse-proxy guards. StartOS fronts the service with its own proxy, so the request the app sees has a different Host/Origin/port than it served. Apps with host-header or CSRF validation (qBittorrent’s WebUI\HostHeaderValidation, many others) reject every proxied request — often with a 401 that looks like a bad password but isn’t. Check the app’s log for the real reason, and disable the guard the app provides for running behind a proxy. Watch the inverse too: a “trust localhost” auth bypass can let proxy-local requests skip the password entirely — disable it.
  • Config you write while the app runs can be clobbered. Many apps rewrite their whole config file on shutdown from in-memory state. If your action edits the config and then restarts the service, the shutdown flush overwrites your edit before the new instance reads it. Write config-file changes from setupMain before the daemon launches (the previous instance has already stopped and flushed), or apply them through the running app’s API instead.

Examples

See startos/main.ts and startos/manifest/index.ts in packages that wrap prebuilt images: ollama, jellyfin, vaultwarden, immich, home-assistant.

Checklist

  • Image repository, tag, and arches confirmed from the registry (not from memory)
  • Every persisted path mounted (config and data)
  • Every required port exposed (UI and non-UI)
  • sdk.useEntrypoint() used; runAsInit: true if the image has its own init system
  • Required env vars set (PUID/PGID/TZ for linuxserver/*, etc.)
  • Credentials applied via the app’s own mechanism; a real login verified
  • Installed on a StartOS box and the feature exercised — not just tsc green

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 fills every missing field from its .catch() default. It does not strip or regenerate what’s already there; see What an Empty merge() Does.

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 sdk.inputSpecConstants.smtpInputSpec — 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({ charset, len }). 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 generates the password, writes it to the store, and returns it as a group result (username unmasked + copyable, password masked + copyable).

Never roll your own password RNG

The random string comes from the SDK, always. It is not a top-level export — it lives under the utils namespace, and it takes a spec:

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

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

getDefaultString() with no argument throws — it resolves a DefaultString, which is either a literal string or a RandomString spec ({ charset, len }). If you find yourself reaching for crypto.randomInt and a hand-written alphabet, you are looking at the wrong name: it is utils.getDefaultString, not getRandomString.

When the credential is a field on an action form rather than something the handler mints, don’t generate it in package code at all — hand the spec to the OS. Value.text takes a RandomString on default (pre-filled) or on generate (adds a “generate” button); see Actions.

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 setupOnInit can 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.

Warning

Do not hand-write a credential into the app’s config file in a format you have not confirmed. Many apps store web passwords as salted PBKDF2 or bcrypt values with app-specific framing, not as a bare hash you can compute and drop into a config key — and writing the wrong format does not error, it silently rejects every login. Prefer applying the password through the app’s own CLI/API (the temp-subcontainer shape above). If you must write the config directly, first confirm the exact on-disk format by setting a password through the app once and reading back what it wrote. Either way, verify a real login succeeds before shipping — a credential flow that has never been logged into is not done.

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({ charset, len }). 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({ charset, len }), seed config file defaults with fileModel.merge(effects, {}) (an empty merge fills every missing field from its .catch() default), 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.

Returning a plain Daemons.of(...) chain from setupMain rebuilds every daemon whenever the list changes (a reactive .const(effects) read re-runs setupMain via effects.restart()). When sub-instances are added, renamed, or removed at runtime and you don’t want to bounce the unaffected ones — or restart the whole service — return sdk.Daemons.dynamic(effects, fn) instead. It reconciles the running set against a freshly-built one, touching only what actually changed.

Important

main is always sdk.setupMain(...). What varies is what you return from it: a static sdk.Daemons.of(...) chain, or the reconciler from sdk.Daemons.dynamic(effects, ...). Both are a DaemonBuildable. Never use Daemons.dynamic as your main export.

import { sdk } from './sdk'
import { tunnelsFile } from './fileModels/tunnels.json'

export const main = sdk.setupMain(async ({ effects }) => {
  // Return the reconciler — it is a DaemonBuildable, just like Daemons.of(...).
  return sdk.Daemons.dynamic(effects, async ({ effects }) => {
    // Re-runs whenever the watched file changes (a constRetry trigger).
    // Inside the builder, `constRetry` reconciles in place — it does NOT
    // restart the service.
    const tunnels = (await tunnelsFile.read().const(effects)) ?? []

    let daemons = sdk.Daemons.of(effects)
    for (const t of tunnels) {
      daemons = daemons.addDaemon(`tunnel-${t.id}`, {
        // Must be a LAZY SubContainer (`.of`, not `.eager`): the reconciler
        // rejects eager handles, and lazy ones are never materialized for
        // daemons that diff to "leave alone".
        subcontainer: sdk.SubContainer.of(effects, { imageId: 'tunnel' }, sdk.Mounts.of(), `tunnel-${t.id}`),
        exec: { command: ['tunnel', '--port', String(t.port)] },
        requires: [],
      })
    }
    return daemons // return the record-mode chain — do NOT call `.build()`.
  })
})

On each run the reconciler diffs entries by id and a configHash of their structural fields (imageId, sharedRun, name, mounts, exec, requires, and ready’s display/gracePeriod):

  • absent → present — start the new daemon
  • present → absent — stop the removed daemon
  • same configHash — leave it running, untouched
  • different configHash — restart it

Dependents (via requires) of any restarted or stopped daemon restart too, so the wiring stays consistent. Closures — ready.fn, ready.trigger, a function-form exec.fn — are not part of the hash, so surface anything the reconciler must react to through one of the hashed fields.

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

  1. Set both userspaceFilesystems: true and virtualNetworking: true at the manifest top level — fuse for storage, tun for rootless networking.
  2. Bake the OCI engine and its rootless prerequisites into the service image.
  3. Add a non-root user and /etc/subuid / /etc/subgid ranges that fit inside the subcontainer’s user namespace.
  4. (Docker only) Drop a tiny runc wrapper into the image and point default-runtime at it via daemon.json — Docker injects a net.ipv4.ip_unprivileged_port_start sysctl 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'],
    },
  },
  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 (via userspaceFilesystems). Required by fuse-overlayfs for 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 (via virtualNetworking). Required by slirp4netns and pasta for 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-technologies/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 expect loginctl enable-linger or user-scoped systemd units.
  • Subordinate-UID range overlap. /etc/subuid / /etc/subgid ranges must live inside the subcontainer’s userns (UIDs 0..65535) AND must not overlap with the calling user’s own UID. With useradd --uid 1000, the subordinate range must skip 1000 — app:1001:64535 works, app:1:65535 does not.
  • fuse-overlayfs only. Kernel overlayfs-on-overlayfs is denied for unprivileged users, so don’t try --storage-driver=overlay2. fuse-overlayfs is 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, and hardwareAcceleration are 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.

Reference: Interfaces

Authentication

StartOS does not authenticate a bound port. Binding an interface exposes it; nothing gates it unless you say so. There are exactly two ways to gate it, and they are not interchangeable.

Prefer the app’s own login. When upstream ships one, feed it the credential it expects — an env var (SALTED_PASS, QBT_PW_HASH) or its config file — and persist only the hash. Follow Prompt User to Create Admin Credentials: the action is the sole writer of the credential, it returns the value rather than storing it in cleartext, and the user rotates it by re-running the action. Reference implementations: qbittorrent (QBT_PW_HASH) and changedetection (SALTED_PASS).

Fall back to the OS gate only when the app has no auth of its own. addSsl.auth makes the OS reverse proxy challenge every request to that binding’s port, so REST endpoints, RSS feeds, and webhooks are gated along with the browser UI, and any client that can’t send an Authorization header is locked out. Reference implementation: searxng.

Warning

Do not derive an app-native login from searxng-startos. It is the OS-gate reference only. It stores the password in cleartext because the reverse proxy needs the cleartext to configure basic auth, and it takes the credential withInput because the user is choosing public-vs-private. Neither property transfers to an app-native login — copying them puts a plaintext password at rest. Searching for addSsl.auth lands you in searxng first; that answers “how does StartOS gate a port,” not “how do I wire up the app’s own login.”

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., a Bitcoin node 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 by resolving its bridge address with sdk.host.get(...).const() — see Service-to-Service Networking for the one correct way (and the two forbidden ones: .startos DNS names and cross-package container IPs).

Reference: Dependencies · Service-to-Service Networking

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 — a Bitcoin node 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', accept: [{ ... }], set: { ... } }accept lists the partial inputs that satisfy the task and set is pre-filled when none match — 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), public-pool (ZMQ on Bitcoin)

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 store.json on the startos volume — it is StartOS-level state, not part of the upstream service’s config, so it must not be an invented key in the app’s config file. In setupDependencies(), read the choice and conditionally return only the selected dependency. In setupMain()/init, read the same choice to conditionally mount the selected dependency’s volumes and resolve that backend’s bridge address into the appropriate env vars or config keys.

Reference: Dependencies · Service-to-Service Networking · 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-registry service from the marketplace, walk through first-run setup (registry name, first admin, signing keys), and connect a local start-cli to 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’re working inside a packaging workspace, add the registry to its .startos/config.yaml under registry: as a named profile, then refer to it with -r <name>:

registry:
  default: https://my-registry.example.com

Now start-cli registry -r default index works, and because it’s named default you can drop the flag entirely (-r uses the default profile when omitted). Outside a workspace, keep passing --registry <url> on each command.

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 a named registry profile in your workspace .startos/config.yaml (used with -r <name>). 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
│   ├── 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               # Agent context: repo identity + how to work in this repo
├── CLAUDE.md               # One-line `@AGENTS.md` import for Claude Code
├── 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 the SDK's s9pk.mk from node_modules)
├── 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:

  • .gitignore
  • Makefile - Includes the SDK’s s9pk.mk from node_modules (see Makefile)
  • package.json / package-lock.json
  • tsconfig.json

.github/workflows/

Every package should include three GitHub Actions workflows that delegate to the reusable CI workflows in this monorepo (.github/workflows/, migrated from the old shared-workflows repo). 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/start-technologies/.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/start-technologies/.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/start-technologies/.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

AGENTS.md is the package’s agent-context file. 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, not copied into each package repo where 40+ duplicates would drift out of sync. AGENTS.md carries only what’s specific to this repo.

Keep it short and repo-specific: state that this is a StartOS service package, point at the repo’s TODO.md as the worklist, give the doc-sync rule (keep README.md and instructions.md in step with every change), and capture any package-specific gotchas — in short, how to work in this repo. Do not restate generic guide content or turn it into a web-fetch driver (don’t instruct the agent to pull guide pages over the web up front). Developers work with the guide checked out locally alongside the package (see Environment Setup); the local-first navigation — read start-technologies/projects/start-sdk/docs/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.

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 (typically images.<name>.source.dockerTag in startos/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. The cross-cutting rule about renaming the file in startos/versions/ versus creating a new one lives in Versions.

assets/

Stores supplementary files and scripts needed by the service, such as configuration generators or entrypoint scripts. Required – the assets/ directory must exist and contain at least one file (e.g. ABOUT.md) for git to track it and for the build to succeed.

startos/

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

Core TypeScript Modules

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

backups.ts

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

dependencies.ts

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

index.ts

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

interfaces.ts (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, and dependencies. See Manifest for details.

sdk.ts

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

utils.ts

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

Subdirectories

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

actions/

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

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

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

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

fileModels/ (optional)

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

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

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

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

init/

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

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

Container initialization takes place under the following circumstances:

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

Note

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

init/index.ts

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

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

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

versions/

versions/
├── index.ts
├── 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 – the setupManifest() call
  • i18n.ts – translated strings for description

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.',
}

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

Required Fields

FieldDescription
idUnique identifier (lowercase, hyphens allowed; start-os is reserved for the OS itself)
titleDisplay name shown in UI
licenseSPDX identifier (MIT, Apache-2.0, GPL-3.0, etc.)
packageRepoURL to the StartOS package repository
upstreamRepoURL to the original project repository
marketingUrlURL for the project’s main website
donationUrlDonation URL or null
description.shortLocale object (see manifest/i18n.ts)
description.longLocale object (see manifest/i18n.ts)
volumesStorage volumes (usually ['main'])
imagesDocker image configuration (including arch)
dependenciesService dependencies

License

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

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

# Without submodule -- copy from upstream repo

Icon

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

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

Images Configuration

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

Pre-built Docker Tag

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

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

Local Docker Build

Use when building from a Dockerfile in the project:

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

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

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

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

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

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

COPY upstream-project/ .

Architecture Support

The arch field accepts these values:

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

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

GPU/Hardware Acceleration

For services requiring GPU access:

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

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 empty device array — 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-add

In particular an nvidia variant must carry an NVIDIA device filter, not []nvidiaContainer: true wires 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'],
  },
},

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>
ComponentDescriptionExample
flavorOptional variant for diverging forks#libre:
upstreamUpstream project version (SemVer)26.0.0
upstream-prereleaseUpstream prerelease suffix-beta.1
downstreamStartOS wrapper revision0, 1, 2

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 StringUpstreamDownstream
26.0.0:026.0.0 (stable)0
26.0.0-rc.1:026.0.0-rc.10
0.13.5:00.13.5 (stable)0
2.3.2:12.3.2 (stable)1

Version Ordering

Versions are compared by:

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

Example ordering (lowest to highest):

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

Choosing a Version

When creating a new package:

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

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' in manifest/index.ts (if using pre-built image)
  • Git submodule checked out to vX.Y.Z tag (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.

VersionFilename
26.0.0:0v26.0.0_0.ts
26.0.0-rc.1:0v26.0.0-rc.1_0.ts
2.3.2:1v2.3.2_1.ts

A historical file’s export is renamed to match the version, with every ., :, and - becoming _ — e.g. 2.3.2:1v_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.

  1. Rename current.ts to the version it currently holds — e.g. v2.3.2_1.ts (see Historical Version File Naming), and rename its export from current to the matching v_2_3_2_1.
  2. Add that historical version to the other array in index.ts so its migration runs when users upgrade through it.
  3. Create a new startos/versions/current.ts exporting current with the new version string, release notes, and the up/down migration.

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.

Why Released Versions Don’t Need to Be Declared

A released version is not a reason to add it to other. Only a migration is. This trips people up, so here is the mechanism.

VersionGraph does not only add a vertex per version you declare. For every version whose up is not IMPOSSIBLE, it also synthesizes a range vertex — an edge into that version, covering the whole gap beneath it (<X if it is the lowest declared version, otherwise >=prev && <X). A package with other: [] and current at 1.0.0:3 therefore has a graph of exactly two vertices:

<1.0.0:3  --[up]-->  1.0.0:3

Any installed version below current falls inside that range and migrates to current in a single hop, running current’s up migration once. This holds whether or not that version was ever declared, and whether or not it was ever released — including versions you shipped as sideloadable .s9pks outside a registry. Most packages in the Start9 registry carry other: [] and have upgraded across dozens of downstream revisions on exactly this path.

A version earns a declared node only when it has its own migration that must run in sequence on the way up. Declaring migration-less versions adds files that must be read, kept compiling, and reasoned about forever, and buys no behavior. Don’t do it to record release history — git history of current.ts already is that record.

Note

A corollary: to make an update reachable for users on a version you shipped elsewhere, you only need current’s version string to sort above theirs. You do not need to declare the intervening revisions.

canMigrateFrom Is Derived, Not Curated

The manifest’s canMigrateFrom / canMigrateTo fields are computed from the graph — a reverse/forward search from current, which setupManifest serializes into the manifest at pack time. They are not something you author, and not something the other array exists to feed. Adding versions to other to “widen” them is a misconception: other: [] already yields the widest possible range (<=current), via the range vertex above.

Upstream Update

When the upstream project releases a new version:

  1. Update git submodule to new tag
  2. Update dockerTag in manifest/index.ts
  3. Update current.ts to the new upstream version (spin off a historical file only if the bump needs a migration — see above)
  4. Reset downstream to 0

Wrapper-Only Changes

When making changes to the StartOS wrapper without upstream changes:

  1. Keep upstream version the same
  2. Increment downstream revision
  3. Apply the migration rule — most wrapper-only bumps need no migration, so just edit current.ts in place

Release Notes

releaseNotes renders as markdown in the StartOS UI. Describe what changed in the release. Read the upstream release notes / changelog for the bumped range, pull out the highlights that matter to a user (notable features, important fixes, security patches, breaking changes / required actions), and summarize them. Then link to the complete upstream release notes or changelog so the user can read the full detail. If the upstream release is genuinely trivial (e.g. a dependency-only patch), say that explicitly.

Match the length to the content. A small release is a sentence or two; a larger one earns bullets, and bold section headers (**Features**, **Fixes**, **Internal**) once it spans more than one category. Localize prose and headers in every locale; don’t leave them in English.

// Small release: a sentence naming the bump + the key change, then the link.
releaseNotes: {
  en_US: `Updated Ghost to 6.38.0. Fixes a crash when restoring from backup and patches a moderate XSS vulnerability in the editor. Full notes: https://github.com/TryGhost/Ghost/releases/tag/v6.38.0`,
  // …other locales
},

// Larger release: highlights as bullets, then the link.
releaseNotes: {
  en_US: `Updated Ghost to 6.38.0.

- New: scheduled newsletter sends
- Fix: crash on backup restore
- Security: patched editor XSS

[Full release notes](https://github.com/TryGhost/Ghost/releases/tag/v6.38.0)`,
  // …
},

// Multiple categories spanning the wrapper and upstream: headers + bullets.
releaseNotes: {
  en_US: `Updated Ghost to 6.38.0 and bumped the start-sdk.

**Features**

- New: scheduled newsletter sends

**Fixes**

- Crash on backup restart

[Full upstream release notes](https://github.com/TryGhost/Ghost/releases/tag/v6.38.0)`,
  // …
},

Use a template literal (backticks) only when the note actually spans multiple lines, and never indent its content lines. If you genuinely can’t find upstream notes, link to the upstream commit/tag comparison instead.

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

Each migration also receives progress alongside effects — a FullProgressTracker you can add a phase to and update for long-running migrations, so the work shows up in the update progress bar. Destructure it only when you use it: up: async ({ effects, progress }) => ….

Use IMPOSSIBLE for the down migration when:

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

Warning

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

setupOnInit

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

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

Bootstrapping Config Files

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

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

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

Creating Tasks

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

// init/taskDisableRegistrations.ts
export const taskDisableRegistrations = 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 versionGit tag
26.0.0:0v26.0.0_0
26.0.0-rc.1:0v26.0.0-rc.1_0
0.13.5:2v0.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 with git 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: 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: [],
  })
})

Note

main is always setupMain. What you return from it is the daemon topology: the static sdk.Daemons.of(effects) chain above, or — for a daemon set that changes at runtime (one per tunnel, site, or account) — return sdk.Daemons.dynamic(effects, async ({ effects }) => …). The reconciler diffs the running daemons against a freshly-built list on each config change instead of restarting them all, and reconciles in place rather than restarting the service. Both are a DaemonBuildable. See Create Dynamic Daemons.

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 = sdk.SubContainer.of(
  effects,
  { imageId: 'my-app' },
  sdk.Mounts.of().mountVolume({
    volumeId: 'main',
    subpath: null,
    mountpoint: '/data',
    readonly: false,
  }),
  'my-app-sub',
)

Note

SubContainer.of() is lazy — it returns immediately and only materializes the filesystem on first use, so you pass it straight to addDaemon() with no await. If you need a synchronous .rootfs, .guid, or .subpath() before running anything, await the accessor or create it eagerly with sdk.SubContainer.eager(...).

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

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

Image Options

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

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

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

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:

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

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

Subset Reading

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

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

Other Reading Methods

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

Getting Hostnames

Interfaces are reached through their host. sdk.host.getOwn(effects, hostId) returns the host (hostId is the id you passed to sdk.MultiHost.of); the interface you exported lives under one of the host’s bindings, and its addressInfo comes back pre-filled — call .format(...) on it for resolvable hostnames/URLs (also .filter(...), .nonLocal, .public, .bridge, .toUrl):

const host = await sdk.host.getOwn(effects, 'ui').const()
const ui = Object.values(host?.bindings ?? {})
  .flatMap(b => Object.values(b.interfaces))
  .find(i => i.id === 'ui')

const allowedHosts = ui?.addressInfo.format('hostname-info').map(h => h.hostname.value) ?? []

.const() sets up a reactive watcher — setupMain re-runs whenever the host’s bindings, addresses, or exported interfaces change.

To react to only a slice of the host, pass a map selector (and optional eq, default deep-equal) to getOwn/get. .const() then re-runs only when the mapped value changes rather than on any change to the whole host:

// re-run only when THIS interface's address info changes
const ui = await sdk.host.getOwn(effects, 'ui', host => host?.bindings[80]?.interfaces['ui']).const()

Oneshots (Runtime)

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

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

Warning

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

Exec Command

Using Upstream Entrypoint

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

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

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

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

When to use sdk.useEntrypoint():

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

Custom Command

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

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

Running the Entrypoint as PID 1 (runAsInit)

Some images bundle their own init system or process supervisor — s6-overlay (used by every linuxserver/* image), tini, dumb-init, or supervisord — and that supervisor expects to run as PID 1. In a StartOS subcontainer the daemon command is not PID 1 by default, so such a supervisor aborts on startup (s6 logs s6-overlay-suexec: fatal: can only run as pid 1). Set runAsInit: true on the exec to make the command the container’s init process:

.addDaemon('primary', {
  subcontainer: appSub,
  exec: {
    command: sdk.useEntrypoint(),
    runAsInit: true, // image bundles s6-overlay / tini / supervisord, which must be PID 1
    env: { PUID: '1000', PGID: '1000', TZ: 'Etc/UTC' },
  },
  // ...
})

When to use runAsInit: true:

  • The image uses s6-overlay (any linuxserver/* image), tini, dumb-init, or supervisord as its entrypoint
  • The daemon starts but its supervisor immediately crashes complaining it is not PID 1

Leave it off (the default) for images whose entrypoint is the application binary itself. (runAsInit is declared on the exec options in Daemons.d.ts — like many SDK options, it’s easier to find by grepping the types than by searching the docs; see Search the SDK before deciding something is impossible.) See Package a Prebuilt Docker Image for the full prebuilt-image workflow.

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:

ResultMeaningWhen to use
successHealthy and fully operationalService is ready and serving
loadingOperational but catching upSyncing blocks, indexing data
disabledIntentionally inactiveFeature excluded by config (e.g. onlynet)
startingNot yet readyStill initializing (also set automatically during gracePeriod)
failureUnhealthyProcess 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.Mounts is an immutable builder. Every mountVolume / mountAssets / mountDependency / mountBackups call returns a new Mounts instance — 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 a FileHelper.string(...).read() returns null or a subcontainer read fails.

Remapping Ownership (idmap)

Every mount (mountVolume / mountAssets / mountDependency / mountBackups) takes an optional idmap — a list of { fromId, toId, range? } entries that remap ownership at the mount boundary, so files stored under one uid/gid on the volume appear under the uid/gid the service expects. fromId is the id seen on the filesystem, toId is the id processes in the container see, and range (default 1) covers that many consecutive ids. The container’s own LXC id-mapping is applied automatically — don’t include it here.

sdk.Mounts.of().mountVolume({
  volumeId: 'main',
  subpath: null,
  mountpoint: '/data',
  readonly: false,
  idmap: [{ fromId: 0, toId: 1000 }], // files owned by uid 0 on the volume appear as uid 1000 in the container
})

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(`${await appSub.rootfs}/app/config.py`, generateConfig({ secretKey, allowedHosts }))

Warning

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

When to use rootfs vs volume mounts:

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

Executing Commands in SubContainers

Use exec or execFail to run commands in a subcontainer:

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

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

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

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

Use execFail() when:

  • The command must succeed for the service to work correctly
  • You are in a custom init file (e.g. seedFiles.ts) and want installation to fail if setup fails
  • You want automatic error propagation

Use exec() when:

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

PostgreSQL Sidecar

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

Security Model

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

Password generation (in utils.ts):

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

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

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

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

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

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

Seed on upgrade (in version migration):

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

Daemon Configuration

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

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

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

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

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

Key points:

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

Connection Strings

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

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

Note

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

Querying PostgreSQL from Actions

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

import { Client } from 'pg'

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

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

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

Warning

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

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

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

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

Config File Generation

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

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

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

Initialization

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

KindWhenUse For
'install'Fresh installGenerate internal secrets, seed file-model defaults, create critical tasks for user setup actions, bootstrap via API
'update'After a package version upgradeRe-apply config, handle post-migration setup
'restore'Restoring from backupRe-register triggers; credentials are already present from the restored store
nullContainer rebuild, server restartRegister 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 seedFiles = 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 reRegisterWebhook = 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 registerWatchers = 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 a custom init function

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 { seedFiles } from './seedFiles'

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

export const uninit = sdk.setupUninit(versionGraph)

runUntilSuccess Pattern

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

Oneshots Only

For simple sequential tasks (like database migrations):

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

Daemon + Dependent Oneshot

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

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

How it works:

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

Making HTTP Calls Without curl

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

Node.js (v18+):

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

Python:

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

Reporting Init Progress

Init progress is surfaced in the Installing / Updating phase of the install, so a long first-run setup (migrations, bootstrapping a server, downloading assets) shows a moving bar instead of an apparent stall. This mirrors backup progress reporting.

You never call the progress effect directly. The init harness builds one FullProgressTracker and passes it to every init handler as a third argument. Each handler adds its own phases (with its own names) to the shared tracker, unaware of the others. Add phases and update them — every update auto-reports to the host in the background, so there’s nothing to flush by hand.

progress.addPhase(name, contribution) returns a PhaseHandle with start(), setTotal(n), setDone(n), setUnits('steps' | 'bytes'), and complete(). Just update the handle; the report follows automatically.

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

  const phase = progress.addPhase('Seeding files', 1)
  phase.setUnits('steps')
  phase.setTotal(seedFiles.length)

  for (let i = 0; i < seedFiles.length; i++) {
    await seedFiles[i](effects)
    phase.setDone(i + 1) // auto-reports in the background
  }

  phase.complete()
})

Auto-sync is coalesced — at most one report is in flight and one queued, so a tight update loop collapses to the latest snapshot instead of stacking up calls. If you ever need to guarantee the latest state has landed before doing something else, await progress.sync() flushes the in-flight and queued reports (the harness already does this when your handler returns).

Note

Progress reporting is a no-op outside the install / update / restore transition, so updating phases on a plain container rebuild is harmless. If you need to construct a tracker yourself (rare), it’s available as utils.FullProgressTracker; no deep import.

Reporting Progress From a Migration

Migrations receive the same kind of tracker through their opts, so a slow data migration shows progress during an update instead of stalling the bar:

// versions/v2_0_0.ts
import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk'
import { i18n } from '../i18n'

export const v2_0_0 = VersionInfo.of({
  version: '2.0.0:0',
  releaseNotes: i18n('Reticulated splines'),
  migrations: {
    up: async ({ effects, progress }) => {
      const records = await loadRecordsToReencode()
      const phase = progress.addPhase('Re-encoding records', 1)
      phase.setUnits('steps')
      phase.setTotal(records.length)

      for (let i = 0; i < records.length; i++) {
        await reencode(records[i])
        phase.setDone(i + 1) // auto-reports in the background
      }

      phase.complete()
    },
    down: IMPOSSIBLE,
  },
})

Multi-phase Handlers

For a handler with several distinct sub-tasks, add one phase per task. The tracker weights them by their contribution and reports a combined percentage:

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

  const dbPhase = progress.addPhase('Initializing database', 1)
  const seedPhase = progress.addPhase('Seeding admin user', 1)

  dbPhase.start()
  await initDatabase(effects)
  dbPhase.complete()

  seedPhase.start()
  await seedAdminUser(effects)
  seedPhase.complete()
})

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 seedFiles = 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) return is 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() (not write()) 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 fill in every missing default. See File Models — Prefer merge() Over write() and What an Empty merge() Does.

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.

Network Reachability

Your package declares what it exposes. The user decides where it is reachable. An interface is bound to the server’s gateways, and the user enables or disables each resulting address individually from the service’s Interfaces tab. LAN addresses (the .local hostname, the LAN IP) are enabled by default; public IPv4 addresses are off by default.

Two consequences worth internalizing before you write any interface code:

  • type is a label, not a control. 'ui', 'api', and 'p2p' tell the user what an interface is for. They do not select a transport, grant public access, or imply anything about how the interface is reached.
  • Tor is opt-in and per-interface. Tor is not part of StartOS. The user installs the Tor service from the marketplace, and then explicitly adds an onion address to each interface they want on Tor — see Tor. Nothing your package does provisions one.

Warning

Never state — in README.md, instructions.md, a comment, or a plan — that a service “is exposed on Tor” or “is published to the internet.” Your package cannot know: no binding type, and no value of type, causes an onion or a clearnet address to exist. Describe what the interface serves and let the user decide how to reach it.

Single Interface

For a service with one web interface:

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

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

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

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

Multiple Interfaces

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

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

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

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

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

Expose interfaces on separate ports:

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

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

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

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

  return receipts
})

The key steps are:

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

bindPort Options

OptionTypeDescription
protocol'http' | 'https' | nullThe protocol. Use null for raw TCP (non-HTTP).
preferredExternalPortnumberThe port users will see in their URLs.
addSslobject | nullSSL termination options for HTTPS. Set to null for no SSL.
addSsl.alpnstring | nullALPN protocol negotiation (e.g., 'h2'). Usually null.
addSsl.preferredExternalPortnumberExternal port for SSL connections.
addSsl.addXForwardedHeadersbooleanWhether to add X-Forwarded-* headers.
addSsl.authProxyAuth | nullOptional auth gate enforced by the OS reverse proxy. See Authenticating at the Proxy.
addSsl.upstreamCertValidation'disable' | { certificate: string } | omittedHow the OS validates your container’s TLS cert when it rewraps SSL. Omit to validate against the StartOS root CA (default). See Rewrapping SSL.
secure{ ssl: boolean } | nullFor non-HTTP protocols, whether the connection is secure.

Interface Options

sdk.createInterface(effects, {
  name: i18n('Display Name'), // Shown in UI (wrap with i18n)
  id: 'unique-id', // How you find this interface under its host
  description: i18n('Description'), // Shown in UI (wrap with i18n)
  type: 'ui', // 'ui', 'api', or 'p2p'
  masked: false, // Hide URLs with sensitive credentials?
  schemeOverride: null, // Override URL scheme (see below)
  username: null, // Auth username embedded in URL
  path: '/some/path/', // URL path
  query: {}, // URL query params
})
OptionTypeDescription
namestringDisplay name shown to the user. Wrap with i18n().
idstringUnique identifier. How you find this interface at runtime, by walking the host from sdk.host.getOwn() (see main.ts).
descriptionstringDescription shown to the user. Wrap with i18n().
type'ui', 'api', or 'p2p''ui' for browser interfaces, 'api' for programmatic endpoints, 'p2p' for peer-to-peer connections.
maskedbooleanIf true, the interface URL is shown as a copyable secret. Use for URLs containing credentials or tokens.
schemeOverride{ ssl: string | null; noSsl: string | null } | nullOverride the URL scheme for custom protocols. For example, { ssl: 'lndconnect', noSsl: 'lndconnect' } produces lndconnect:// URLs. Use null for standard http/https.
usernamestring | nullUsername embedded in the URL (e.g., for smp://fingerprint:password@host).
pathstringURL path appended to the base address (e.g., '/admin/').
queryobjectURL query parameters as key-value pairs (e.g., { macaroon: 'abc123' }).

Tip

The id you assign to an interface is what you use in main.ts to retrieve hostnames for it. Interfaces are reached through their host: sdk.host.getOwn(effects, hostId) returns the host, and the interface lives at host.bindings[internalPort].interfaces[id]. See Main for details.

Port Ranges

Some services need a contiguous block of ports rather than a single one — coturn / RTP media relays, bitcoin’s ZMQ notification endpoints, passive-FTP data ports. Use bindPortRange instead of one bindPort per port:

export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
  const turn = sdk.MultiHost.of(effects, 'turn')
  const range = await turn.bindPortRange({
    internalStartPort: 49152,
    externalStartPort: 49152, // may differ; the forward maps by offset
    numberOfPorts: 100, // 2–500 contiguous ports
  })

  await range.export(
    sdk.createRangeInterface(effects, {
      id: 'turn-relay',
      name: i18n('TURN Relay'),
      description: i18n('WebRTC media relay ports'),
    }),
  )
  return []
})

A range binds TCP + UDP together and exposes exactly one api service interface spanning the whole range. The interface is deliberately restricted compared to createInterface: it is always type: 'api' and has no masked, username, path, query, or schemeOverride. The one extra option is an optional scheme — a transport prefix for protocols addressed as scheme://host:port, e.g. tcp for bitcoin ZMQ:

const zmq = sdk.MultiHost.of(effects, 'zmq')
const zmqRange = await zmq.bindPortRange({
  internalStartPort: 28332,
  externalStartPort: 28332,
  numberOfPorts: 2,
})
await zmqRange.export(
  sdk.createRangeInterface(effects, {
    id: 'zmq',
    name: i18n('ZMQ'),
    description: i18n('Bitcoin ZMQ notification endpoints'),
    scheme: 'tcp', // omit for raw UDP/TCP ranges (coturn, RTP, FTP data)
  }),
)

Two distinct endpoints are two bindPortRange calls — a range is a homogeneous pool of ports, so it maps to one named interface. Range interfaces show up in the service’s Interfaces page using the same per-gateway address cards as single-port interfaces (non-SSL, IPv4-only). The public/WAN address is disabled by default; enabling it surfaces the exact port range to forward on the router.

createRangeInterface optionTypeDescription
idstringUnique identifier for the range interface.
namestringDisplay name shown to the user. Wrap with i18n().
descriptionstringDescription shown to the user. Wrap with i18n().
schemestring | nullOptional transport prefix (e.g. 'tcp'). Omit for raw UDP/TCP ranges.

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://.

Rewrapping SSL to a TLS container

The guidance above (“do not configure in-container HTTPS”) applies when StartOS terminates TLS and forwards plain HTTP — the http/ws protocols. The https/wss protocols are different: the container serves its own TLS, StartOS terminates the client’s TLS at the edge, and then opens a fresh TLS connection to your container (a “rewrap”). This happens whenever addSsl is set and the protocol’s secure.ssl is true.

On that inner OS→container leg, StartOS validates your container’s certificate. By default it requires a certificate signed by the StartOS root CA. A container serving a self-signed certificate on the internal bridge will fail that check, so use addSsl.upstreamCertValidation to control it:

ValueBehavior
omittedValidate against the StartOS root CA (default).
'disable'Skip certificate validation entirely. Appropriate for a self-signed cert on the trusted internal bridge.
{ certificate: '<pem>' }Validate against the supplied PEM certificate/chain instead of the root CA.
const origin = await multi.bindPort(443, {
  protocol: 'https',
  addSsl: {
    upstreamCertValidation: 'disable', // container serves its own self-signed cert
  },
})

Note

For { certificate }, StartOS connects to the container by IP, so the pinned certificate must be valid for that internal IP (present in its SANs). If it isn’t, use 'disable' instead.

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 fieldTypeDescription
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.
realmstring | nullRealm 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 username field on createInterface is unrelated to this gate — it only embeds a username in the displayed URL (e.g. https://user@host/). The enforced credential check is addSsl.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.

Service-to-Service Networking

Interfaces covers how your service exposes ports inbound. This page covers the reverse: how your service reaches another service at runtime — a wallet dialing Bitcoin’s RPC, an indexer dialing Bitcoin’s P2P port, anything dialing Tor’s SOCKS proxy.

There is exactly one supported way to do this, and two once-common patterns that are now forbidden.

The host bridge

Every StartOS service runs in its own container on a single internal bridge, lxcbr0. The bridge gateway — the OS itself — always sits at a fixed address you read with:

const osIp = await sdk.getOsIp(effects) // "10.0.3.1" — a plain Promise, not reactive

getOsIp is a one-shot read of a compile-time constant. It never changes and never needs watching.

Every port a service binds (via sdk.MultiHost.of(...).bindPort(...) in interfaces.ts) is reachable from other containers at 10.0.3.1:<assigned external port>. This holds even for a binding with no exported interface — binding a port is enough to make it reachable on the bridge (see Exposing a bridge-only port below).

Important

The bridge exposes a service at its assigned external port, which you must not assume. preferredExternalPort is a request, not a guarantee: the first service to claim a given external port gets it and later claimants fall back to a random port, and when preferredExternalPort is omitted it defaults to the protocol default (http → 80). So the external port is only knowable at runtime, by reading the dependency’s live binding. What is stable is the dependency’s internal port and host id — import those as constants from the dependency’s package.

Reaching a dependency

Resolve a dependency’s bridge address by reading its host and mapping to <osIp>:<assignedPort>:

const osIp = await sdk.getOsIp(effects)

const rpcAddr = await sdk.host
  .get(effects, { packageId: 'bitcoind', hostId: rpcHostId }, host => {
    const port = host?.bindings[rpcInternalPort]?.net.assignedPort
    return port != null ? `${osIp}:${port}` : null
  })
  .const()

Three things make this correct, and each matters:

  1. Read net.assignedPort, keyed by the dependency’s internal port — not an addressInfo hostname. assignedPort is the minimal thing that identifies the bridge address, and it changes only when the external port changes. A binding’s addressInfo hostname list, by contrast, folds in things that have nothing to do with reaching it over the bridge — the box’s LAN IP changing, a Tor or clearnet address being added or removed — any of which would change a hostname-derived value and needlessly restart your service. osIp + assignedPort is stable against all of that. rpcHostId and rpcInternalPort are imported from the dependency’s package (bitcoin-core-startos/startos/utils), not hardcoded — the internal port and host id are the stable contract.

  2. .const() on a minimal mapped value. .const() re-runs main only when the mapped value changes, not on every churn of the dependency’s host record. Because the mapped value is just the address string, the restart behavior is exactly what you want:

    EventRestarts your service?
    Dependency updatedNo — its assigned port survives an update
    Dependency installed after yoursOnce, to heal onto the now-resolvable address
    Dependency uninstalledOnce, to reconfigure to the absent state
    Dependency reinstalled, same portNo
    Dependency reinstalled, new portOnce, to heal
    Conditional binding appears (e.g. LND unlock)Once, then stable across lock/unlock

    Do not use .once() (it snapshots null forever if the dependency isn’t installed yet — your service never heals when the user installs the dependency second) or .waitFor() (it blocks main before any daemon or health check exists, leaving the service stuck “starting” with no signal). .const() on the minimal value is the only option that both avoids needless restarts and self-heals.

  3. Absent means absent — never fabricate an address. When the map returns null (dependency not installed), write nothing for that dependency: leave the config key out, make the file-model field .optional().catch(undefined), omit the env var. Let the dial fail and the health check go red. Never write a placeholder like 127.0.0.1:8332 for a cross-container dependency — that address can’t reach the dependency’s container and only masks the real state. The .const() heals the moment the dependency appears.

A reusable helper

Packages wrap the pattern above in a small utils.ts helper so each call site stays a one-liner. This is the fleet convention and a drop-in for the planned sdk.host.getBridgeAddress:

export function bridgeAddress(effects: T.Effects, opts: { packageId: string; hostId: string; internalPort: number; fallbackPort?: number }) {
  const watchable = async () => {
    const osIp = await sdk.getOsIp(effects)
    return sdk.host.get(effects, { packageId: opts.packageId, hostId: opts.hostId }, host => {
      const port = host?.bindings[opts.internalPort]?.net.assignedPort ?? opts.fallbackPort
      if (port == null) return null
      return `${osIp}:${port}`
    })
  }
  return {
    const: async () => (await watchable()).const(), // reactive, in main / init
    once: async () => (await watchable()).once(), // snapshot, in an action
  }
}

Use .const() in setupMain and setupOnInit; use .once() only inside an action, where a live snapshot (not a subscription) is what you want.

The Tor exception: always-on flags

Some flags should be passed unconditionally, even when the dependency is absent — most commonly Bitcoin’s -onion=<tor SOCKS>. A dead bridge address there is harmless (connection refused), and passing the flag always means Tor works the moment it’s installed with no reconfiguration.

For this, and only this, use fallbackPort so the value is never null:

const torSocks = await bridgeAddress(effects, {
  packageId: 'tor',
  hostId: socksHostId,
  internalPort: socksPort, // 9050
  fallbackPort: socksPort, // keeps the value at `${osIp}:9050` when Tor is absent
}).const()

Tor’s SOCKS port (9050) is the one external port StartOS guarantees is claimable, so ${osIp}:9050 is always valid. This is not a license to fabricate addresses generally (see rule 3 above) — it applies to Tor’s SOCKS proxy, whose address is fixed and whose flag is inert when unreachable.

Track a dependency’s presence (for a health check, say) with sdk.getStatus(effects, { packageId }).onChange(...), registered unconditionally — it returns null when the dependency is uninstalled and re-fires when it’s installed. Never gate the watch itself behind a startup-time presence check.

State that a config value is derived from

Sometimes an address depends on a choice the user made — which of several interchangeable backends to use (Fulcrum vs. Electrs, LND vs. CLN). That choice is StartOS-level state. It belongs in your package’s own store.jsonnever as an invented key in the upstream service’s config file, which may contain only keys the upstream software recognizes.

A package keeps StartOS state in a single store.json file model (see File Models). If your package has no other on-disk state to colocate it with, put it on the dedicated startos volume:

// store.json.ts — StartOS state, kept out of the upstream config
const shape = z.object({
  indexer: z.enum(['electrs', 'fulcrum']).optional().catch(undefined),
})
export const storeJson = FileHelper.json({ base: sdk.volumes.startos, subpath: '/store.json' }, shape)

Declare the volume in the manifest (volumes: [..., 'startos']) and add it to the backup set if the choice must survive a restore. setupDependencies and the selection action read/write store.json; init reads the choice, resolves that backend’s bridge address, and writes only the real upstream keys into the app config. It is a bug to add a discriminator field (INDEXER, BACKEND_CHOICE, …) to a file model that maps the upstream service’s own config file.

Exposing a bridge-only port

If you are on the provider side — you want other services to reach a port but you do not want it on the LAN — bind the port and simply don’t export an interface on it. A binding with no exported interface is reachable on lo/lxcbr0 only, never the LAN. This is how the Tor service publishes its SOCKS proxy:

// tor-startos/startos/interfaces.ts
await sdk.MultiHost.of(effects, socksHostId).bindPort(socksPort, {
  protocol: null,
  preferredExternalPort: socksPort,
  addSsl: null,
  secure: { ssl: false },
})
// no origin.export([...]) — bridge/lo only, off the LAN

Export the host id and internal port as constants so dependents import them rather than hardcoding.

Forbidden patterns

Two patterns that older packages used are being removed. Do not introduce them:

  • <package-id>.startos DNS names (http://bitcoind.startos:8332). The overlay DNS that resolved these is deprecated and will be removed. Resolve the bridge address instead.
  • Cross-package container IPs (sdk.getContainerIp(effects, { packageId })). A dependency’s container IP is not stable across its restarts/updates and reading it reactively restarts your service on every dependency churn. Use the bridge. (getContainerIp with no packageId — your own container IP — remains fine.)

Reference implementations

  • bitcoin-corestartos/utils.ts (bridgeAddress helper) and startos/main.ts (torSocks with the fallbackPort case).
  • lndstartos/utils.ts resolves bitcoind’s RPC and ZMQ hosts; a conditional (unlock-gated) binding handled by the same .const() pattern.
  • mempoolstartos/utils.ts + startos/file-models/store.json.ts: the backend-selection choice in store.json, the resolved address written to the upstream config’s real keys.

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'
    access: 'user', // 'public' | 'dependent' | 'user' — who may invoke directly via effects.action.run (default 'user')
  }),

  // 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.

Controlling Access

The optional access field on the metadata controls who may invoke the action directly via effects.action.run({ packageId, actionId, input }):

  • 'user' (default) — only the user; another service must request it through a task (effects.action.createTask(...)).
  • 'dependent' — only services that declare this package as a current dependency.
  • 'public' — any installed package.

access is independent of visibility (whether the action is shown/enabled) and allowedStatuses (which run states permit it); a direct cross-package run is rejected if access denies the caller.

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

  1. Start with registrations enabled in the initial config.
  2. Create an important task in setupOnInit advising the user to disable registrations after creating their admin account.
  3. Provide a toggle action that reads the current registration state, flips it, and writes back.
// In init/taskDisableRegistrations.ts
export const taskDisableRegistrations = 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.

Generating Values in a Form

When a form field holds a secret, don’t generate it in package code. Value.text accepts a RandomString spec — { charset, len } — in two places, and StartOS does the generating:

password: Value.text({
  name: i18n('Password'),
  description: i18n('Leave as generated, or choose your own'),
  required: true,
  masked: true,
  // Pre-fill the field with a fresh random value each time the form opens
  default: { charset: 'a-z,A-Z,0-9', len: 32 },
  // …and/or render a "generate" button that refills it on demand
  generate: { charset: 'a-z,A-Z,0-9', len: 32 },
}),

default also takes a plain string when you want a fixed literal. The same RandomString shape is what utils.getDefaultString resolves in a withoutInput handler — between the two, package code never needs its own random-string generator.

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

Don’t as const What the SDK Already Types

Action metadata and results are contextually typed by the SDK’s own signatures — version: '1' is declared as the literal '1', and visibility, allowedStatuses, access, and type are unions, not string. Write the literal and stop:

// GOOD — the SDK narrows these for you
return { version: '1', title: i18n('Login Credentials'), ... }

// NOISE — asserts something the compiler already knows
return { version: '1' as const, ... }

tsc passes either way, which is why the assertions spread by copy-paste. They aren’t load-bearing anywhere in an action file; drop them when you see them. (Distinct from an as cast, which claims the compiler is wrong — reach for that only when it actually is.)

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 for setupMain — 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/seedFiles.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

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

Severity Levels

  • critical — Blocks the service from starting until the user completes the task. Use for essential setup like 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',
      accept: [
        {
          /* one or more acceptable partial inputs */
        },
      ],
      set: {
        /* the value to pre-fill when none are accepted */
      },
    },
    when: { condition: 'input-not-matches', once: false },
    reason: i18n('Configure the dependency for use with this service'),
  })

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

Parameters

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

Options

FieldTypeDescription
input{ kind: 'partial', accept: Partial<InputSpec>[], set: Partial<InputSpec> }accept lists the partial inputs that satisfy the task; set pre-fills the action’s input form when none of them match
when{ condition: 'input-not-matches', once: boolean }Re-trigger until the action’s input matches one of the accept values
reasonstringHuman-readable explanation shown to the user
replayIdstring (optional)Overrides the default idempotency key (see below)

With condition: 'input-not-matches', the task is satisfied when the action’s current input is a superset of any entry in accept (each entry is matched partially — only the fields you list must agree). When none match, the task is shown and the action form is pre-filled with set. Use multiple accept entries to tolerate several already-good configurations while still steering the user to one recommended value; for the common case where any value but one specific target is unacceptable, pass a single accept entry equal to set.

Note

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

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

ParameterTypeDescription
effectsEffectsProvided by the calling context
level'success' | 'info' | 'warning' | 'error'Severity, controls the icon and color in the panel
titlestringShort headline shown in the row
messagestringOne-line body shown in the row beneath the title
datastring | 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({
  // 10.0.3.1 is the OS bridge gateway — the reverse proxy this container should
  // trust. It is the OS's own fixed address (see Service-to-Service Networking),
  // not a dependency dial, so the literal is correct here.
  string: z.literal('10.0.3.1').array().catch(['10.0.3.1']),
})

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

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

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

Reading File Models

Reading Methods

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

Note

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

Use the Map Function

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

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

// GOOD: daemon only restarts when secretKey changes
const secretKey = await storeJson.read(s => s.secretKey).const(effects)

Warning

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

Examples

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

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

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

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

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

Writing File Models

Prefer merge() Over write()

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

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

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

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

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

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

What an Empty merge() Does

Every merge() — including merge(effects, {}) — reads the file, parses it through your schema, deep-merges the patch over the parsed value, re-serializes, and writes only if the result differs from what was on disk. With an empty patch, against a file that already exists:

Existing contentResult
Key present and validUntouched
Key missingFilled from its .catch() default
Key present but invalidReplaced by its .catch() default — this is the self-healing
Key outside the schemaPreserved (see Unknown Key Preservation)
Comments, formattingNot preserved — the file is re-serialized from the parsed value

So merge(effects, {}) is safe to call on every init: it seeds a fresh file, repairs a corrupted one, and does nothing to a file that already round-trips cleanly. The parse is why every key needs .catch() — a value the schema can’t repair makes merge() throw rather than heal.

Warning

An empty merge is not a way to clean, strip, or regenerate a config. It never removes a key you didn’t name — pass the key explicitly as undefined for that. And because it re-serializes, the first empty merge against a hand-written or upstream-generated .toml/.yaml discards its comments.

Exporting Defaults from File Models

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

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

export const defaultMaxUpload = '50M'

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

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

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

This keeps the default defined in exactly one place.

Schema Design

Every Key Should Have .catch()

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

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

Nested Objects Must Also Have .catch()

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

The problem:

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

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

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

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

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

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

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

Note

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

Deep Nesting

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

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

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

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

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

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

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

Hardcoded Literal Values

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

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

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

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

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, at every nesting level. 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.

Important

Import z from @start9labs/start-sdk and use z.object. You never need z.looseObject. Plain zod’s z.object strips unknown keys, so a reader who knows zod reaches for looseObject to protect a two-way-bound config file — but the SDK’s z.object already preserves them, deeply. z.looseObject is still exported and still compiles; it is not the convention.

This has two important consequences:

  1. merge() never removes keys you don’t mention. Only keys explicitly passed to merge() are updated. Everything else — including keys outside your schema — passes through untouched.
  2. 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)

Warning

This removes a stale key your schema doesn’t model. It cannot surgically delete one entry of a typed collection that has a .catch() default. merge({ users: { bob: undefined } }) against users: z.record(...).catch({}) makes the whole users value fail validation, so the .catch({}) replaces the entire record with {} — every entry is wiped, not just bob. To drop one entry while keeping the rest, rebuild the value in code and write() it.

Arrays Are Replaced, Not Merged

merge() recurses into plain objects key by key, but it treats arrays and primitives as atomic values — whatever you pass replaces what was there. For an array this means no element-wise union, append, or de-duplication: the array in your patch becomes the new value in its entirety.

// stored: { friends: ["alice", "bob"] }
await storeJson.merge(effects, { friends: ['alice'] })
// result: { friends: ["alice"] }  — "bob" is dropped, not preserved

This produces an asymmetry that is easy to get wrong, because object keys and array elements behave oppositely under the same merge() call:

What you mergeWhat happens to what you left out
An object without a keyThe key is keptmerge() never deletes a key you don’t mention (see Unknown Key Preservation)
An array without an entryThe entry is gone — the whole array is overwritten

So to change one entry of an array, read the current array, edit it in code, and merge the complete new array — you cannot add or drop a single element by passing a one-element patch. When you instead need to remove an object key (or otherwise rebuild a structure wholesale), reach for write(), which replaces the entire file.

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 = 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 (their ready IDs) or standalone health checks (addHealthCheck IDs) 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',
      accept: [
        {
          /* one or more acceptable partial inputs */
        },
      ],
      set: {
        /* the value to pre-fill when none are accepted */
      },
    },
    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', accept: Partial<InputSpec>[], set: Partial<InputSpec> },
    when?: { condition: 'input-not-matches', once: boolean },
    reason: string,
    replayId?: string,       // prevents duplicate task execution
  }
)

Note

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

Important

accept entries are matched against the dependency’s resolved action input, not its raw config file. That input is the dependency action’s prefill — its config parsed through its file model — so an optional field comes back carrying its resolved default, never a missing key. bitcoind’s prune, for example, reads as the number 0 on an unpruned node (its file model coerces an absent prune to 0), so accept: [{ prune: 0 }] matches an unpruned node exactly. Match the concrete value the input actually holds.

An accept field value is compared for equality: null matches the literal value null — nothing else. It is not a wildcard and does not stand in for a defaulted or absent field. To leave a field unconstrained, omit it from the entry — an absent (undefined) key is not checked at all. (undefined means absence, not null: writing undefined for a field in a set/config would delete that key.) To require a specific value, name it. Multiple entries mean “any of these matches.”

Reaching a Dependency at Runtime

A dependency is reached over the internal host bridge, by resolving its live bridge address (10.0.3.1:<assigned port>) with sdk.host.get(...).const().

The mechanics — reading net.assignedPort, the .const() restart matrix, the bridgeAddress, the Tor fallbackPort case, and where a backend-selection value belongs — are all in Service-to-Service Networking. Read that page before dialing any dependency.

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

A StartOS package’s Makefile carries only project-specific configuration and includes the shared build logic (s9pk.mk) that ships inside the SDK.

File Structure

my-service-startos/
└── Makefile     # Project-specific config; includes the SDK's s9pk.mk

s9pk.mk

The s9pk.mk file contains all the common build logic shared across StartOS packages. It ships inside the published SDK (@start9labs/start-sdk), so your Makefile includes it straight from node_modules — there’s nothing to vendor or copy into the package, and bumping the SDK delivers build-system fixes automatically.

Targets

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

Variables

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

Makefile

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

include node_modules/@start9labs/start-sdk/s9pk.mk

Adding Custom Targets

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

TARGETS := generic rocm
ARCHES := x86 arm

include node_modules/@start9labs/start-sdk/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 node_modules/@start9labs/start-sdk/s9pk.mk:

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

include node_modules/@start9labs/start-sdk/s9pk.mk

Build Commands

# Build for all architectures
make

# Build for a specific architecture
make x86
make arm

# Install to StartOS server (requires a workspace whose .startos/config.yaml points at your device)
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 universal install      # Clean, build universal, then install

Prerequisites

Building signs the package with your workspace signing key, so the package must live inside a packaging workspace. If you haven’t created one yet, do that first — see Environment Setup — Set Up Your Packaging Workspace. Running make without a workspace fails with a message telling you to run start-cli s9pk init-workspace.

The build also needs the tools from Environment Setup — Docker (running), make, Node.js/npm, start-cli, git, and jq.

Installation

make install builds nothing on its own — it uploads the most recently built .s9pk to a StartOS device, so build first (for development, just your device’s architecture — make x86 or make arm). It resolves the device from your workspace .startos/config.yaml (the host.default profile) or an explicit -H.

  1. Point your workspace at the device. Edit .startos/config.yaml (at the workspace root, not ~/.startos/config.yaml) so host.default is your device’s address:

    host:
      default: https://your-device.local
    
  2. Log in once. start-cli needs a session on the device:

    start-cli auth login
    

    Enter your StartOS master password when prompted.

  3. Build and install for your device’s architecture (x86 or arm) — the fast path for development:

    make x86 install       # or: make arm install
    

    (make install on its own installs the most recent build. Reach for make universal only when publishing — building all architectures is slower and unnecessary for local testing.)

Note

make install talks to the device over HTTPS, so your computer must trust the device’s certificate — the same trust you set up to open its web interface in a browser. If it isn’t trusted yet, import the device’s root CA into your system trust store; or, for a one-off, sideload the .s9pk through the web interface instead (see Sideloading), which needs no certificate setup.

To install to a device other than host.default, run start-cli directly with -H (a profile name or URL): start-cli -H prod package install -s <your-package>.s9pk.

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:

  1. Humans – clear, scannable, with practical examples
  2. AI assistants – structured data that can be parsed programmatically
<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 AGENTS.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

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 DocumentExample
Image sourceUpstream unmodified, or custom Dockerfile
Architecturesx86_64, aarch64, riscv64
EntrypointDefault or custom

Volume and Data Layout

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

Installation and First-Run Flow

Document if your package:

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

Configuration Management

Use a table to clarify the division of responsibility:

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

Actions

For each action, document:

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

Network Interfaces

For each interface:

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

Backups

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

Health Checks

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

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

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.

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.

READMEinstructions.md
AudienceDevelopers, AI assistants, contributorsEnd 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?”
ToneTechnical, structured, scannable for parsingPractical, instructional, written in second person
Versions / image tagsAvoided (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 whereThe package repository on GitHubInside 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:

  1. 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.

  2. Documentation links. A ## Documentation section. Port exactly the URLs the manifest previously carried in its docsUrls array, 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 from docsUrls. Link to canonical, stable URLs the upstream maintains — not specific commits, not your own README.

  3. 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.

  4. 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.”

  5. 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 parrot allowedStatuses from 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.

  6. 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 docsUrls array for upstream documentation links. That field has been removed — those links belong in the ## Documentation section 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.md at 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”; allowedStatuses is 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 ## Documentation section ports the URLs the manifest’s docsUrls previously 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:

Initial Submission

  1. Email submissions@start9.com with a link to your public GitHub repository.
  2. Start9 forks your repo into the Start9-Community GitHub organization and replies with any feedback.
  3. 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:

  1. Open a PR against the fork with your changes.
  2. 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.
  3. 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.
  4. 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.

  1. Tag convention followed. Your version tag matches Git Tag Conventions.
  2. All checks pass. tsc --noEmit, tests, and the pack step must be green.
  3. README is current. Every action, volume, port, dependency, and limitation matches the code. No version numbers anywhere — see Writing READMEs.
  4. 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 .s9pk packages
  • Registry — manage packages, categories, signers, and OS versions on a registry

Starting out, the two commands you’ll use first are s9pk init-workspace (create a workspace) and s9pk init-package (scaffold a package) — see Environment Setup and Quick Start.