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
- Set up your environment — Follow Environment Setup, including the Claude Code section.
- Build your first package — Follow Quick Start to create, build, and install the Hello World template.
- Use recipes to build your service — Browse Recipes to find the patterns you need. Each recipe describes what to do, links to reference pages for API details, and points to real packages for working code. Your AI agent reads these docs and writes the code.
Recipes
Intent-driven guides for common packaging tasks. These are the primary entry point for both you and your AI coding agent.
- What Do You Want To Do? - Browse all recipes by intent
Getting Started
- Environment Setup - Install the required development tools
- Quick Start - Create, build, and install your first package
- Development Workflow - How to behave while working on a package
Reference
- Project Structure - Understand the file layout of a StartOS package
- Manifest - Define your service metadata and release notes
- Versions - Handle install, update, and downgrade logic
- Main - Configure daemons, health checks, and the service lifecycle
- Initialization - Run code when your service initializes
- Interfaces - Expose network interfaces to users
- Actions - Define user-facing buttons and scripts
- Tasks - Prompt users to run actions at the right time
- File Models - Represent and validate configuration files
- Dependencies - Declare and configure service dependencies
- Makefile - Automate build and install workflows
- Writing READMEs - Write effective service documentation
Environment Setup
Before building service packages, you need to install several development tools on your workstation. This page lists each prerequisite and how to install it. The final section — Set Up Your Packaging Workspace — scaffolds the AI-assisted workspace that all packaging is designed around.
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-worldbefore 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-worldfails, re-read the Docker note above (the daemon must be running, and on Linux your user must be in thedockergroup).
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 pullin 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.defaultlets you install withmake 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 withstart-cli auth login(it prompts for your StartOS master password). If you’d rather not set up the CLI yet, you can sideload the.s9pkthrough 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-sdk2.0,make installandmake publishresolve their target throughstart-cli— the workspace.startos/config.yamlprofiles, or-H/-r. (Olders9pk.mkparsed a singlehost:/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-packageonly 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.
Recommended: make install from the command line
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:
- Point your workspace at the device — set
host.defaultin.startos/config.yamlto your device’s address (see Hosts and registries). - 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:
- Open your StartOS device in a browser and log in.
- Click Sideload in the top navigation bar.
- Select the
.s9pkyou just built (hello-world_x86_64.s9pkorhello-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:
- Make the code change.
- Open
README.mdandinstructions.md. Read what each says about the area you touched. - If either no longer matches the code, update it in the same change.
- If a file is silent on the area and doesn’t need to speak to it, leave it.
Don’t skip step 2 on the theory that a change was “internal.” If you’re unsure whether a change 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: Zfixup commits. - If you’ve already accumulated fixups during a debug session,
git reset --soft HEAD~Ncollapses them so you can recommit as one.
Pre-existing errors are still errors
If tsc, a test, or the pack step fails — even on something unrelated to your change — the package does not pass. “Pre-existing” is not a pass condition; it is a signal that nobody has fixed the problem yet. Either fix it, or stop and flag it explicitly. Never report a run as green when any check was red.
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 guessorg/namefrom 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.svgor 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.mdthatstart-cli s9pk init-workspacelinks 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:
- Find the recipe. Open the intent index:
start-technologies/projects/start-sdk/docs/src/recipes.md. - Follow its reference links for API details and code examples.
- Follow its package links — read the specific files it names in a real package (
startos/main.ts,startos/actions/, …) for working production code. - 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
| Need | Read |
|---|---|
| Find the right recipe for a task | start-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 package | start-technologies/projects/start-sdk/docs/src/project-structure.md |
| Service metadata, descriptions | start-technologies/projects/start-sdk/docs/src/manifest.md |
| Versions, migrations, release notes | start-technologies/projects/start-sdk/docs/src/versions.md |
| Daemons, health checks, oneshots, lifecycle | start-technologies/projects/start-sdk/docs/src/main.md |
| Install / update / restore init logic | start-technologies/projects/start-sdk/docs/src/init.md |
| Network interfaces and ports | start-technologies/projects/start-sdk/docs/src/interfaces.md |
| User-facing actions | start-technologies/projects/start-sdk/docs/src/actions.md |
| Prompting the user to run actions | start-technologies/projects/start-sdk/docs/src/tasks.md |
| Config files as typed models | start-technologies/projects/start-sdk/docs/src/file-models.md |
| Service dependencies | start-technologies/projects/start-sdk/docs/src/dependencies.md |
| Build / install commands | start-technologies/projects/start-sdk/docs/src/makefile.md |
| Writing the README | start-technologies/projects/start-sdk/docs/src/writing-readmes.md |
| Writing user instructions | start-technologies/projects/start-sdk/docs/src/writing-instructions.md |
| Publishing / registries | start-technologies/projects/start-sdk/docs/src/publishing.md |
start-cli reference | start-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,
readyhealth check, and arequiresarray 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()insetupMain(). (recipe-oneshot.md,main.md) - Health checks come in two forms: the
readyproperty 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
tscand a cleans9pk packprove 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 howrunAsInitis found). - Keep
README.mdandinstructions.mdin sync.README.mdtracks architecture/behavior (for developers and AI);instructions.mdtracks 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
-modifiedpack-hash suffix is informational — don’t commit between test attempts. One clean commit when the package works;git reset --soft HEAD~Ncollapses 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.VersionGraphsynthesizes a range vertex beneathcurrent, so any lower installed version migrates up in one hop without its own node;canMigrateFromis derived from that graph, not authored. Seeversions.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 generatedTODO.mdfrom 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
| Recipe | Description |
|---|---|
| Set Up a Basic Service | Minimal single-container service with a web UI, health check, and backup |
| Package a Prebuilt Docker Image | Wrap an upstream linuxserver/* or official image — verify the image, mount every data path, expose all ports, handle init systems and credentials |
| Create Configuration Actions | Let users configure your service through actions with input forms |
| Generate Config Files | Produce YAML, TOML, INI, JSON, or ENV files from user settings using FileModel |
| Pass Config via Environment Variables | Configure your service through environment variables in the daemon definition |
| Hardcode Config Values | Lock down ports, paths, or auth modes so users cannot change them |
| Set a Primary URL | Let users choose which hostname the service uses for links, invites, and federation |
| Set Up SMTP / Email | Let users configure email sending with disabled/system/custom modes |
Credentials & Access Control
| Recipe | Description |
|---|---|
| Auto-Generate Internal Secrets | Generate passwords or tokens in init for internal use (database auth, secret keys) |
| Prompt User to Create Admin Credentials | Critical task that points to a “set admin password” action — the action generates, stores, and returns the credential on each invocation (first-set + rotation) |
| Reset a Password | Action that regenerates credentials and updates the running application |
| Gate User Registration | Toggle action that enables/disables public signups with a dynamic label |
Setup & Lifecycle
| Recipe | Description |
|---|---|
| Require Setup Before Starting | Block service startup with a critical task until the user completes configuration |
| Run One-Time Setup on Install | Generate passwords, seed databases, or bootstrap config on first install only |
| Bootstrap via Temporary Daemon Chain | Start the service during init, call its API to bootstrap, then tear it down |
| Handle Version Upgrades | Migrate data between package versions using the version graph |
| Handle Restore from Backup | Re-register services or fix state after restoring from backup |
Daemons & Containers
| Recipe | Description |
|---|---|
| Run Multiple Containers | App + database, app + cache, app + worker — multi-daemon setups |
| Run a PostgreSQL Sidecar | Password generation, pg_isready health check, pg_dump backup |
| Run a MySQL/MariaDB Sidecar | MySQL daemon, health check, mysqldump backup and restore |
| Run a Redis/Valkey Cache | Ephemeral cache daemon with valkey-cli ping health check |
| Create Dynamic Daemons | Variable number of daemons based on user configuration |
| Run a One-Shot Command | Migrations, file ownership fixes, or setup scripts before the main daemon starts |
| Run a Nested OCI Runtime | Rootless Podman or Docker inside the service for CI runners, build daemons, sandboxed jobs |
Networking
| Recipe | Description |
|---|---|
| Expose a Web UI | Single HTTP interface for browser access |
| Expose Multiple Interfaces | RPC, API, peer, WebSocket, or SSH on different ports |
| Expose an API-Only Interface | Programmatic access with no browser UI |
| Reach Another Service | Dial a dependency over the host bridge (getOsIp + assigned port) |
Dependencies
| Recipe | Description |
|---|---|
| Depend on Another Service | Declare a dependency, read its connection info, and auto-configure |
| Enforce Settings on a Dependency | Create a cross-service task that requires specific dependency configuration |
| Mount Volumes from Another Service | Read-only access to a dependency’s data volume |
| Support Alternative Dependencies | Let users choose between backends (e.g., LND vs CLN) |
Data & Health
| Recipe | Description |
|---|---|
| Back Up and Restore Data | Volume snapshots, pg_dump, mysqldump, and incremental rsync strategies |
| Add Standalone Health Checks | Sync progress, reachability, and other ongoing checks beyond daemon readiness |
User Communication
| Recipe | Description |
|---|---|
| Post a Notification to the User | Send a plain or markdown-detailed notification to the StartOS panel when a long-running action finishes or a sync completes |
Set Up a Basic Service
A minimal StartOS service: one container, one web UI, one health check, one backup volume. This is the starting point for any new package — every other recipe builds on this foundation.
Solution
Define a daemon in setupMain() with one subcontainer, mount a volume, and add a checkPortListening health check. Define a single HTTP interface in setupInterfaces() using MultiHost.of() and createInterface(). Define backups with sdk.Backups.ofVolumes() to back up the data volume.
Wrapping an upstream Docker image (a
linuxserver/*or officialorg/appimage) 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:
- 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_64andaarch64at minimum). Pinimages.<id>.source.dockerTagto that confirmedimage:tagand setarchaccordingly. See Verify the image below. - 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.
- 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 likepeerPortthat is declared but never bound is a tell that a port was forgotten. - 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(everylinuxserver/*image),tini,dumb-init,supervisord— setrunAsInit: trueon the daemon’sexec, or the supervisor crashes because it is not PID 1. See Images with their own init system. - Pass the env vars the image expects. Many community images are configured through environment variables —
linuxserver/*images readPUID,PGID, andTZto drop privileges and set ownership; others takeAPP_*settings. Set them viaexec.env. See Pass Config via Environment Variables. - 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.
- Verify by installing, not by compiling. A clean
tscand a successfuls9pk packprove 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/qbittorrentdoes not exist;linuxserver/qbittorrentdoes). Pulling, or fetching the registry’s tags endpoint, tells you for sure. - The tag is published.
latestalmost always exists; a specificX.Y.Zmay 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_64andarm64/aarch64. An image that only shipsamd64cannot 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
/configbut 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’sWebUI\HostHeaderValidation, many others) reject every proxied request — often with a401that 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
setupMainbefore 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: trueif the image has its own init system - Required env vars set (
PUID/PGID/TZforlinuxserver/*, etc.) - Credentials applied via the app’s own mechanism; a real login verified
- Installed on a StartOS box and the feature exercised — not just
tscgreen
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
setupOnInitcan run on every container rebuild without spamming tasks.
When the upstream service requires the password to be applied via CLI or API (rather than read from the store at startup), wrap the work in sdk.SubContainer.withTemp() inside the action handler and run the upstream command before returning — see the Reset a Password recipe for the temp-subcontainer shape.
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
mainis alwayssdk.setupMain(...). What varies is what you return from it: a staticsdk.Daemons.of(...)chain, or the reconciler fromsdk.Daemons.dynamic(effects, ...). Both are aDaemonBuildable. Never useDaemons.dynamicas yourmainexport.
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
- Set both
userspaceFilesystems: trueandvirtualNetworking: trueat the manifest top level — fuse for storage, tun for rootless networking. - Bake the OCI engine and its rootless prerequisites into the service image.
- Add a non-root user and
/etc/subuid//etc/subgidranges that fit inside the subcontainer’s user namespace. - (Docker only) Drop a tiny
runcwrapper into the image and pointdefault-runtimeat it viadaemon.json— Docker injects anet.ipv4.ip_unprivileged_port_startsysctl by default that runc fails to apply across the nested-userns boundary.
Podman works out of the box once the prerequisites are in place. Docker needs the wrapper.
Reference: Manifest · Project Structure
Manifest
import { setupManifest } from '@start9labs/start-sdk'
import { short, long } from './i18n'
export const manifest = setupManifest({
id: 'gitea-runner',
title: 'Gitea Actions Runner',
// ...
volumes: ['main'],
images: {
main: {
source: { dockerBuild: { workdir: '.' } },
arch: ['x86_64', 'aarch64'],
},
},
dependencies: {},
userspaceFilesystems: true,
virtualNetworking: true,
})
What StartOS provides
With userspaceFilesystems and virtualNetworking set, the per-service LXC gets:
/dev/fuse— char device 10:229, world-RW (viauserspaceFilesystems). Required byfuse-overlayfsfor rootless layered storage. Kernel overlayfs-on-overlayfs is denied for unprivileged users, so fuse-overlayfs is the only viable rootless storage driver inside a userns LXC./dev/net/tun— char device 10:200, world-RW (viavirtualNetworking). Required byslirp4netnsandpastafor rootless container networking.
virtualNetworking additionally grants CAP_NET_ADMIN (scoped to the container’s user namespace). A rootless OCI engine using slirp4netns/pasta doesn’t strictly need it, but the tun device and the capability are bundled under the one flag; the grant is namespaced and harmless here.
Both devices are bind-mounted from the host (via the same machinery that handles hardwareAcceleration for GPU nodes). The host’s fuse and tun kernel modules are auto-loaded at boot.
The host-level sysctls kernel.unprivileged_userns_clone=1 and user.max_user_namespaces=28633 are pinned at install time so unprivileged user-namespace creation is allowed and headroom for nested namespaces is reserved.
Image: Podman
FROM debian:trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
podman fuse-overlayfs uidmap iproute2 iptables \
slirp4netns ca-certificates \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/containers && \
printf 'unqualified-search-registries = ["docker.io"]\n' \
> /etc/containers/registries.conf
# Subordinate UIDs/GIDs for nested user namespaces. The range MUST live
# inside the subcontainer's userns (mapped 0..65535) and MUST NOT overlap
# with the calling user's own UID — kernel rejects uid_map writes with
# EINVAL when outside ranges overlap.
RUN useradd --create-home --uid 1000 --shell /bin/bash app \
&& echo 'app:1001:64535' > /etc/subuid \
&& echo 'app:1001:64535' > /etc/subgid
USER app
WORKDIR /home/app
Inside setupMain, run Podman as app:
podman --root=$HOME/.local/share/containers/storage \
--runroot=$XDG_RUNTIME_DIR/containers \
--cgroup-manager=cgroupfs \
run --network=slirp4netns --rm docker.io/library/alpine echo ok
(--cgroup-manager=cgroupfs is required because there’s no user systemd session inside the subcontainer.)
Image: Docker
Docker rootless needs the same prerequisites — subuid, fuse-overlayfs, slirp4netns — plus one workaround. Docker’s container spec includes a net.ipv4.ip_unprivileged_port_start=0 sysctl by default; runc opens that proc file in the parent userns and re-opens the file descriptor inside the nested userns, where it EPERMs. The kernel itself is fine with the write — unshare -Urn from the same shell can do it — but runc’s reopen-after-pivot pattern breaks under nested userns. Setting --sysctl net.ipv4.ip_unprivileged_port_start=… on the command line doesn’t help: runc still does the reopen.
The fix is a thin runc wrapper that strips that sysctl from the OCI bundle before exec’ing real runc. Drop it in the image, register it as the default runtime in /etc/docker/daemon.json. Skipping the sysctl is harmless — ports < 1024 just stay privileged inside the container, which is the upstream Linux default anyway.
FROM debian:trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl gnupg fuse-overlayfs uidmap iproute2 iptables \
slirp4netns jq ca-certificates \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/debian/gpg \
-o /etc/apt/keyrings/docker.asc \
&& chmod a+r /etc/apt/keyrings/docker.asc \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian trixie stable" \
> /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
docker-ce docker-ce-cli containerd.io docker-ce-rootless-extras \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --uid 1000 --shell /bin/bash app \
&& echo 'app:1001:64535' > /etc/subuid \
&& echo 'app:1001:64535' > /etc/subgid
# runc wrapper — strips the sysctl runc can't apply across the nested
# userns boundary. See https://github.com/Start9Labs/start-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 expectloginctl enable-lingeror user-scoped systemd units. - Subordinate-UID range overlap.
/etc/subuid//etc/subgidranges must live inside the subcontainer’s userns (UIDs 0..65535) AND must not overlap with the calling user’s own UID. Withuseradd --uid 1000, the subordinate range must skip 1000 —app:1001:64535works,app:1:65535does not. fuse-overlayfsonly. Kernel overlayfs-on-overlayfs is denied for unprivileged users, so don’t try--storage-driver=overlay2.fuse-overlayfsis the only rootless option.- No bridge IPv6 by default. Rootless networking via slirp4netns is IPv4-only out of the box. If you need IPv6 inside nested containers, configure pasta (
--network=pasta) instead. - The capability flags are independent.
userspaceFilesystems,virtualNetworking, andhardwareAccelerationare orthogonal opt-ins. A nested OCI engine needs the first two; an LLM-driven CI runner that also wants GPU access sets all three.
Expose a Web UI
Every service with a browser interface needs at least one HTTP interface. This is the most basic networking pattern — bind a port, create an interface descriptor, and export it.
Solution
In setupInterfaces(), create a MultiHost with sdk.MultiHost.of(effects, 'ui'), bind an HTTP port with multi.bindPort(port, { protocol: 'http', preferredExternalPort: 80 }), create a 'ui' type interface with sdk.createInterface() setting masked: false, and export it. Return the receipt array.
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 credentialwithInputbecause the user is choosing public-vs-private. Neither property transfers to an app-native login — copying them puts a plaintext password at rest. Searching foraddSsl.authlands 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-registryservice from the marketplace, walk through first-run setup (registry name, first admin, signing keys), and connect a localstart-clito the registry. - Administration — day-to-day tasks: managing signers, publishing and removing packages, organizing categories, registering StartOS releases. Links out to the start-cli registry reference for command details.
When you don’t need to host your own
If you’re publishing through the Start9 Community pipeline, you don’t need your own registry to ship — that pipeline runs registries on your behalf. See Publishing. The two paths aren’t exclusive: developers often run a personal registry for alpha builds while a more stable version is promoted through Start9 Community.
Setup
The packaged startos-registry service is the supported way to run a registry. Install it from any registry that carries it (the Start9 registry does), complete two first-run actions, and you’re ready to publish.
1. Install the service
From StartOS, open the Marketplace, find StartOS Registry, and install. The service has no external dependencies. Once installed, start it.
On first install, StartOS surfaces two setup tasks under the service. Both must be completed before the registry is usable.
2. Configure Registry
Run the Configure Registry action to set the registry’s display name (max 32 characters) and an optional icon. This is what users will see when they browse your registry from another StartOS device.
The registry’s hostnames, listen address, Tor proxy, and data directory are managed by StartOS automatically — you don’t configure them by hand. As the service’s network addresses change (e.g. you add a clearnet domain to the API interface), the configured hostnames update to match.
3. Add the first administrator
Run Add Administrator to register the first admin. You’ll need a PEM-encoded Ed25519 public key, a label, and contact info (email or Matrix handle). Admins can manage signers, publish packages, register OS versions, and edit categories.
To generate a key on your workstation:
start-cli init-key
start-cli pubkey
init-key creates an Ed25519 keypair at ~/.startos/developer.key.pem (or /run/startos/developer.key.pem if running on a StartOS device). pubkey prints the public half — that’s what you paste into the Public Key field of the Add Administrator action.
Treat the private key like an SSH key: it authenticates every admin and publish action you take against the registry. Back it up.
4. Point start-cli at the registry
All registry operations go through start-cli registry (or start-cli s9pk publish for uploading packages). Pass the registry’s URL with --registry:
start-cli registry --registry https://my-registry.example.com index
If you’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:
.gitignoreMakefile- Includes the SDK’ss9pk.mkfromnode_modules(see Makefile)package.json/package-lock.jsontsconfig.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 (typicallyimages.<name>.source.dockerTaginstartos/manifest/index.ts). - Applying the bump — the exact file and field to edit, including any tag-format quirks (e.g. drop the leading
v, append-alpine, keep the major version aligned with a sibling image).
Packages with multiple upstream sources (e.g. a service plus its database sidecar) get one subsection per source under each heading. 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
| File | Purpose |
|---|---|
main.ts | Daemon runtime configuration and health checks |
interfaces.ts | Network interface definitions and port bindings (optional) |
backups.ts | Backup volumes and exclusion patterns |
dependencies.ts | Service dependencies and version requirements |
sdk.ts | SDK initialization (boilerplate) |
utils.ts | Package-specific constants and helper functions |
index.ts | Module exports (boilerplate) |
backups.ts
setupBackups() is where you define what volumes to back up as well as what directories or files to exclude from backups.
dependencies.ts
setupDependencies() is where you define any dependencies of this package, including their versions, whether or not they need to be running or simply installed, and which health checks, if any, need to be passing for this package to be satisfied.
index.ts
This file is plumbing, used for exporting package functions to StartOS.
interfaces.ts (optional)
setupInterfaces() is where you define the service interfaces and determine how they are exposed. This function executes on service install, update, and config save. It takes the user’s config input as an argument, which will be null for install and update.
The barebones scaffold ships no interfaces.ts — many services (background workers, sidecars) expose nothing on the network. When a service does, add this file and wire its setInterfaces into init/index.ts (conventionally before setDependencies).
main.ts
setupMain() is where you define the daemons that compose your service’s runtime. It runs each time the service is started. Daemon comes with built-in health checks that can optionally be displayed to the user. You can also use setupMain() to define additional health checks, such as tracking and displaying a sync percentage.
manifest/
The manifest directory defines static metadata about the service, such as ID, name, description, release notes, helpful links, volumes, images, hardware requirements, and dependencies. See Manifest for details.
sdk.ts
This file is plumbing, used to imbue the generic Start SDK with package-specific type information defined in manifest.ts and store.ts. The exported SDK is what should be used throughout the startos/ directory. It is a custom SDK just for this package.
utils.ts
This file is for defining constants and functions specific to your package that are used throughout the code base. Many packages will not make use of this file.
Subdirectories
| Directory | Purpose |
|---|---|
actions/ | Custom user-facing scripts displayed as buttons in the UI |
fileModels/ | Type-safe representations of config files (.json, .yaml, .toml, etc.) |
i18n/ | Internationalization: default dictionary and translated strings |
init/ | Container initialization logic (install, update, restart) |
manifest/ | Service metadata (ID, name, description, images) with i18n |
versions/ | Version management and migration logic |
actions/
actions/
├── index.ts
├── action1.ts
└── action2.ts
In the actions/ directory, you define custom actions for your package.
Actions are predefined scripts that display as buttons to the user. They accept arbitrary input and return structured data that can be optionally displayed masked or as QR codes. For example, a config.ts action might present a validated form that represents an underlying config file of the service, allowing users to configure the service without needing SSH or the command line. A resetPassword action could use the upstream service’s CLI to generate a new password for the primary admin, then display it to the user.
Each action receives its own file and is also passed into Actions.of() in actions/index.ts.
fileModels/ (optional)
fileModels/
├── store.json.ts
└── config.json.ts
In the fileModels/ directory, you can create separate .ts files from which you export a file model for each file from the file system you want to represent. Supported file formats are .yaml, .toml, .json, .env, .ini, .txt. For alternative file formats, you can use the raw method and provide custom serialization and parser functions.
These .ts files afford a convenient and type-safe way for your package to read, write, monitor, and react to files on the file system.
It is common for packages to have a store.json.ts file model as a convenient place to persist arbitrary data that are needed by the package but not persisted by the upstream service. For example, you might use store.json to persist startup flags or login credentials.
init/
init/
├── index.ts
├── taskCreateAdmin.ts
└── seedDatabase.ts
In the init/ directory, you define the container initialization sequence for your package as well as optional custom init functions. Name each init file specifically for what it does (e.g., taskCreateAdmin.ts, seedDatabase.ts) rather than using a generic name like initializeService.ts.
Container initialization takes place under the following circumstances:
- Package install (including fresh install, update, downgrade, and restore)
- Server (not service) restart
- “Container Rebuild” (a built-in Action that must be manually triggered by the user)
Note
Starting or restarting a service does not trigger container initialization. Even if a service is stopped, the container still exists with event listeners still active.
init/index.ts
setupInit() is where you define the specific order in which functions will be executed when your container initializes.
restoreInitandversionGraphmust remain first and second. Do not move them.setInterfaces,setDependencies,actionsare recommended to remain in this order, but could be rearranged if necessary.- Any custom init functions can be appended to the list of built-in functions, or even inserted between them. Most custom init functions are simply appended to the list.
It is possible to limit the execution of custom init functions to specific kinds of initialization. For example, if you only wanted to run a particular init function on fresh install and ignore it for updates and restores, setupOnInit() provides a kind variable (one of install, update, restore) that you can use for conditional logic. kind can also be null, which means the container is being initialized due to a server restart or manual container rebuild, rather than installation.
versions/
versions/
├── index.ts
├── current.ts # The latest version — always this filename
└── v1.0.2_0.ts # A historical version kept for its migration
In the versions/ directory, you manage package versions and define migration logic. The latest version always lives in current.ts; historical versions kept for migrations sit beside it under version-named files. The index.ts file uses VersionGraph.of() to index the current version and any previous versions of your package. Each version file uses VersionInfo.of() to provide the version number, release notes, and any migrations that should run.
Migration up and down functions run once, before anything else, upon updating or downgrading to that version only.
See Versions for full details.
Warning
Migrations are only for migrating data that is not migrated by the upstream service itself.
Manifest
The manifest defines service identity, metadata, and build configuration. It lives in startos/manifest/ as two files:
index.ts– thesetupManifest()calli18n.ts– translated strings fordescription
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
| Field | Description |
|---|---|
id | Unique identifier (lowercase, hyphens allowed; start-os is reserved for the OS itself) |
title | Display name shown in UI |
license | SPDX identifier (MIT, Apache-2.0, GPL-3.0, etc.) |
packageRepo | URL to the StartOS package repository |
upstreamRepo | URL to the original project repository |
marketingUrl | URL for the project’s main website |
donationUrl | Donation URL or null |
description.short | Locale object (see manifest/i18n.ts) |
description.long | Locale object (see manifest/i18n.ts) |
volumes | Storage volumes (usually ['main']) |
images | Docker image configuration (including arch) |
dependencies | Service dependencies |
License
Check the upstream project’s LICENSE file and use the correct SPDX identifier (e.g., MIT, Apache-2.0, GPL-3.0). If you have a git submodule, symlink to its license. Otherwise, copy the license text directly from the upstream repository:
# With submodule
ln -sf upstream-project/LICENSE LICENSE
# Without submodule -- copy from upstream repo
Icon
Symlink from upstream if available (svg, png, jpg, or webp, max 40 KiB):
ln -sf upstream-project/logo.svg icon.svg
Images Configuration
Each image can include an arch field specifying supported architectures. It defaults to ['x86_64', 'aarch64', 'riscv64'] if omitted, but it is good practice to list architectures explicitly for transparency. The arch field must align with the ARCHES variable in the Makefile.
Pre-built Docker Tag
Use when an image exists on Docker Hub or another registry:
images: {
main: {
source: {
dockerTag: 'nginx:1.25',
},
arch: ['x86_64', 'aarch64'],
},
},
Local Docker Build
Use when building from a Dockerfile in the project:
// Dockerfile in project root
images: {
main: {
source: {
dockerBuild: {},
},
arch: ['x86_64', 'aarch64'],
},
},
If upstream has a working Dockerfile: Set workdir to the upstream directory. If the Dockerfile is named Dockerfile, you can omit the dockerfile field:
images: {
main: {
source: {
dockerBuild: {
workdir: './upstream-project',
},
},
arch: ['x86_64', 'aarch64'],
},
},
For a non-standard Dockerfile name, specify dockerfile relative to project root:
images: {
main: {
source: {
dockerBuild: {
workdir: './upstream-project',
dockerfile: './upstream-project/sync-server.Dockerfile',
},
},
arch: ['x86_64', 'aarch64'],
},
},
If you need a custom Dockerfile: Create one in your project root:
COPY upstream-project/ .
Architecture Support
The arch field accepts these values:
| Value | Architecture |
|---|---|
x86_64 | Intel/AMD 64-bit |
aarch64 | ARM 64-bit |
riscv64 | RISC-V 64-bit |
Most services support ['x86_64', 'aarch64']. Only add riscv64 if the upstream image actually supports it. The ARCHES variable in the Makefile must align (see Makefile).
GPU/Hardware Acceleration
For services requiring GPU access:
images: {
main: {
source: {
dockerTag: 'ollama/ollama:0.13.5',
},
arch: ['x86_64', 'aarch64'],
nvidiaContainer: true, // Enable NVIDIA GPU support
},
},
hardwareAcceleration: true, // Top-level flag
Hardware requirements and variants
A package that targets several accelerators (NVIDIA, AMD, CPU-only, …) ships one variant per accelerator: a separate .s9pk built with a different VARIANT in the Makefile (see Makefile), all published under a single version. The manifest reads process.env.VARIANT to pick per-variant settings, including hardwareRequirements.device — a list of device filters telling StartOS which hardware a variant needs:
const variant = process.env.VARIANT || 'cpu'
// inside setupManifest({ ... })
hardwareRequirements: {
device:
variant === 'nvidia'
? [{ class: 'display', product: null, vendor: null, driver: 'nvidia', description: 'An NVIDIA GPU' }]
: variant === 'rocm'
? [{ class: 'display', product: null, vendor: null, driver: 'amdgpu', description: 'An AMD GPU' }]
: [], // cpu: runs anywhere
},
The registry stores a version’s variants together and disambiguates them by hardware requirement — on a given machine StartOS offers the variant whose requirement the detected hardware satisfies.
Warning
Every variant must declare a distinct hardware requirement, and at most one variant may have an empty requirement (
[], the catch-all fallback). Two variants presenting the same requirement — most often two with an emptydevicearray — collide when the second is published, and the registry rejects it:Invalid Request: package.add: package metadata mismatch: remove the existing version first, then re-addIn particular an
nvidiavariant must carry an NVIDIAdevicefilter, not[]—nvidiaContainer: truewires up the GPU runtime but does not set a hardware requirement, so without the filter the NVIDIA variant is indistinguishable from the CPU fallback and one of the two fails to publish.
Virtual Networking (VPN / kernel tun interfaces)
For services that bring up their own kernel tunnel interface — VPNs, WireGuard, or any tun-class workload — set virtualNetworking: true at the manifest top level:
virtualNetworking: true,
When set, StartOS exposes /dev/net/tun inside the service’s container and grants CAP_NET_ADMIN (scoped to the container’s user namespace) so the service can create and configure tunnel interfaces. This is a meaningful privilege escalation — enable it only when the service genuinely needs a kernel tunnel interface.
Nested OCI Runtimes (Docker / Podman inside a service)
For services that need to run their own OCI containers — e.g. CI runners like gitea-act-runner that spawn build containers per job — set both userspaceFilesystems and virtualNetworking at the manifest top level:
userspaceFilesystems: true, // /dev/fuse for fuse-overlayfs storage
virtualNetworking: true, // /dev/net/tun for slirp4netns / pasta networking
userspaceFilesystems exposes /dev/fuse so a rootless engine (Podman or Docker) can use fuse-overlayfs for layered storage. virtualNetworking exposes /dev/net/tun so it can use slirp4netns (or pasta) for networking (and also grants CAP_NET_ADMIN). Both are opt-in. Service authors are still responsible for installing the OCI engine in the image and configuring it for rootless mode — see Run a Nested OCI Runtime for the full recipe (subuid setup, daemon configuration, and the runc wrapper required when using Docker).
Multiple Images
Services can define multiple images. Each image needs its own arch field:
images: {
app: {
source: { dockerTag: 'myapp:latest' },
arch: ['x86_64', 'aarch64'],
},
db: {
source: { dockerTag: 'postgres:15' },
arch: ['x86_64', 'aarch64'],
},
},
Volumes
Storage volumes for persistent data. When possible, prefer matching the upstream project’s volume naming convention for clarity:
// If upstream docker-compose uses a volume named "mcaptcha-data"
volumes: ['mcaptcha-data'],
// Simple services can use 'main'
volumes: ['main'],
For services needing separate storage areas:
volumes: ['main', 'db', 'config'],
Reference these in main.ts mounts by the volume ID you chose.
Dependencies
Declare dependencies on other StartOS services. Note that dependency description is a plain string, not a locale object:
dependencies: {
// Required dependency
bitcoin: {
description: 'Required for blockchain data',
optional: false,
},
// Optional dependency with metadata
'c-lightning': {
description: 'Needed for Lightning payments',
optional: true,
metadata: {
title: 'Core Lightning',
icon: 'https://raw.githubusercontent.com/Start9Labs/cln-startos/refs/heads/master/icon.png',
},
},
},
Versions
StartOS uses Extended Versioning (ExVer) to manage package versions, allowing downstream maintainers to release updates without upstream changes.
Version Format
[#flavor:]<upstream>[-upstream-prerelease]:<downstream>
| Component | Description | Example |
|---|---|---|
flavor | Optional variant for diverging forks | #libre: |
upstream | Upstream project version (SemVer) | 26.0.0 |
upstream-prerelease | Upstream prerelease suffix | -beta.1 |
downstream | StartOS wrapper revision | 0, 1, 2 |
Note
ExVer allows a prerelease suffix on the downstream revision too (e.g.
:0-beta.0), but Start9 packages don’t use it — the downstream revision is always a plain integer. Prerelease suffixes appear only on the upstream side, when wrapping an upstream alpha/beta/rc.
Flavor
Flavors are for diverging forks of a project that maintain separate version histories. Example: if a project forks into “libre” and “pro” editions that diverge significantly, each would have its own flavor prefix.
Note
Do NOT use flavors for hardware variants (like GPU types) – those should be handled via build configuration.
Examples
| Version String | Upstream | Downstream |
|---|---|---|
26.0.0:0 | 26.0.0 (stable) | 0 |
26.0.0-rc.1:0 | 26.0.0-rc.1 | 0 |
0.13.5:0 | 0.13.5 (stable) | 0 |
2.3.2:1 | 2.3.2 (stable) | 1 |
Version Ordering
Versions are compared by:
- Upstream version (most significant)
- Upstream prerelease (stable > rc > beta > alpha)
- Downstream revision
Example ordering (lowest to highest):
1.0.0-alpha.0:01.0.0-beta.0:01.0.0-rc.0:01.0.0:0(fully stable)1.0.0:11.1.0:0
Choosing a Version
When creating a new package:
- Select the latest stable upstream version – avoid prereleases (alpha, beta, rc) unless necessary.
- Match the Docker image tag – the version in
manifest/index.tsimages.*.source.dockerTagmust match the upstream version. - Match the git submodule – if using a submodule, check out the corresponding tag.
- Start downstream at 0 – increment only when making wrapper-only changes.
Version Consistency Checklist
Ensure these all match for upstream version X.Y.Z:
- The current version lives in
startos/versions/current.ts - Version string matches:
version: 'X.Y.Z:0'in VersionInfo - Docker tag matches:
dockerTag: 'image:X.Y.Z'inmanifest/index.ts(if using pre-built image) - Git submodule checked out to
vX.Y.Ztag (if applicable)
File Structure
The latest version always lives in startos/versions/current.ts. The filename never changes as you bump — only its contents do. Historical versions that a migration needs to upgrade from are kept as version-named files alongside it.
startos/versions/
├── index.ts # VersionGraph: imports current, lists historical versions in `other`
├── current.ts # The latest version (always this filename)
├── v1.0.0_0.ts # Historical version 1.0.0:0, kept because a later migration upgrades from it
└── v1.1.0_0.ts # Historical version 1.1.0:0, ditto
A brand-new package has only index.ts and current.ts — no historical files until a migration forces one out (see When to Create a New Version File).
current.ts Template
current.ts exports its VersionInfo under the stable name current. Keeping the export name fixed is what makes an in-place bump touch only this file — index.ts never changes.
import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk'
export const current = VersionInfo.of({
version: 'X.Y.Z:0',
releaseNotes: {
en_US: 'Initial release for StartOS',
es_ES: 'Version inicial para StartOS',
de_DE: 'Erstveeroffentlichung fuer StartOS',
pl_PL: 'Pierwsze wydanie dla StartOS',
fr_FR: 'Version initiale pour StartOS',
},
migrations: {
up: async ({ effects }) => {},
down: IMPOSSIBLE, // Use for initial versions or breaking changes
},
})
index.ts
import { VersionGraph } from '@start9labs/start-sdk'
import { current } from './current'
export const versionGraph = VersionGraph.of({
current,
other: [], // Add historical versions here so migrations run when upgrading through them
})
Historical Version File Naming
When a migration forces a version out of current.ts (see below), the spun-off file is named after the version it holds, in the same form as its git tag: prefix with v, replace the : with _, and add .ts. The upstream portion keeps its dots; prerelease suffixes are left as-is.
| Version | Filename |
|---|---|
26.0.0:0 | v26.0.0_0.ts |
26.0.0-rc.1:0 | v26.0.0-rc.1_0.ts |
2.3.2:1 | v2.3.2_1.ts |
A historical file’s export is renamed to match the version, with every ., :, and - becoming _ — e.g. 2.3.2:1 → v_2_3_2_1. Only current.ts uses the stable current export.
Incrementing Versions
When to Create a New Version File
The deciding question is does this bump need a migration?
No migration (the common case): bump current.ts in place. Edit version and releaseNotes in startos/versions/current.ts and you’re done. Don’t rename the file, don’t touch the export name, don’t touch index.ts, leave other as it is. Git history of current.ts preserves the prior release notes automatically, so there is no separate “keep the old notes” step.
Migration needed: spin the old version off, then write a fresh current.ts.
- Rename
current.tsto the version it currently holds — e.g.v2.3.2_1.ts(see Historical Version File Naming), and rename its export fromcurrentto the matchingv_2_3_2_1. - Add that historical version to the
otherarray inindex.tsso its migration runs when users upgrade through it. - Create a new
startos/versions/current.tsexportingcurrentwith the new version string, release notes, and theup/downmigration.
This keeps versions/ lean: only versions that a migration upgrades from survive as their own files; everything else is just the latest state of current.ts.
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:
- Update git submodule to new tag
- Update
dockerTagin manifest/index.ts - Update
current.tsto the new upstream version (spin off a historical file only if the bump needs a migration — see above) - Reset downstream to 0
Wrapper-Only Changes
When making changes to the StartOS wrapper without upstream changes:
- Keep upstream version the same
- Increment downstream revision
- Apply the migration rule — most wrapper-only bumps need no migration, so just edit
current.tsin place
Release Notes
releaseNotes renders as markdown in the StartOS UI. 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:
| Kind | When it runs |
|---|---|
'install' | Fresh install |
'restore' | Restoring from backup |
null | Container rebuild (no data changes) |
Bootstrapping Config Files
Generate passwords, write initial config files, and seed stores on fresh install:
// init/seedFiles.ts
export const seedFiles = sdk.setupOnInit(async (effects, kind) => {
if (kind !== 'install') return
const secretKey = utils.getDefaultString({ charset: 'a-z,A-Z,0-9', len: 32 })
await storeJson.merge(effects, { secretKey })
await configToml.merge(effects, {
/* initial config */
})
})
Creating Tasks
Tasks reference actions, so they must be created in a setupOnInit that runs after actions are registered in the init sequence:
// init/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 version | Git tag |
|---|---|
26.0.0:0 | v26.0.0_0 |
26.0.0-rc.1:0 | v26.0.0-rc.1_0 |
0.13.5:2 | v0.13.5_2 |
Conventions:
- Underscore between upstream and wrapper. The
:from the version string becomes_in the tag — tags can’t contain colons. - No package-name prefix. The tag is just the version, not
myservice-v26.0.0_0. - Keep the upstream prerelease suffix (
-alpha.N/-beta.N/-rc.N) when wrapping an upstream prerelease — it stays inline in the upstream portion. The downstream revision is always a plain integer with no suffix. - Push tags individually (
git push origin <tag>), not withgit push --tags.
Main
setupMain() defines the runtime behavior of your service – daemons, health checks, volume mounts, environment variables, and config file generation. It runs each time the service is started.
Basic Structure
import { i18n } from './i18n'
import { sdk } from './sdk'
import { uiPort } from './utils'
export const main = sdk.setupMain(async ({ effects }) => {
/**
* ======================== Setup (optional) ========================
*
* In this section, we fetch any resources or run any desired preliminary commands.
*/
console.info(i18n('Starting Hello World!'))
/**
* ======================== Daemons ========================
*
* In this section, we create one or more daemons that define the service runtime.
*
* Each daemon defines its own health check, which can optionally be exposed to the user.
*/
return sdk.Daemons.of(effects).addDaemon('primary', {
subcontainer: 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
mainis alwayssetupMain. What you return from it is the daemon topology: the staticsdk.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 aDaemonBuildable. 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 toaddDaemon()with noawait. If you need a synchronous.rootfs,.guid, or.subpath()before running anything,awaitthe accessor or create it eagerly withsdk.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:
| Option | Type | Default | Description |
|---|---|---|---|
imageId | string | — | Required. The Docker image ID from the manifest images field |
sharedRun | boolean | false | Bind-mount the host’s /run directory into the subcontainer |
By default, subcontainers share /dev and /sys with the host. Setting sharedRun: true additionally shares /run, giving access to host runtime sockets (D-Bus, systemd, PID files). Most services do not need this – only use it when the container must communicate with host system services.
Convention: Inline SubContainer.of()
When a subcontainer is only used by one daemon, inline the SubContainer.of() call directly inside addDaemon() rather than extracting it into a separate variable. Only extract to a variable when the same subcontainer is reused across multiple daemons, oneshots, or exec calls. See the basic example at the top of this page.
Reactive vs One-time Reads
When reading configuration in main.ts, you choose how the system responds to changes:
| Method | Returns | Behavior on Change |
|---|---|---|
.once() | Parsed content only | Nothing – value is stale |
.const(effects) | Parsed content | Re-runs the setupMain context, restarting daemons |
// Reactive: re-runs setupMain when value changes (restarts daemons)
const store = await storeJson.read().const(effects)
// One-time: read once, no re-run on change
const store = await storeJson.read().once()
Subset Reading
Use a mapper function to read only specific fields. This is more efficient and limits reactivity to only the fields you care about:
// Read only secretKey - re-runs only if secretKey changes
const secretKey = await storeJson.read(s => s.secretKey).const(effects)
Other Reading Methods
| Method | Purpose |
|---|---|
.onChange(effects, callback) | Register callback for value changes |
.watch(effects) | Create async iterator of new values |
Getting Hostnames
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) inmain.tsoneshots – 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(anylinuxserver/*image),tini,dumb-init, orsupervisordas 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:
| Result | Meaning | When to use |
|---|---|---|
success | Healthy and fully operational | Service is ready and serving |
loading | Operational but catching up | Syncing blocks, indexing data |
disabled | Intentionally inactive | Feature excluded by config (e.g. onlynet) |
starting | Not yet ready | Still initializing (also set automatically during gracePeriod) |
failure | Unhealthy | Process crashed, port not listening, dependency unreachable |
loading and failure require a message string. Other states accept an optional message.
Built-in Health Check Helpers
Available on sdk.healthCheck:
checkPortListening(effects, port, { successMessage, errorMessage })— checks if a TCP/UDP port is bound by reading/proc/net. Lightweight, no network I/O. Preferred for daemon readiness checks.checkWebUrl(effects, url, { successMessage, errorMessage })— fetches a URL, succeeds on any HTTP response.runHealthScript(command, subcontainer, { errorMessage })— runs a command in a subcontainer, succeeds on exit code 0.
Polling Triggers
By default, health checks poll every 1 s while the daemon is pending, then every 30 s once it reports a non-pending result (success, loading, or disabled). Override this with the trigger option on ready:
ready: {
display: i18n('Sync Progress'),
trigger: sdk.trigger.cooldownTrigger(30_000), // fixed 30s interval
fn: async () => { /* ... */ },
}
Available triggers on sdk.trigger:
cooldownTrigger(ms)— fixed interval between checks, regardless of status.statusTrigger(defaultMs, { success?, loading?, disabled?, starting?, waiting?, failure? })— per-status polling intervals in milliseconds. The first argument is the default interval for any status not explicitly listed.
Use a slower trigger for expensive checks (RPC calls during heavy processing) to reduce load on the service:
trigger: sdk.trigger.statusTrigger(30_000, {
starting: 5_000,
failure: 5_000,
}),
Volume Mounts
sdk.Mounts.of()
// Mount entire volume (directory)
.mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: '/data',
readonly: false,
})
// Mount specific file from volume (requires type: 'file')
.mountVolume({
volumeId: 'main',
subpath: 'config.py',
mountpoint: '/app/config.py',
readonly: true,
type: 'file', // Required when mounting a single file
})
Warning
sdk.Mountsis an immutable builder. EverymountVolume/mountAssets/mountDependency/mountBackupscall returns a newMountsinstance — the original is unchanged. Discarded return values silently drop the mount.// BROKEN — conditional mount is lost const mounts = sdk.Mounts.of().mountVolume({ /* ... */ }) if (needsCookie) { mounts.mountDependency({ /* ... */ }) // ← return value discarded } // CORRECT — reassign each time let mounts = sdk.Mounts.of().mountVolume({ /* ... */ }) if (needsCookie) { mounts = mounts.mountDependency({ /* ... */ }) }Chained calls (
.mountVolume(...).mountDependency(...)) are fine — the returned instance flows into the next call. The trap is conditional mutation with the return thrown away. Symptom: the file you expected at the mountpoint isn’t there, so aFileHelper.string(...).read()returnsnullor a subcontainer read fails.
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:
| Method | Behavior on Non-zero Exit |
|---|---|
exec() | Returns result with exitCode, stdout, stderr – does NOT throw |
execFail() | Throws an error on non-zero exit code |
// exec() - manual error handling (good for optional/warning cases)
const result = await appSub.exec(['update-ca-certificates'], { user: 'root' })
if (result.exitCode !== 0) {
console.warn('Failed to update CA certificates:', result.stderr)
}
// execFail() - throws on error (good for required commands)
// Uses the default user from the Dockerfile (no need to specify { user: '...' })
await appSub.execFail(['git', 'clone', 'https://github.com/user/repo.git'])
// Override user when needed (e.g., run as root)
await appSub.exec(['update-ca-certificates'], { user: 'root' })
The user option is optional. If omitted, commands run as the default user defined in the Dockerfile (USER directive). Only specify { user: 'root' } when you need elevated privileges.
Use execFail() when:
- The command must succeed for the service to work correctly
- You are in 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 accessPOSTGRES_PASSWORD: Auto-generated password, stored instore.jsondisplay: null: Internal sidecar health checks are typically not shown to the user
Connection Strings
When the upstream service needs a PostgreSQL connection string, include the password:
.addDaemon('app', {
subcontainer: appSub,
exec: {
command: sdk.useEntrypoint(),
env: {
// Standard PostgreSQL URI
DATABASE_URL: `postgresql://postgres:${pgPassword}@127.0.0.1:5432/mydb`,
// Or .NET-style: `User ID=postgres;Password=${pgPassword};Host=127.0.0.1;Port=5432;Database=mydb`
},
},
requires: ['postgres'],
})
Note
The Docker entrypoint for the official
postgresimage handles initial database creation automatically. You do not need to runcreatedborinitdbmanually on fresh installs.
Querying PostgreSQL from Actions
Some actions need to query PostgreSQL directly (e.g., resetting a user password). Read the password from the store:
import { Client } from 'pg'
const pgPassword = (await storeJson.read(s => s.pgPassword).once()) || ''
const client = new Client({
user: 'postgres',
password: pgPassword,
host: '127.0.0.1',
database: 'mydb',
port: 5432,
})
try {
await client.connect()
await client.query(`UPDATE "Users" SET "PasswordHash"=$1 WHERE "Id"=$2`, [hash, userId])
} finally {
await client.end()
}
Warning
When interpolating values into raw SQL strings (e.g., for
psql -c), always escape single quotes to prevent SQL injection:function sqlLiteral(value: string): string { return `'${value.replace(/'/g, "''")}'` } // Use in psql commands await sub.execFail(['psql', '-c', `ALTER USER myuser PASSWORD ${sqlLiteral(password)}`], { user: 'postgres' })Prefer parameterized queries (the
$1syntax above) whenever possible — they handle escaping automatically.
Config File Generation
A common pattern is to define a helper function that generates a config file string from your service’s configuration values:
function generateConfig(config: { secretKey: string; allowedHosts: string[] }): string {
const hostsList = config.allowedHosts.map(h => `'${h}'`).join(', ')
return `
SECRET_KEY = '${config.secretKey}'
ALLOWED_HOSTS = [${hostsList}]
DATABASE = '/data/db.sqlite3'
`
}
Initialization
setupOnInit runs during container initialization. The kind parameter indicates why init is running:
| Kind | When | Use For |
|---|---|---|
'install' | Fresh install | Generate internal secrets, seed file-model defaults, create critical tasks for user setup actions, bootstrap via API |
'update' | After a package version upgrade | Re-apply config, handle post-migration setup |
'restore' | Restoring from backup | Re-register triggers; credentials are already present from the restored store |
null | Container rebuild, server restart | Register long-lived triggers (e.g., .const() watchers) |
Init Kinds
Install Only
For one-time setup that generates new state. Internal-only secrets (DB password, JWT secret, etc.) are generated here, because no user interaction is involved:
export const 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:
- The daemon starts and runs its health check
- Once healthy, the dependent oneshot executes
- When the oneshot completes successfully,
runUntilSuccessreturns - All processes are cleaned up automatically
Making HTTP Calls Without curl
Many slim Docker images do not have curl. Use the runtime’s built-in HTTP capabilities instead.
Node.js (v18+):
command: [
'node',
'-e',
`fetch('http://127.0.0.1:${port}/api/endpoint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'value' })
}).then(r => r.ok ? process.exit(0) : process.exit(1))
.catch(() => process.exit(1))`,
]
Python:
command: [
'python',
'-c',
`import urllib.request, json
req = urllib.request.Request(
'http://127.0.0.1:${port}/api/endpoint',
data=json.dumps({'key': 'value'}).encode(),
headers={'Content-Type': 'application/json'},
method='POST'
)
urllib.request.urlopen(req)`,
]
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) returnis the common guard for “install, update, or restore — but not a plain container rebuild.” The inverse (if (kind) return) would mean “only on rebuild” — almost never what you want.
Empty-Seed Inits: Drop the kind Parameter
When a setupOnInit does nothing but seed file models with their schema defaults (fileModel.merge(effects, {})), drop the kind parameter entirely — the overhead of running on every init is negligible, and it keeps the logic trivially correct:
// init/seedFiles.ts
export const seedFiles = sdk.setupOnInit(async effects => {
await storeJson.merge(effects, {})
await configToml.merge(effects, {})
})
Reach for the kind check only when the body needs to behave differently between install / update / restore / rebuild.
Note
Always use
merge()(notwrite()) to seed file models, even on first install. With every key in your zod schema carrying a.catch(),merge(effects, {})is enough to create the file and 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:
typeis 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 oftype, 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:
- Create a
MultiHostand bind a port with protocol and options - Create one or more interfaces using
sdk.createInterface() - Export the interfaces from the origin and return the receipt(s)
bindPort Options
| Option | Type | Description |
|---|---|---|
protocol | 'http' | 'https' | null | The protocol. Use null for raw TCP (non-HTTP). |
preferredExternalPort | number | The port users will see in their URLs. |
addSsl | object | null | SSL termination options for HTTPS. Set to null for no SSL. |
addSsl.alpn | string | null | ALPN protocol negotiation (e.g., 'h2'). Usually null. |
addSsl.preferredExternalPort | number | External port for SSL connections. |
addSsl.addXForwardedHeaders | boolean | Whether to add X-Forwarded-* headers. |
addSsl.auth | ProxyAuth | null | Optional auth gate enforced by the OS reverse proxy. See Authenticating at the Proxy. |
addSsl.upstreamCertValidation | 'disable' | { certificate: string } | omitted | How 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 } | null | For non-HTTP protocols, whether the connection is secure. |
Interface Options
sdk.createInterface(effects, {
name: i18n('Display Name'), // Shown in UI (wrap with i18n)
id: 'unique-id', // 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
})
| Option | Type | Description |
|---|---|---|
name | string | Display name shown to the user. Wrap with i18n(). |
id | string | Unique identifier. How you find this interface at runtime, by walking the host from sdk.host.getOwn() (see main.ts). |
description | string | Description shown to the user. Wrap with i18n(). |
type | 'ui', 'api', or 'p2p' | 'ui' for browser interfaces, 'api' for programmatic endpoints, 'p2p' for peer-to-peer connections. |
masked | boolean | If true, the interface URL is shown as a copyable secret. Use for URLs containing credentials or tokens. |
schemeOverride | { ssl: string | null; noSsl: string | null } | null | Override the URL scheme for custom protocols. For example, { ssl: 'lndconnect', noSsl: 'lndconnect' } produces lndconnect:// URLs. Use null for standard http/https. |
username | string | null | Username embedded in the URL (e.g., for smp://fingerprint:password@host). |
path | string | URL path appended to the base address (e.g., '/admin/'). |
query | object | URL query parameters as key-value pairs (e.g., { macaroon: 'abc123' }). |
Tip
The
idyou assign to an interface is what you use inmain.tsto retrieve hostnames for it. Interfaces are reached through their host:sdk.host.getOwn(effects, hostId)returns the host, and the interface lives athost.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 option | Type | Description |
|---|---|---|
id | string | Unique identifier for the range interface. |
name | string | Display name shown to the user. Wrap with i18n(). |
description | string | Description shown to the user. Wrap with i18n(). |
scheme | string | null | Optional 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:
| Value | Behavior |
|---|---|
| omitted | Validate 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 field | Type | Description |
|---|---|---|
type | 'basic' | 'bearer' | The auth scheme the proxy enforces. |
credentials (basic) | Array<{ username, password }> | Accepted pairs. Any match passes. The matched username is forwarded upstream as X-Forwarded-User. |
tokens (bearer) | Array<string> | Accepted bearer tokens. Any match passes. |
realm | string | null | Realm advertised in the 401 WWW-Authenticate challenge. Defaults to "StartOS". Use a stable realm across bindings that share credentials so browsers reuse them. |
Setting auth implies HTTP-aware proxying, so it is only valid on the SSL-variant protocols above — not on raw TCP (protocol: null).
Note
The
usernamefield oncreateInterfaceis unrelated to this gate — it only embeds a username in the displayed URL (e.g.https://user@host/). The enforced credential check isaddSsl.auth.
Generating and rotating credentials
Don’t hard-code the password. Generate it at install time and let the user rotate it through an action. Store the credential in a file model such as store.json and read it reactively in setupInterfaces — when the action rewrites the stored value, setupInterfaces re-runs and the proxy picks up the new credential automatically:
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
const password = await storeJson.read(s => s.uiPassword).const(effects)
const uiMulti = sdk.MultiHost.of(effects, 'ui-multi')
const uiOrigin = await uiMulti.bindPort(uiPort, {
protocol: 'http',
addSsl: {
auth: { type: 'basic', credentials: [{ username: 'admin', password }], realm: null },
},
})
const ui = sdk.createInterface(effects, {
name: i18n('Web UI'),
id: 'ui',
description: i18n('The web interface'),
type: 'ui',
masked: false,
schemeOverride: null,
username: null,
path: '',
query: {},
})
return [await uiOrigin.export([ui])]
})
Seed uiPassword with a generated value during install init so the gate is active from first start, and pair it with a reset-password action that rewrites the stored value and surfaces it to the user once. See Reset Password.
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.
preferredExternalPortis 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 whenpreferredExternalPortis 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:
-
Read
net.assignedPort, keyed by the dependency’s internal port — not anaddressInfohostname.assignedPortis the minimal thing that identifies the bridge address, and it changes only when the external port changes. A binding’saddressInfohostname 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+assignedPortis stable against all of that.rpcHostIdandrpcInternalPortare imported from the dependency’s package (bitcoin-core-startos/startos/utils), not hardcoded — the internal port and host id are the stable contract. -
.const()on a minimal mapped value..const()re-runsmainonly 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:Event Restarts your service? Dependency updated No — its assigned port survives an update Dependency installed after yours Once, to heal onto the now-resolvable address Dependency uninstalled Once, to reconfigure to the absent state Dependency reinstalled, same port No Dependency reinstalled, new port Once, to heal Conditional binding appears (e.g. LND unlock) Once, then stable across lock/unlock Do not use
.once()(it snapshotsnullforever if the dependency isn’t installed yet — your service never heals when the user installs the dependency second) or.waitFor()(it blocksmainbefore 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. -
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 like127.0.0.1:8332for 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.json — never 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>.startosDNS 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. (getContainerIpwith nopackageId— your own container IP — remains fine.)
Reference implementations
bitcoin-core—startos/utils.ts(bridgeAddresshelper) andstartos/main.ts(torSockswith thefallbackPortcase).lnd—startos/utils.tsresolves bitcoind’s RPC and ZMQ hosts; a conditional (unlock-gated) binding handled by the same.const()pattern.mempool—startos/utils.ts+startos/file-models/store.json.ts: the backend-selection choice instore.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:
- Start with registrations enabled in the initial config.
- Create an important task in
setupOnInitadvising the user to disable registrations after creating their admin account. - Provide a toggle action that reads the current registration state, flips it, and writes back.
// In init/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 forsetupMain— it’s wasted overhead in a prefill, which is a one-shot read at the moment the form opens.
SMTP Configuration
The SDK provides a built-in SMTP input specification for managing email credentials. This supports three modes: disabled, system SMTP (from StartOS settings), or custom SMTP with provider presets (Gmail, Amazon SES, SendGrid, Mailgun, Proton Mail, or custom).
1. Add SMTP to store.json.ts
Use the SDK’s smtpShape zod schema in your store’s shape definition. See File Models for more on file model patterns.
import { FileHelper, smtpShape, z } from '@start9labs/start-sdk'
import { sdk } from '../sdk'
const shape = z.object({
adminPassword: z.string().optional(),
secretKey: z.string().optional(),
smtp: smtpShape,
})
export const storeJson = FileHelper.json({ base: sdk.volumes.main, subpath: './store.json' }, shape)
2. Create the manageSmtp Action
Use smtpPrefill() in the prefill function to bridge between the stored SmtpSelection type and the input spec’s expected type. These types represent the same data but are structurally different in TypeScript (the store uses a flat union, the input spec uses a distributed discriminated union), so smtpPrefill() handles the conversion.
import { smtpPrefill } from '@start9labs/start-sdk'
import { i18n } from '../i18n'
import { storeJson } from '../fileModels/store.json'
import { sdk } from '../sdk'
const { InputSpec } = sdk
export const inputSpec = InputSpec.of({
smtp: sdk.inputSpecConstants.smtpInputSpec,
})
export const manageSmtp = sdk.Action.withInput(
'manage-smtp',
async ({ effects }) => ({
name: i18n('Configure SMTP'),
description: i18n('Add SMTP credentials for sending emails'),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
inputSpec,
// Pre-fill form with current values
async ({ effects }) => ({
smtp: smtpPrefill(await storeJson.read(s => s.smtp).const(effects)),
}),
// Save to store
async ({ effects, input }) => storeJson.merge(effects, { smtp: input.smtp }),
)
3. Register the Action
import { sdk } from '../sdk'
import { setAdminPassword } from './setAdminPassword'
import { manageSmtp } from './manageSmtp'
export const actions = sdk.Actions.of().addAction(setAdminPassword).addAction(manageSmtp)
4. Use SMTP Credentials at Runtime
In your main.ts, resolve the SMTP credentials based on the user’s selection:
import { T } from '@start9labs/start-sdk'
export const main = sdk.setupMain(async ({ effects }) => {
const store = await storeJson.read().const(effects)
// Resolve SMTP credentials based on selection
const smtp = store?.smtp
let smtpCredentials: T.SmtpValue | null = null
if (smtp?.selection === 'system') {
// Use system-wide SMTP from StartOS settings
smtpCredentials = await sdk.getSystemSmtp(effects).const()
if (smtpCredentials && smtp.value.customFrom) {
smtpCredentials.from = smtp.value.customFrom
}
} else if (smtp?.selection === 'custom') {
// Use custom SMTP credentials from the selected provider
const { host, from, username, password, security } = smtp.value.provider.value
smtpCredentials = {
host,
port: Number(security.value.port),
from,
username,
password: password ?? null,
security: security.selection,
}
}
// If smtp.selection === 'disabled', smtpCredentials remains null
// Pass to config generation
const config = generateConfig({
smtp: smtpCredentials,
// ... other config
})
// ...
})
5. Initialize with SMTP Disabled
In init/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
| Parameter | Type | Description |
|---|---|---|
effects | Effects | Provided by the calling context |
action | ActionDefinition | The action to prompt the user to run |
severity | 'critical' | 'important' | 'optional' | How urgently the task is surfaced in the UI |
options | { reason: string } | Human-readable explanation shown to the user |
Severity Levels
- critical — Blocks the service from starting until the user completes the task. Use for essential setup like creating admin credentials or selecting a backend.
- important — Prominently displayed but does not block the service. Use for post-install reminders like disabling registrations.
- optional — Informational, least prominent.
Common Patterns
Prompt When Credentials Are Unset
The standard admin-credentials pattern: init reads the store and surfaces a critical task when the password is unset. Generation lives in the matching action, which covers both first-set and later rotation. The watcher runs on every init kind; the prompt is idempotent (see Idempotency and replayId), so a container rebuild after the password is set is a no-op:
export const watchCredentials = sdk.setupOnInit(async effects => {
const store = await storeJson.read().const(effects)
if (!store?.adminPassword) {
await sdk.action.createOwnTask(effects, setAdminPassword, 'critical', {
reason: i18n('Set the admin password before signing in'),
})
}
})
See the Prompt User to Create Admin Credentials recipe for the matching action.
Prompt for Required Configuration
Ask the user to configure something before the service can function:
await sdk.action.createOwnTask(effects, manageSmtp, 'important', {
reason: i18n('Configure email settings to enable notifications'),
})
Dependency Tasks
Use sdk.action.createTask() to prompt the user to run an action on a dependency service. The action must be imported from the dependency’s package.
import { someAction } from 'dependency-package/startos/actions/someAction'
export const setDependencies = sdk.setupDependencies(async ({ effects }) => {
await sdk.action.createTask(effects, 'dependency-id', someAction, 'critical', {
input: {
kind: 'partial',
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
| Parameter | Type | Description |
|---|---|---|
effects | Effects | Provided by the calling context |
packageId | string | The dependency’s service ID |
action | ActionDefinition | Imported from the dependency’s package |
severity | 'critical' | 'important' | 'optional' | How urgently the task is surfaced |
options | object | See below |
Options
| Field | Type | Description |
|---|---|---|
input | { kind: 'partial', 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 |
reason | string | Human-readable explanation shown to the user |
replayId | string (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.jsonso the action can be imported (e.g.,"synapse-startos": "file:../synapse-wrapper"). See Dependencies for more on cross-service integration.
Idempotency and replayId
Tasks are idempotent by default. The SDK computes a default replayId of [package-id]:[action-id], so calling createOwnTask / createTask multiple times with the same action does not create duplicate tasks — subsequent calls are no-ops against the same replay key. You can safely re-run your init function on every container rebuild without accumulating stale tasks.
Provide a custom replayId only when you need to intentionally create multiple distinct tasks for the same action (e.g., one-per-peer setup prompts). Each unique replayId becomes a separate task.
To cancel a task programmatically, clear it by its replay key:
await sdk.action.clearTask(effects, 'my-service:set-admin-password')
Notifications
Notifications are messages your service can post to the StartOS notifications panel — the same panel where StartOS shows backup-completion notices, install failures, and similar OS-generated events. Use them sparingly, only for information the user genuinely needs to know about — most commonly that a long-running action has finished: a sync health check that finally passes, a lengthy reindex or migration completing. They are not a changelog feed or an activity log; the vast majority of what your service does should not produce one. If you need the user to do something, use a Task instead.
The host attributes every notification to the calling service automatically — a package cannot post notifications on behalf of another package.
Plain Notification
Omit data for a notification with no extra payload. The notifications panel shows the title and message directly in the row.
await sdk.notification.create(effects, {
level: 'info',
title: 'Sync Complete',
message: 'Initial block download finished.',
})
Notification With Markdown Details
Pass data as markdown text when the notification carries long-form content that doesn’t belong inline. The panel still shows title and message in the row, and a “View Details” button opens data rendered as markdown in a large modal. Typical uses: a completion summary for a long-running operation, or a diagnostic report for a recoverable error.
data should be markdown text — not a short status string.
await sdk.notification.create(effects, {
level: 'success',
title: 'Reindex Complete',
message: 'The transaction index finished rebuilding. Tap for a summary.',
data: ['## Reindex summary', '', '- Blocks processed: 812,043', '- Duration: 3h 14m', '- Index size: 4.2 GiB', '', 'No further action is needed — the service is fully synced.'].join('\n'),
})
Parameters
| Parameter | Type | Description |
|---|---|---|
effects | Effects | Provided by the calling context |
level | 'success' | 'info' | 'warning' | 'error' | Severity, controls the icon and color in the panel |
title | string | Short headline shown in the row |
message | string | One-line body shown in the row beneath the title |
data | string | null (optional) | Optional markdown body rendered in the “View Details” modal. Omit for a plain (panel-row-only) notification |
Common Patterns
Notify on Sync Completion
Post a one-time success notification from a daemon’s health check or main flow when long-running work finishes:
await sdk.notification.create(effects, {
level: 'success',
title: i18n('Sync Complete'),
message: i18n('Bitcoin Core has finished initial block download.'),
})
Report a Recoverable Error With Details
Pair a short message with full diagnostic output in data so the user gets context without dumping a wall of text into the panel row:
await sdk.notification.create(effects, {
level: 'warning',
title: i18n('Backup Skipped'),
message: i18n('A non-critical backup step was skipped. Tap for details.'),
data: ['## Skipped: optional thumbnail cache', '', '`/data/cache/thumbnails` was not present, so it was skipped during this backup.', 'No data was lost — the cache will be regenerated on next use.', '', '```', err.stack ?? String(err), '```'].join('\n'),
})
Note
Notifications are not idempotent — every call creates a new entry. If a daemon’s health loop calls
sdk.notification.create()on every poll, the panel will fill up. Gate on a one-shot condition (a flag in your store, a state transition, etc.) so you only post when something actually changed.
File Models
File Models represent configuration files as TypeScript definitions using zod schemas. They provide type safety, runtime validation, and automatic enforcement of defaults and hardcoded values throughout your codebase.
Supported Formats
File Models support automatic parsing and serialization for:
.json.yaml/.yml.toml.xml.ini.env
Custom parser/serializer support is available for non-standard formats via FileHelper.raw().
Core Principle: Lean on File Models
File models are not just type definitions — they are your primary tool for enforcing runtime correctness. The zod schema is both the shape definition and the source of truth for default values. Every key should have a .catch() so that:
- Missing keys are filled with defaults automatically
- Invalid values are corrected on the next
merge() - Files can be seeded with
merge(effects, {})on first install — no separate default object needed - Hardcoded values (ports, paths, auth modes) are enforced on every read
When done correctly, the shape itself eliminates the need for separate default constants, defensive checks, and manual file initialization.
Creating a File Model
store.json.ts (Common Pattern)
The most common file model is store.json, used to persist internal service state:
import { FileHelper, z } from '@start9labs/start-sdk'
import { sdk } from '../sdk'
const shape = z.object({
adminPassword: z.string().optional().catch(undefined),
secretKey: z.string().optional().catch(undefined),
someNumber: z.number().catch(0),
someFlag: z.boolean().catch(false),
})
export const storeJson = FileHelper.json({ base: sdk.volumes.main, subpath: './store.json' }, shape)
YAML Configuration
import { FileHelper, z } from '@start9labs/start-sdk'
import { sdk } from '../sdk'
const serverSchema = z.object({
host: z.string().catch('localhost'),
port: z.number().catch(8080),
})
const shape = z.object({
server: serverSchema.catch(() => serverSchema.parse({})),
features: z.array(z.string()).catch([]),
})
export const configYaml = FileHelper.yaml({ base: sdk.volumes.main, subpath: 'config.yaml' }, shape)
TOML Configuration
import { FileHelper, z } from '@start9labs/start-sdk'
import { sdk } from '../sdk'
const shape = z.object({
api_bind: z.literal('0.0.0.0').catch('0.0.0.0'),
api_port: z.literal(9814).catch(9814),
debug: z.literal(false).catch(false),
subscription_slots: z.literal(10_000).catch(10_000),
})
export const configToml = FileHelper.toml({ base: sdk.volumes.main, subpath: 'config.toml' }, shape)
XML Configuration
XML support includes options for controlling array detection during parsing:
import { FileHelper, z } from '@start9labs/start-sdk'
import { sdk } from '../sdk'
const knownProxiesSchema = z.object({
// 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
| Method | Purpose |
|---|---|
.once() | Read once, no reactivity |
.const(effects) | Read and re-run the enclosing context if value changes |
.onChange(effects, callback) | Register a callback for value changes |
.watch(effects) | Create an async iterator of new values |
.waitFor(effects, predicate) | Block until the value satisfies a predicate |
Note
All read methods return
nullif the file doesn’t exist. Do NOT use try-catch for missing files.
Use the Map Function
When reading file models, always use the map function to extract only the fields you need. This is critical for two reasons:
- Avoids unnecessary restarts: With
.const(effects), the daemon only restarts when the mapped value changes, not when any field in the file changes. - Avoids unnecessary callbacks: With
.onChange(effects)or.watch(effects), your callback only fires when the specific field you care about changes.
// BAD: daemon restarts when ANY field changes, even unrelated ones
const store = await storeJson.read().const(effects)
const secretKey = store?.secretKey
// GOOD: daemon only restarts when secretKey changes
const secretKey = await storeJson.read(s => s.secretKey).const(effects)
Warning
Never use an identity mapper like
.read((s) => s). Either omit the mapper to get the full object (.read()) or use it to extract a specific field (.read((s) => s.someField)).
Examples
// One-time read (no restart on change) - returns null if file doesn't exist
const store = await storeJson.read().once()
// Handle missing file with nullish coalescing
const keys = (await authorizedKeysFile.read().once()) ?? []
// Reactive read of a specific field - daemon only restarts if secretKey changes
const secretKey = await storeJson.read(s => s.secretKey).const(effects)
// Read nested values
const serverHost = await configYaml.read(c => c.server.host).once()
// Wait until a condition is met (blocks until predicate returns true)
const syncedStore = await storeJson.read(s => s.fullySynced).waitFor(effects, synced => synced === true)
Writing File Models
Prefer merge() Over write()
Use merge() for almost all writes. It has two major advantages:
- Preserves unknown keys:
merge()only updates the fields you specify, leaving everything else intact — including keys that the upstream service uses but your file model doesn’t define.write()replaces the entire file, destroying any keys not in your schema. See Unknown Key Preservation for details and migration implications. - Defaults come from the schema: When every key in your zod schema has a
.catch(), the schema is the default. You can seed a file on first install withmerge(effects, {})— the.catch()values fill in every missing field. No need to define a separate defaults object and pass it towrite().
// Seed a file on first install — .catch() defaults fill everything in
await configToml.merge(effects, {})
// Update specific fields, preserve everything else
await storeJson.merge(effects, { someFlag: false })
// Update nested fields
await configYaml.merge(effects, { server: { port: 9090 } })
Only use write() when you intentionally want to replace the entire file — for example, when generating a file from scratch during a migration:
// write() replaces the entire file — use only when that's the intent
await storeJson.write(effects, {
adminPassword: generatedPassword,
secretKey: generatedKey,
smtp: { selection: 'disabled', value: {} },
})
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 content | Result |
|---|---|
| Key present and valid | Untouched |
| Key missing | Filled from its .catch() default |
| Key present but invalid | Replaced by its .catch() default — this is the self-healing |
| Key outside the schema | Preserved (see Unknown Key Preservation) |
| Comments, formatting | Not 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
undefinedfor that. And because it re-serializes, the first empty merge against a hand-written or upstream-generated.toml/.yamldiscards 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 usingwrite()instead of relying onmerge(effects, {}).
Deep Nesting
When a schema has multiple levels of nesting, extract each level into its own variable. This keeps the top-level shape readable and ensures .catch() works at every depth:
import { FileHelper, z } from '@start9labs/start-sdk'
import { sdk } from '../sdk'
// Level 2: nested object
const dbDefault = { path: '/data/app.db', journal_mode: 'wal' }
const dbShape = z
.object({
path: z.literal('/data/app.db').catch(dbDefault.path),
journal_mode: z.string().catch(dbDefault.journal_mode),
})
.catch(dbDefault)
// Level 2: array item
const endpointDefault = { port: 8080, tls: false }
const endpointShape = z
.object({
port: z.number().catch(endpointDefault.port),
tls: z.boolean().catch(endpointDefault.tls),
})
.catch(endpointDefault)
// Top level
const shape = z.object({
database: dbShape,
endpoints: z.array(endpointShape).catch([endpointDefault]),
log_level: z.string().catch('info'),
max_upload_size: z.string().catch('50M'),
})
export const configYaml = FileHelper.yaml({ base: sdk.volumes.main, subpath: 'config.yaml' }, shape)
The key technique: define each nested level’s default and shape separately, then compose them. Every level has its own .catch() so missing or malformed data at any depth resolves to sane defaults.
Hardcoded Literal Values
For values that should always be a specific literal and never change (e.g., internal ports, paths, auth modes), use z.literal().catch(). If the file ends up with a different value (e.g., user edits it manually), it is corrected on the next merge():
const shape = z.object({
// Enforced — always corrected back to these values
api_bind: z.literal('0.0.0.0').catch('0.0.0.0'),
api_port: z.literal(9814).catch(9814),
btc_network: z.literal('mainnet').catch('mainnet'),
debug: z.literal(false).catch(false),
// Mutable — can be changed by actions
subscription_slots: z.number().catch(10_000),
})
This pattern is especially useful for upstream config files where you need to lock down certain values while still letting the user configure others through actions.
Reparse raw Through Shape in formToFile
When a FileHelper.ini uses an InputSpec’s partialValidator as its validator and exposes the raw file as raw: Value.hidden(shape), formToFile must reparse rawInput through shape before spreading it. Otherwise, the first install seed writes an empty file — the enforced .catch() defaults in shape never fire, and the daemon starts with upstream defaults instead of the locked-down values.
export const shape = z.object({
'rpc-bind-ip': z.literal('0.0.0.0').catch('0.0.0.0'),
'rpc-bind-port': z.literal(18081).catch(18081),
// ...more enforced + configurable keys
})
export const fullConfigSpec = InputSpec.of({
raw: Value.hidden(shape),
// ...user-facing form fields
})
function formToFile(input: T.DeepPartial<typeof fullConfigSpec._TYPE>): Conf {
const { raw: rawInput, ...rest } = input
// Reparse through shape so .catch() defaults fire when rawInput is undefined.
const raw = shape.parse(rawInput ?? {})
return {
...raw,
// ...form-derived fields
}
}
export const confFile = FileHelper.ini(
{ base: sdk.volumes.main, subpath: 'my.conf' },
fullConfigSpec.partialValidator,
{ bracketedArray: false },
{
onRead: a => fileToForm(shape.parse(a)),
onWrite: a => formToFile(a),
},
)
Why it matters: partialValidator makes every field of fullConfigSpec optional — including raw. On first install (confFile.merge(effects, {}) from seedFiles), rawInput arrives undefined, so ...raw spreads nothing. zod’s .catch() defaults only fire under shape.parse(). Calling shape.parse(rawInput ?? {}) is what forces them. On subsequent writes, onRead has already produced a fully populated conf, so the reparse is idempotent.
Alternative: Some packages (bitcoin-core, cln) hardcode the enforced values in both shape (z.literal(X).catch(X)) and again inside formToFile. That works but duplicates the source of truth — two places to update if a value changes. The reparse keeps shape as the single source.
Unknown Key Preservation
The SDK patches z.object() to use loose mode by default — unknown keys in the parsed data are preserved, not stripped, 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
zfrom@start9labs/start-sdkand usez.object. You never needz.looseObject. Plain zod’sz.objectstrips unknown keys, so a reader who knows zod reaches forlooseObjectto protect a two-way-bound config file — but the SDK’sz.objectalready preserves them, deeply.z.looseObjectis still exported and still compiles; it is not the convention.
This has two important consequences:
merge()never removes keys you don’t mention. Only keys explicitly passed tomerge()are updated. Everything else — including keys outside your schema — passes through untouched.- Stale keys from previous versions persist. If an earlier version of your package wrote keys that the current version no longer uses, those keys survive across updates. They are not automatically cleaned up by
merge()or by the zod schema.
To delete a stale key, pass it as undefined in a merge() call:
// Remove keys that no longer exist in the current version
await configToml.merge(effects, {
old_deprecated_key: undefined,
removed_plugin_setting: undefined,
})
When stale keys are outside your schema’s type, cast the merge data:
await configToml.merge(effects, {
legacy_key: undefined,
} as any)
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 } })againstusers: z.record(...).catch({})makes the wholeusersvalue fail validation, so the.catch({})replaces the entire record with{}— every entry is wiped, not justbob. To drop one entry while keeping the rest, rebuild the value in code andwrite()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 merge | What happens to what you left out |
|---|---|
| An object without a key | The key is kept — merge() never deletes a key you don’t mention (see Unknown Key Preservation) |
| An array without an entry | The 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 (theirreadyIDs) or standalone health checks (addHealthCheckIDs) that should be passing.
If your service genuinely cannot operate before a dependency reaches a particular state (a file exists, an RPC responds, a config is generated), handle that at runtime in setupMain — poll the dependency, retry, or surface your own error. Don’t rely on the dependency declaration to block startup for you.
Creating Cross-Service Tasks
Use sdk.action.createTask() in dependencies.ts to trigger an action on a dependency. The action must be exported from the dependency’s package.
import { i18n } from './i18n'
import { sdk } from './sdk'
import { someAction } from 'dependency-package/startos/actions/someAction'
export const setDependencies = sdk.setupDependencies(async ({ effects }) => {
await sdk.action.createTask(effects, 'dependency-id', someAction, 'critical', {
input: {
kind: 'partial',
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.replayIdprevents duplicate tasks across restarts.
Important
acceptentries 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’sprune, for example, reads as the number0on an unpruned node (its file model coerces an absentpruneto0), soaccept: [{ prune: 0 }]matches an unpruned node exactly. Match the concrete value the input actually holds.An
acceptfield value is compared for equality:nullmatches the literal valuenull— 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. (undefinedmeans absence, notnull: writingundefinedfor a field in aset/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
| Target | Description |
|---|---|
make or make all | Build for all architectures (default) |
make x86 | Build for x86_64 only |
make arm | Build for aarch64 only |
make riscv | Build for riscv64 only |
make universal | Build a single package containing all architectures |
make install | Install the most recent .s9pk to your StartOS server |
make clean | Remove build artifacts |
Variables
| Variable | Default | Description |
|---|---|---|
ARCHES | x86 arm riscv | Architectures to build by default |
TARGETS | arches | Default build target |
VARIANT | (unset) | Optional variant suffix for package name |
Makefile
The project Makefile is minimal and just includes s9pk.mk:
include 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.
-
Point your workspace at the device. Edit
.startos/config.yaml(at the workspace root, not~/.startos/config.yaml) sohost.defaultis your device’s address:host: default: https://your-device.local -
Log in once.
start-clineeds a session on the device:start-cli auth loginEnter your StartOS master password when prompted.
-
Build and install for your device’s architecture (
x86orarm) — the fast path for development:make x86 install # or: make arm install(
make installon its own installs the most recent build. Reach formake universalonly when publishing — building all architectures is slower and unnecessary for local testing.)
Note
make installtalks 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.s9pkthrough the web interface instead (see Sideloading), which needs no certificate setup.To install to a device other than
host.default, runstart-clidirectly 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:
- Humans – clear, scannable, with practical examples
- AI assistants – structured data that can be parsed programmatically
Recommended Structure
<p align="center">
<img src="icon.svg" alt="[Service Name] Logo" width="21%">
</p>
# [Service Name] on StartOS
> **Upstream docs:** <https://docs.example.com/>
>
> Everything not listed in this document should behave the same as upstream
> [Service Name]. If a feature, setting, or behavior is not mentioned here,
> the upstream documentation is accurate and fully applicable.
[Brief description of what the service does and link to upstream repo]
---
## Table of Contents
[Links to each section — must include all sections present in the README]
---
## Image and Container Runtime
[Image source, architectures, entrypoint modifications]
## Volume and Data Layout
[Mount points, data directories, StartOS-specific files like store.json]
## Installation and First-Run Flow
[How setup differs from upstream -- skipped wizards, auto-configuration, initial credentials]
## Configuration Management
[Which settings are managed by StartOS vs configurable via upstream methods]
## Network Access and Interfaces
[Exposed ports, protocols, access methods]
## Actions (StartOS UI)
[Each action: name, purpose, availability, inputs/outputs]
## Backups and Restore
[What's backed up, restore behavior]
## Health Checks
[Endpoint, grace period, messages]
## Dependencies
[Required and optional dependencies — version constraints, health checks, mounted volumes, purpose]
## Limitations and Differences
[Numbered list of key limitations compared to upstream]
## What Is Unchanged from Upstream
[Explicit list of features that work exactly as documented upstream]
## Contributing
[Link to 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
Logo
Every README should begin with the service icon centered above the title. Use the standard format:
<p align="center">
<img src="icon.svg" alt="[Service Name] Logo" width="21%" />
</p>
Adjust the src to match the actual icon filename (e.g., icon.png if the icon is a PNG).
Image and Container Runtime
| What to Document | Example |
|---|---|
| Image source | Upstream unmodified, or custom Dockerfile |
| Architectures | x86_64, aarch64, riscv64 |
| Entrypoint | Default or custom |
Volume and Data Layout
| What to Document | Example |
|---|---|
| Volume names | main, data, config |
| Mount points | /data, /config |
| StartOS files | store.json for persistent settings |
| Database | Embedded SQLite vs external |
Installation and First-Run Flow
Document if your package:
- Skips an upstream setup wizard
- Auto-generates credentials
- Pre-configures settings
- Creates tasks for initial setup
Configuration Management
Use a table to clarify the division of responsibility:
| StartOS-Managed | Upstream-Managed |
|---|---|
| Settings controlled via actions/env vars | Settings configurable via app’s own UI/config |
Actions
For each action, document:
- Name: What users see in the StartOS UI
- Purpose: What it does
- Visibility: Visible, hidden, or conditional
- Availability: Any status, only running, only stopped
- Inputs: What users provide
- Outputs: Credentials, confirmation, etc.
Network Interfaces
For each interface:
- Port number
- Protocol (HTTP, SSH, etc.)
- Purpose (UI, API, etc.)
- Access methods (LAN IP, .local, .onion, custom domains)
Backups
- What volumes/data are included
- Data NOT backed up (if any)
- Restore behavior
Health Checks
- Endpoint or method
- Grace period
- Success/failure messages
Dependencies
For each dependency, document:
- Service name and whether it is required or optional
- Version constraint (e.g.
>= 28.3) - Health checks that must pass before this service starts
- Mounted volumes — if a dependency volume is mounted, note the mount point and whether it is read-only
- Purpose — why this dependency is needed (e.g. “blockchain data via RPC”, “Electrum lookups”)
If the service has no dependencies, state “None” explicitly.
Limitations
Be explicit about:
- Features that do not work or work differently
- Unavailable configuration options
- Unsupported dependencies
- Version-specific limitations
AI Quick Reference
End every README with a YAML block for machine parsing. This block should contain the package ID, upstream version, image, architectures, volumes, ports, dependencies, managed environment variables, and action IDs.
Pre-Publish Checklist
- Centered logo header at the top of the file
- Upstream docs linked at the top
- All volumes and mount points documented
- All actions documented with their purpose
- All StartOS-managed settings/env vars listed
- All dependencies documented (or “None” stated explicitly)
- All limitations listed explicitly
- “What Is Unchanged” section included
- YAML quick reference block for AI consumers
- No specific version numbers anywhere (image tags, upstream version, dep version constraints — all stale the moment you bump)
- Tested that documented features match actual behavior
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.
| README | instructions.md | |
|---|---|---|
| Audience | Developers, AI assistants, contributors | End users running the service on StartOS |
| Question it answers | “How does this package work, and how does it differ from running the upstream service directly?” | “I just installed this — now what? How do I use it on StartOS?” |
| Tone | Technical, structured, scannable for parsing | Practical, instructional, written in second person |
| Versions / image tags | Avoided (manifest is source of truth) | Avoided for the same reason |
| Upstream behavior | “Anything not listed here behaves as upstream documents” | Linked from the Documentation section; never duplicated |
| Surfaced where | The package repository on GitHub | Inside the StartOS UI, post-install |
If your README is a reference manual, your instructions are a quick-start guide for a non-developer who just clicked Install.
What belongs in instructions
A good instructions.md covers, roughly in this order:
-
A brief orientation — usually skip it. The reader already saw the marketplace short and long description before clicking Install, so don’t restate them. The default is to omit this section and go straight to Documentation. Add a line only if there is genuinely new context the listing did not cover — a hard ordering constraint, a permanent decision the user is about to make, or similar. “You’ve installed X” framing is not useful; the reader knows. Don’t pad.
-
Documentation links. A
## Documentationsection. Port exactly the URLs the manifest previously carried in itsdocsUrlsarray, each with a few words on what it is (“the upstream admin guide”, “the official Foo configuration reference”). Do not add marketing, donation, project-home, or support-channel links — those live elsewhere and were deliberately omitted fromdocsUrls. Link to canonical, stable URLs the upstream maintains — not specific commits, not your own README. -
What it gives you on StartOS — the practical answer to “why did I just install this?” Keep it concrete: the interfaces it exposes, the data it manages, the experience the StartOS package adds on top of upstream.
-
Getting set up — the smallest sequence of steps that takes a fresh install to a usable state. The reader has already installed the service — don’t include download or install steps. Start from first launch. Use numbered lists. Reference real action names, real interfaces, and real screens that exist in the StartOS UI for this service. If setup requires a dependency, say so plainly: “Install Bitcoin Core first” rather than “satisfy the dependency.”
-
Using the available features — once the service is running, what can the user actually do with it? Describe the interfaces (web UI, RPC, etc.) and the user-visible actions. Hidden actions (
visibility: 'hidden'in the package source — typically those invoked by the platform or by another service rather than by a human) do not belong here; the user never sees them. Likewise, do not parrotallowedStatusesfrom action source code (“the service must be running”, “the service must be stopped”): describe what the user actually encounters in the UI, and omit the qualifier when it’s noise. -
Important limitations — usually omit. The default is no Limitations section at all. Add one only if there is a specific, consequential thing the user will be surprised by: a deliberately disabled feature they may go looking for, a hard data caveat, an incompatibility worth flagging up front. Generic caveats (“performance depends on your hardware”, “encryption keys are sensitive”) are not limitations and do not belong here.
Note
Older StartOS manifests carried a
docsUrlsarray for upstream documentation links. That field has been removed — those links belong in the## Documentationsection of this file now, where you can give each one the context a bare URL in the manifest never had.
What does not belong in instructions
- A restatement of the marketplace description. The reader saw the short and long description before installing — opening with “Foo is a self-hosted bar” wastes their time. Start from “now what.”
- “You’ve installed X” or any other orientation that tells the reader something they already know. They installed it; that’s why they’re on this tab.
- Install or download steps. They’ve already installed the service. Begin at first launch.
- How StartOS itself works. The interface panel’s copy-address / QR-code / LAN-Tor-domain controls, the Dashboard and Instructions tabs, how backups and updates work, how to start or stop a service — these are platform features a user learns once, not per-package. Mention only what’s specific to this service: which interfaces it exposes and what each is for, which actions it adds and when to run them. Naming a screen to send the user to (“open it from the Dashboard tab”) is fine; explaining what that screen is, isn’t.
- Invented navigation paths. Don’t guess at how to reach a UI surface. Reference only screens, tabs, and tables that actually exist in StartOS for this service. “Set X in the network settings” is wrong if there is no such page; “add the domain on the Homeserver interface” is right if that’s where it actually lives.
- Hidden actions. Actions marked
visibility: 'hidden'in source — typically those invoked by the platform or by another package’s plugin handshake — are not user-facing. Do not list them, even to “explain” them. - Status preconditions for critical tasks. A critical task suspends every other control: the user does not see a Start / Stop / Run button while the task is required, only the task. Telling them “the service must be stopped” or “start the service first” before running a critical task is not just noise, it’s wrong.
- Platform plumbing the user can’t act on. “Registration is typically triggered automatically by the bridge service” tells the reader nothing they can do with the information. If they’d never act on a sentence, cut it.
- The full configuration reference. Link to upstream for that.
- Version numbers and image tags. They go stale every release; the manifest is the source of truth.
- Architectural detail about how the package is built. That is the README’s job.
- Reasons the package was structured a particular way. Users do not care.
- Internal terminology from the StartOS codebase (“ABI”, “task”, “manifest”, “subcontainer”). Use the words a user sees in the UI.
- Secrets, default passwords, or API keys hard-coded into the markdown. Generate those at install time and surface them via actions.
Style
- Write in the second person. “You will see…”, “When you click…”, “Before you start, make sure…”.
- Prefer numbered lists for any multi-step procedure.
- Use code blocks for commands the user might run, hostnames they might paste, or RPC calls — not for prose.
- Keep paragraphs short. Many users will scan, not read.
- Use H2 (
##) for top-level sections; reserve H1 for the service name at the top of the file. - StartOS will render the markdown through the same pipeline as release notes and licenses, so standard CommonMark + GFM tables work; exotic HTML may not.
Suggested structure
Use the sections that apply — a trivial service might be two paragraphs and a Documentation list; a complex one might need every section below and more. Don’t include a section just to have it (if the service has no actions, you usually needn’t say so).
# [Service Name]
[Optional, usually omit. Add one or two sentences only if there is genuinely new context the marketplace listing didn't cover — for example, a hard ordering constraint or a permanent decision the user is about to make. Otherwise delete this line and start with the section below.]
## Documentation
- [Upstream documentation](https://docs.example.org) — what it is in a few words (the config reference, the upstream README, etc.).
(Port exactly the URLs the manifest previously carried in `docsUrls`. Don't add marketing, project-home, donation, or support links here.)
## What you get on StartOS
[Concrete description of the StartOS experience: which interfaces are exposed, what data it manages, what the package adds on top of upstream.]
## Getting set up
1. [First concrete step the user should take after install.]
2. [Second step…]
3. [Until the service is in a usable state.]
> If your service depends on another, list the dependency explicitly here and tell the user to install it first.
## Using [Service Name]
[Describe the day-to-day experience. Interfaces, actions, common workflows. One short subsection per major capability is fine.]
### Web interface
[What this interface is for and what the user sees first — a login screen, a setup wizard, an empty dashboard. Not how the universal interface-panel controls work; those are identical in every service.]
### Actions
[Each StartOS action: what it does, when to run it.]
### [Other capability]
[…]
## Limitations
[Usually omit this section entirely. Include only if there is a specific, consequential surprise — a deliberately disabled feature the user may go looking for, an incompatibility worth flagging.]
Pre-publish checklist
- File exists at
instructions.mdat the package root (the build will fail otherwise). - Written for the user, not the developer — no internal SDK terminology.
- Does not restate the marketplace short/long description, contains no install or download steps, no “You’ve installed X” framing, and doesn’t explain StartOS platform features (interface controls, tabs, backups) the user already knows.
- All navigation references point at UI surfaces that actually exist for this service — no invented network-settings pages or pretend tabs.
- Setup steps walk from first launch to a usable state.
- Every action and interface mentioned actually exists in the package, and every action mentioned is
visibility: 'enabled'(hidden actions are not listed). - Status preconditions are described as the user actually sees them — critical tasks are not qualified with “stop the service first”;
allowedStatusesis not parroted when its gate is invisible to the user. - Every sentence is something the user could act on — no “this is typically triggered automatically by …” plumbing notes.
- No hard-coded version numbers, image tags, or secrets.
- Limitations section is omitted unless there is a specific, consequential surprise to flag.
- A
## Documentationsection ports the URLs the manifest’sdocsUrlspreviously carried, each with a few words of context. No added marketing / donation / project-home / support links. - Renders cleanly in the StartOS Instructions tab on a real install.
Publishing
Every .s9pk needs a registry to live in before it can be installed on a StartOS device. StartOS is deliberately flexible about which registry that is — you can run your own forever, submit to the Start9 Community Registry, or do both in parallel. Nothing about the packaging workflow requires you to distribute through Start9.
Self-Hosted Registry
The fastest and most autonomous path is to run your own registry — install the startos-registry service on a StartOS device, point start-cli at it, and publish. See Hosting a Registry for the full walkthrough (install, first-run setup, administration).
You can run a self-hosted registry in parallel with a Start9 Community submission: developers often keep an alpha/testing registry of their own while a more stable build is promoted through the community pipeline.
Start9 Community Registry
If you want your package on Start9’s official community registry, the current flow is email-driven. A developer portal with self-service submission and promotion is on the roadmap; until it ships, this is the interface.
The community registries, in promotion order:
- community-alpha — https://community-alpha-registry-x.start9.com — receives every PR-merge build automatically
- community-beta — https://community-beta-registry.start9.com — promoted from alpha on your request
- community (production) — https://community-registry.start9.com — promoted from beta on your request
Initial Submission
- Email submissions@start9.com with a link to your public GitHub repository.
- Start9 forks your repo into the Start9-Community GitHub organization and replies with any feedback.
- Address feedback by opening PRs against the Start9-Community fork, not your original repo. The fork becomes the upstream for the community pipeline from that point on.
The Pipeline
Once your fork exists inside Start9-Community:
- Open a PR against the fork with your changes.
- Merge — when Start9 merges the PR, a workflow automatically builds, tags, and deploys the package to community-alpha. You don’t run any publish commands yourself; the automation handles it.
- Promote to beta — when you’re ready for wider testing, email submissions@start9.com or open an issue on the fork. Start9 promotes the current alpha build to community-beta.
- Promote to production — when the beta has soaked and you’re ready to ship broadly, same signal (email or issue). Start9 promotes to community.
Every subsequent change or version bump is another PR through the same cycle — merge publishes to alpha, email/issue promotes onward.
Note
The email / issue loop is clunky — we know. A developer portal with self-service submission management and one-click promotion is actively being built. Until it ships, email and issues are how the pipeline is operated.
Pre-Publish Checklist
Before publishing to your own registry — or before opening / updating a PR on the Start9-Community fork — walk through this. For community submissions, these checks must pass before you open the PR: the merge triggers the build, and anything wrong will ship directly to community-alpha.
- Tag convention followed. Your version tag matches Git Tag Conventions.
- All checks pass.
tsc --noEmit, tests, and the pack step must be green. - README is current. Every action, volume, port, dependency, and limitation matches the code. No version numbers anywhere — see Writing READMEs.
- Tested end-to-end on StartOS. Installed cleanly, service started, UI loaded (if applicable), health checks went green. Uninstall and reinstall to confirm teardown works.
CLI Reference
The start-cli tool handles both building packages and managing registries. The full command reference lives in the start-cli Reference — these sections are most relevant to service developers:
- S9PK Packaging — build, inspect, edit, and publish
.s9pkpackages - Registry — manage packages, categories, signers, and OS versions on a registry
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.