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 objectives. 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
On Debian and its derivatives — Ubuntu, Raspberry Pi OS, Linux Mint — the script adds the Start9 apt repository and installs the start-cli package to /usr/bin, so sudo apt update && sudo apt upgrade picks up later releases. On macOS and every other Linux distribution it downloads the release binary into ~/.local/bin and adds that directory to your PATH; re-running the same command is how you update it there.
start-cli installs outside your workspace, so Keep it current does not touch it. You don’t have to track it yourself either: an s9pk command run inside a workspace whose checkout names a newer release prints a one-line notice saying so.
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), links the fleet’s agent skills where Claude Code and Codex look for them, 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.pem (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)
├── .claude/skills ← the packaging skills (symlink → start-technologies/projects/start-sdk/docs/skills), for Claude Code
├── .agents/skills ← the same skills, for Codex
└── 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 checkout tracks live-docs, not master. That branch is what every product has published: each release moves it to the tagged tree for the product being released, so the guide you read, the template init-package scaffolds from, and the SDK source all describe the @start9labs/start-sdk that npm install resolves. master carries what hasn’t shipped, where a page can document a call your package cannot import. It is also the branch docs.start9.com serves, and corrections to published pages land there first — so your local copy and the site are the same thing, and you get a fix the moment it goes live.
The context lives once, at the workspace root — it is never copied into your package repos. Start every AI session from the workspace root, never from inside a package repo. From the root, your assistant picks up AGENTS.md / CLAUDE.md automatically; a session started in a package directory can miss them and work without the guide. You can read exactly what it contains on the Agent Context page.
Skills
Beyond the always-on context, the guide ships skills — procedures an agent loads on demand to drive a whole job end to end, in the Agent Skills format both Claude Code and Codex read. The one you want first is package-service: given a project name or an upstream URL, it researches the upstream and how people self-host it, settles the package’s shape with you in one round of questions, then scaffolds, builds, and verifies the package on your StartOS device and hands it back for review.
They live in the guide itself (start-technologies/projects/start-sdk/docs/skills/), so syncing the guide updates them like any page, and the workspace links them for you — .claude/skills and .agents/skills both point there, so a session opened at the workspace root has them. Invoke one by name:
- Claude Code:
/package-service Vaultwarden - Codex:
$package-service Vaultwarden
There is nothing to install anywhere else: the skills need the workspace as much as you do — make, s9pk pack and init-package all refuse to run outside one — so a workspace is where they live.
Already have the monorepo?
Let the workspace clone its own anyway. A checkout you develop in sits on master and moves with your branches; the workspace’s sits on live-docs and is only ever fast-forwarded — one checkout cannot be both, and pointing the workspace at yours would put the guide, the template, and the SDK source ahead of what your packages can install. The second copy is blobless, so it costs ~75 MB.
To open a pull request against the monorepo, use your development checkout. Without one, branch the workspace’s from origin/master and switch it back to live-docs when you’re done.
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/id.key.pem <workspace>/.startos/build.key.pem.
Hosts and registries
The .startos/config.yaml created with the workspace defines named host targets (your StartOS boxes) and registry targets:
schema: 1
# The StartOS devices you install to. Uncomment and set `default` to your own
# box's address — shown in its web interface — to enable `make install`.
# host:
# default: https://server-name.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 block is the StartOS devices you install to, and ships commented out because no address would be right for everyone. Uncomment it and set default to your own box. Its .local address is built from the server’s hostname — which StartOS generates and you can change — so take the address from the StartOS web interface rather than guessing it from the server’s name; its IP works too:
host:
default: https://server-name.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. See Makefile.
Keep it current
The guide, the package template, the agent context, and the SDK source 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
live-docs only ever moves forward, so this is always a fast-forward. It brings in two things: corrections to already-published pages, as soon as they go live on docs.start9.com, and — when a product is released — that product’s whole tree at the release.
There’s no separate update command. Anything a newer start-cli adds to a workspace is filled in the first time any start-cli command runs inside it after updating, and re-running init-workspace on an existing workspace does the same on demand — it fills in only what’s missing, and your AGENTS.local.md is never touched. If start-technologies has ended up on another branch, init-workspace and init-package say so; move it out of the workspace and rerun init-workspace to get a fresh checkout on live-docs.
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.
Your workspace now looks like:
start9-workspace/
├── .startos/
├── AGENTS.md
├── AGENTS.local.md
├── CLAUDE.md
├── start-technologies/
└── hello-world-startos/ ← your new package
Keep starting your AI sessions from the workspace root, not from inside hello-world-startos/ — that’s where the agent context loads from (see Environment Setup).
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. The fastest route is the package-service skill your workspace already carries — /package-service <name or upstream URL> in Claude Code, $package-service … in Codex — which researches the upstream, asks you one round of questions, then scaffolds, builds, and verifies the package on your device (see Environment Setup — Skills). Or drive it yourself: open hello-world-startos/ in your AI assistant and point it at the New Package Checklist, which 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.
New Package Checklist
start-cli s9pk init-package "<Name>" scaffolds a barebones clone — one daemon running the
hello-world image with a port-listening health check, no interface, no dependencies. This page
takes that clone to a release-ready package. Work it top to bottom.
The scaffold names its arbitrary ids example-* (example-volume, example-image,
example-daemon) to signal that you rename them freely; they are not required namings.
Tip
Wrapping an existing upstream Docker image — the common case? Read Package a Prebuilt Docker Image first. It expands the “replace the hello-world image” step below.
Identity & metadata
startos/manifest/index.ts: fill inpackageRepo,upstreamRepo, andmarketingUrl/donationUrl(or remove the latter two). Confirm thelicense.- Replace the placeholder
LICENSEfile with your package’s license, matching thelicensefield instartos/manifest/index.ts. startos/manifest/i18n.ts: write the short and long descriptions, then translate them into the other locales.- Replace
icon.svgwith a real icon for your service (≤ 40 KiB). Fetch the upstream asset — never ship an invented one.
The service
- Rename the
example-*placeholder ids to fit your service. Keep them consistent acrossstartos/manifest/index.ts(theexample-imagekey andexample-volumeentry),startos/main.ts(imageId,volumeId, the daemon and subcontainer ids), andstartos/backups.ts(the backed-up volume). - Replace the hello-world image with your service’s image: set
images.*.source.dockerTag(or add aDockerfile) instartos/manifest/index.ts, and update theexec.commandinstartos/main.ts. Document how you track that version inUPDATING.md. startos/main.ts: define the daemon(s) and any oneshots. The example daemon ships acheckPortListeninghealth check onuiPort(startos/utils.ts) — pointuiPortat the port your service listens on, or swap in another check. Keep only the i18n keys instartos/i18n/dictionariesthat you actually reference.- Interfaces:
startos/interfaces.tsships wired intostartos/init/index.tsbut returns an empty list. If the service exposes a network interface, bind a port and export the interface there (see Interfaces). startos/backups.ts: choose what to back up (see Back Up and Restore Data).startos/dependencies.ts: declare any dependencies (or confirm none).startos/actions/: add user-facing actions / config as needed (see Create Configuration Actions).startos/init/: add install / restore setup if the service needs it.startos/versions/: set the initial version string and release notes.
Docs
- Write
README.md(per Writing READMEs). - Write
instructions.md(per Writing Instructions). - Fill in
UPDATING.md— what “upstream” means for this package, where the pin lives, and how to bump it. - Write the
## This repobullets inAGENTS.md, or delete the section. A simple package needs none.
Build, test, ship
- First test build:
make(orstart-cli s9pk pack); fix anytsc/ pack errors. - Install on a StartOS box and verify the service runs, and is reachable once it exposes an
interface. A green
tscproves the code builds, not that the service works — see Development Workflow. - Backup / restore sanity check.
- Review the README and instructions one more time against actual behavior.
- Publish (see Publishing).
After that
The checklist ends here, and it leaves nothing behind in the repo to maintain. From this point a defect you can fix is a fix, and one that needs a decision is a GitHub issue on the package repo — never a checklist, a worklist, or a notes file. See Development Workflow — Fix what you find; file only what needs deciding.
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 change.
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 package’s technical reference, and the only one an AI support or administering agent reads — 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 change:
- 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.
Reinstall before believing a type error
A node_modules that has drifted from package-lock.json produces errors that read exactly like real bugs in your code — a nullability complaint, a mismatch deep inside a dependency’s own typings. Run npm ci and check whether the error survives before you spend time on it, and certainly before you change code to satisfy it.
The same drift ruins git bisect. If node_modules is shared across the checkouts you’re bisecting — symlinked in, or left in place while the tree moves under it — every commit is judged against the same broken typings, and the bisect lands on an innocent commit with total confidence.
Work one package at a time
Finish a change in one package before starting the next. When several packages need the same edit, that is a deliberate decision to make once and then apply, not a default to slip into: a mistake made in one package is a bug, and the same mistake cascaded across a fleet is an afternoon of reverts. Land the first one, confirm it builds and behaves, and only then propagate.
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.
Inspecting a running install
To read a generated config or grep the application’s own logs from inside a container:
start-cli package attach <id> -n <subcontainer-name> -- <cmd>
Select the subcontainer by name with -n — the name passed to SubContainer.of in main.ts — or by image with -i. -s/--subcontainer takes the internal Guid, not the name, so passing a name to it fails with “no matching subcontainers”; that is the most common way this command is got wrong. A service with more than one subcontainer requires a selector, and with none given attach falls back to an interactive picker that panics in a non-TTY shell — which is the missing selector surfacing, not a TTY requirement.
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 say that it still needs the real icon.
When you can’t verify something, say so — raise it as an open question, or open an issue if it needs tracking beyond this session. Don’t paper over it with confident prose in the README.
Fix what you find; file only what needs deciding
A defect you spot while you already have the package open is a fix in the branch you are already on, not a report for someone else to pick up. That holds whether or not it is related to what you came to do: you have the code in front of you and the context to be sure, and the next person has neither. Describe what you fixed, and why, in the PR body.
Open a GitHub issue on the package repo when the call is not yours to make — you cannot pin the cause down, two defensible fixes exist and choosing between them needs a human, or the change is too large to ride on the work in hand. Say what you found and what you would need decided. Then reference the issue in a line of the PR body if a PR is open; don’t write the finding out in full in both places, or the two copies immediately start to disagree.
The tracker in the other direction is not an invitation. An open issue is somebody’s report, not a queue you may pick from — take one on when you were asked to, or when it carries the Approved label, which is a maintainer saying it is ready for a PR. Then implement it in the branch you already have and put Closes #<n> in the PR body.
Nothing else in the repo is a place to record work. Don’t create a TODO.md, a NOTES.md, a PLAN.md, or a results log at the bottom of a doc: a file like that is invisible to everyone who isn’t reading the repo, it accumulates session notes nobody can act on, and it goes stale the moment the session ends.
What is neither a fix nor an issue: what you verified, what you tried, what you decided and why. That is the commit message and the PR body’s job, where it stays attached to the change that motivated it.
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.
Fetch a package before reading it as a reference
The same applies to a package you open to derive behavior from — a dependency’s volume path, a credential scheme, a shared pattern, a version pin. Fetch it first. A checkout you cloned weeks ago shows code the package has since changed, and it will mislead you with total confidence: reading a dependency’s API key after upstream dropped it produces an integration that type-checks, builds, and cannot work.
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. Branch from origin/master — the checkout itself sits on live-docs, which carries what is published — and switch it back when you’re done.
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 spun off only when the version already in current.ts carries a migration, which stays with the version that introduced it rather than riding forward into its successor. 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.
Maintaining a Package
The rest of this guide is about making a change: which construct to reach for, how to write it, how to verify it. This page is about the package as something that keeps existing — the branches it accumulates, the upstream it chases, and the sibling packages it depends on. None of it matters much on the day you scaffold a package, and all of it matters by the fiftieth release.
Branches
A package has one base branch — the integration branch its CI is wired to. master and main are both fine; what matters is that the branch the repo actually uses is the branch its workflows name. build.yml’s PR target and tagAndRelease.yml’s push trigger both have to point at it, and a package sitting on main whose workflows still say master silently never builds a PR and never releases. See Project Structure — .github/workflows/.
Ordinary work targets the base branch: cut a feature branch, open a pull request, merge, and let the branch be deleted.
next, the long-lived iteration branch
Alongside the base sits next — a branch that is never deleted and keeps accumulating work after each pull request lands. It exists so a package can be iterated on continuously without every increment having to be release-ready, and so a chain of dependent changes can build on each other before any of them ship.
syncNext.yml keeps it honest: every push to the base is carried onto next, so it never falls behind what has already shipped. You do not have to create next yourself — the first run makes one at the base tip.
After next picks up the base, re-check the version it claims. The base moves on its own, and it can land — and release — the very work next is carrying. Two things go wrong, and neither announces itself:
- The version is already published. If
nextclaimed1.36.0:2while the base was on:1, and the base has since released1.36.0:2, publishingnextships different bytes under a live version. Query the registry for the package’s existing versions and take the next free revision. - The release notes describe work that already shipped. When the base landed the same submodule bump or upstream refresh independently,
next’s own commit is a no-op after the sync, but its notes still announce the fix. Drop the redundant commit and rewrite the notes to describe only what this version adds on top.
Merge a long-lived branch with a merge commit, never a squash
This is the one branch rule that is easy to get wrong, because the habit runs the other way.
When you merge next into its base, use gh pr merge --merge — “Create a merge commit” in the web UI. The merge commit’s second parent is next’s tip, which leaves next a true ancestor of the base, and the next sync fast-forwards it.
Squashing re-lands the same content under a brand-new commit. The base ends up with the changes but not the commits, so next is left carrying history the base will never contain — permanently diverged, with every later sync having to merge around it. Rebase-merging diverges for the same reason.
Feature branches are the opposite case: they are deleted on merge, so squash them as usual. The rule is about the head branch’s lifetime, not about the repository. Anything long-lived — next, a release line, the scratch branch syncNext opens when a sync conflicts — takes a merge commit.
Parallel release lines
Some packages ship more than one line at once: two upstream major versions that both still receive updates, or two build flavors of the same software. Each line gets its own base branch, and each base branch gets its own paired iteration branch named next/<base>.
The slash order is forced by git, not chosen. Refs are paths, so while a branch named 29.x exists, no branch 29.x/next can exist beside it — a ref cannot be both a file and a directory. next/29.x is the form that works.
Important
Every operation applies to every line, not just the one you have checked out. A version bump, a dependency refresh, a README correction, a security fix — if it belongs on one line it almost certainly belongs on the others.
git branch -rtells you what the package maintains;git worktree listis the ergonomic way to keep several checked out at once.
For example, a package wrapping a widely-deployed daemon might carry 28.x through 31.x as base branches, with next/28.x … next/31.x paired to them, plus a separate line for a variant build of the same software. Users on the older line keep receiving fixes without being forced onto a major upgrade, and the packaging work happens once per line rather than once per release.
If your package only ever tracks one upstream line — most do — ignore all of this. A plain next is the whole story.
Chasing upstream
UPDATING.md at the package root is the package-specific recipe: where its version string lives, which registry and tag format to look for, and exactly which field to edit. Follow it. It is also not automatically correct — if its command contradicts what you can actually observe, trust the observation, fix the file in the same change, and say so in the pull request.
Four disciplines apply on top of it.
A release existing does not mean the artifact exists
Verify that the thing you are about to pin actually resolves, before you pin it. Upstreams tag a release and fail to publish the image often enough that this is a routine failure, not an edge case — and the symptom lands on users of the package, not on you, because the manifest type-checks and only the build fails.
- For a
dockerTag, confirm the exact tag resolves.docker manifest inspect <image>:<tag>works, but anonymous Docker Hub requests are rate-limited and a throttled response is indistinguishable from a missing tag — prefer the registry’s tag API (https://hub.docker.com/v2/repositories/<namespace>/<repo>/tags/<tag>for Docker Hub). - For a source build, confirm the git tag exists.
- If the package pins several images that move together, check every one.
- If the newest release has no usable artifact, target the newest one that does, and say so in the pull request.
Skip prereleases, and don’t trust “Latest”
GitHub’s “Latest” badge is unreliable in both directions: it can sit on a prerelease, and it can lag behind the newest stable. Read the tag list rather than the release page, and pin a stable release unless the package deliberately tracks a prerelease line.
Scale scrutiny to the size of the jump
Identify upstream’s versioning scheme before classifying the jump; the tier decides how much of upstream you read. Preserve the full upstream version in every case. The tier is a review scope, not a reason to coerce a version into SemVer.
For projects that follow SemVer, use the positions in the table below. Treat a SemVer 0.x minor jump as a major jump for review. For CalVer, build numbers, or other schemes, read upstream’s release policy and notes to classify the actual impact: compatible bugfixes take the patch scope, compatible features take the minor scope, and breaking changes take the major scope. A changed year, month, or build counter does not establish any of those on its own. If the policy and notes leave compatibility unclear, do the breaking-change pass rather than assuming a patch.
Upstream’s own notes outrank the number: reindexing, data migration, or a raised dependency requirement takes the major review scope regardless of the version scheme.
| SemVer jump / review scope | What it needs |
|---|---|
| Patch (1.2.3 → 1.2.4) | Bump the pin and verify the artifact. Nothing else — no changelog survey, no source reading. |
| Minor (1.2.x → 1.3.0) | Read the changelog and release notes for what is worth exposing: a setting a user would want (an action), a new precondition (a task), a readiness or progress signal (a health check), a config option. Wire in what is high-value and leave the rest alone. |
| Major (1.x → 2.0) | Everything Minor needs, plus the breaking-change pass below and a decision on a data migration. |
The breaking-change pass re-verifies every assumption the package encodes about upstream — against the new source, not the old comment. Each is a place a bump breaks the package without breaking the build:
- Every key the file model writes still exists and still does what the package says. An option upstream removed is rejected or ignored; one it stopped reading is a dead knob the action still sells.
- Every health-check probe — the request it sends and the reply it treats as success. A readiness gate upstream dropped turns a positive confirmation into a false one.
- Every log line the package parses, and every path it reads or mounts.
- The data on disk — whether the new version opens what the old one wrote. Data it cannot read is a migration, and one that rebuilds puts its disk and time cost in the release notes.
- What the dependency now has to provide — a version floor in
dependencies.ts, or a setting the dependency package must enable. A requirement the dependency’s current release does not meet is an issue on that package’s repo, linked from the pull request, and the pull request says it is blocked on it.
When the packaging repo is itself a fork
To sync a fork, you need its fork parent, which the GitHub API knows:
gh api repos/<owner>/<repo> --jq '.parent.full_name'
Do not use the manifest’s upstreamRepo for this. That field points at the upstream software project, which is a different repository from the packaging repo you forked — using it for a fork sync merges an unrelated history.
Bumping the SDK
Packages pin @start9labs/start-sdk to an exact version, so a bump is npm install @start9labs/start-sdk@<version> --save-exact. Read the SDK’s CHANGELOG.md for the range you crossed before relying on the new version.
Then run npm install again until package-lock.json stops changing. The first install after an SDK bump is not a fixed point: npm records the SDK’s bundled dependencies only once the new tarball is extracted, so the lockfile the first install writes is one the second rewrites. Committing after a single install leaves a half-written lock; two installs have always been enough. Then check that no nested copy of the previous SDK survives in the lock — if one does, a sibling’s pin didn’t move (see Keep one copy of the SDK).
Depending on another package’s repo
A package can depend on another package’s repo through npm, to reuse its exported constants and types:
"dependencies": {
"some-service-startos": "github:<org>/some-service-startos#next"
}
Track the iteration branch, and let the lockfile do the pinning
Pin the git dependency at #next (or #next/<base> for a package with parallel lines) rather than at the base branch, so your package compiles against the sibling’s newest types instead of the last integrated ones.
What makes a moving branch safe is the lockfile, not the specifier. package-lock.json records an exact commit, and CI installs with npm ci, so every build is reproducible. The branch only decides what a future npm update resolves to — and that arrives as a reviewable lockfile diff rather than as a silent change.
Warning
npm updateis a no-op on a git-ref dependency. npm considersgithub:org/repo#nextalready satisfied and will not re-fetch it, so the pin stays stale however many times you run it. To actually refresh: delete the git-resolved entries and their subtrees frompackage-lock.json, thennpm installto re-resolve frompackage.json. That refreshes direct and transitive git dependencies together, which naming one package on the command line does not.
Read the lockfile diff as code review, not as a version bump
Whether a sibling’s code ends up in your .s9pk depends on how you import it, because the bundler tree-shakes what it can:
- A type-only use (
typeof manifest) sits in a type position and is erased entirely. - A scalar constant (a port number, a path) is inlined as its value; nothing else comes with it.
- A value object pulls its whole reachable graph in. Importing one exported config object can drag an entire configuration spec into your package — and then a sibling’s changes become your package’s changes.
So a lockfile diff that moves a sibling commit can change what ships. Check what actually landed:
start-cli s9pk inspect <package>.s9pk cat javascript.squashfs | unsquashfs -d out /dev/stdin
Keep one copy of the SDK
When a sibling pins an older @start9labs/start-sdk than yours, npm hoists two copies, and generic helpers start failing to type-check in ways that read as bugs in your own code — a payload “not assignable to type never” is the usual shape, because the imported value no longer structurally matches the generic it is being matched against.
Force a single copy with an override, and remove it once the sibling catches up:
"overrides": { "@start9labs/start-sdk": "<version>" }
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 objective. The substance lives in the packaging guide under start-technologies/projects/start-sdk/docs/ — read those pages locally, on demand, as the work requires. Do not load everything at once.
Workspace layout
<workspace>/
├── .startos/ ← workspace marker: build.key.pem (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)
├── .claude/skills ← the packaging skills (symlink → start-technologies/projects/start-sdk/docs/skills), where Claude Code looks
├── .agents/skills ← the same skills, where Codex looks
├── start-technologies/ ← checkout of the Start9 monorepo on `live-docs` (what is published): 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), 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
The checkout is on live-docs, the branch that carries what every product has published — never master. That is what keeps the guide, the template, and the SDK source describing the @start9labs/start-sdk a package installs; master carries what hasn’t shipped, where a page can document a call npm cannot resolve. It is also the branch docs.start9.com serves, so the pages on disk are the published ones. Don’t move the checkout to master to see something newer — what is newer there is not what your package builds against.
start-cli is installed outside the workspace, so the sync does not touch it. When an s9pk command warns that yours is behind the published release, update it before going further — the guide on disk describes the newer one.
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 — its branches are the owner’s to manage, not this workspace’s. Say so rather than pulling it: a development checkout sits on master, so everything read through it is ahead of what packages install. The workspace wants its own (remove the symlink and re-run start-cli s9pk init-workspace).
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 objective:
- 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 objective 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).
Skills
start-technologies/projects/start-sdk/docs/skills/ holds the packaging skills — each a SKILL.md that drives one whole job end to end, with its own references beside it. They are part of the guide, so a sync updates them like any page. A session opened at the workspace root lists them when .claude/skills (Claude Code) or .agents/skills (Codex) links to that directory, and can then invoke one by name — /package-service <name> in Claude Code, $package-service in Codex. Without the links, open a skill’s SKILL.md at the path below and follow it.
| Skill | Use when |
|---|---|
start-technologies/projects/start-sdk/docs/skills/package-service/SKILL.md | asked to package a new service: it researches the upstream, settles the shape in one round of questions, then scaffolds, builds, verifies on a StartOS box, and hands back |
AGENTS.local.md records how this workspace departs from the scaffold — which box to install to, where packages live, how finished work is handed back. Where it and a skill differ, follow AGENTS.local.md.
Where to read for X
| Need | Read |
|---|---|
| Find the right recipe for an objective | start-technologies/projects/start-sdk/docs/src/recipes.md |
| How to behave on every change (the disciplines below) | start-technologies/projects/start-sdk/docs/src/workflow.md |
| Branches, release lines, upstream bumps, sibling deps | start-technologies/projects/start-sdk/docs/src/maintaining-a-package.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 open a pull request against — branch from origin/master, not from the live-docs checkout, and switch back to live-docs when you’re done.
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), wallet unlocks, config generation, an app-level schema upgrade. Chained with.addOneshot()alongside.addDaemon()insetupMain(). (recipe-oneshot.md,main.md) - Oneshot, init, or migration is decided by what determines when the work runs, not by whether it happens once. A oneshot runs on every start;
setupOnInitruns per container init and knows why (kind), and re-runs from the top on every change to anything it.const()s — an init handler is a live reactive context, not a one-shot script;migrations.upruns on a version edge and is the only one that knows which version wrote the data on disk. Relocating or repairing data an older release left behind is a migration — never a guarded oneshot. (main.md§ Choosing Between a Oneshot, an Init, and a Migration) - 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. Build every shape with
z.looseObjectat every nesting level — neverz.object, which deletes every undeclared key on the next write. The sole exception is a file you have fully modeled and will always keep fully modeled.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.
- The package repo is not a fork of the application. Take the application from a published image, a git submodule, or a Start9-built image — never by copying upstream’s source in and merging releases into it. Carrying a patch upstream hasn’t taken uses the submodule plus
patches/, not a fork. (project-structure.md)
Working discipline (every change)
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). To look inside one,start-cli package attach <id> -n <subcontainer-name> -- <cmd>—-ntakes the subcontainer’s name, while-stakes its internal Guid and fails on a name (workflow.md). 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 say plainly that the gap is still there.
- Fix what you find; file only what needs deciding. A defect you spot with the package already open is a fix in the branch you are on, related to your task or not — you have the context to be sure and the next person won’t. File a GitHub issue on the package repo when the call isn’t yours: cause unpinned, two defensible fixes, or too large to ride along. Never both — the issue or the PR body, not the finding written out twice. Going the other way, an open issue is a report, not a queue: implement one when asked or when it is labelled
Approved, thenCloses #<n>. Don’t create aTODO.md/NOTES.md/PLAN.mdin place of any of this — a notes file is invisible to whoever can act on it and stale the moment the session ends. (workflow.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). - Refer to a multi-flavor dependency generically. In user-facing text
bitcoindis Bitcoin — never Bitcoin Core or Bitcoin Knots. (dependencies.md) - Keep
README.mdandinstructions.mdin sync.README.mdis the package’s technical reference — how it works, what its actions do, how to diagnose it — and the only technical file an AI support or administering agent reads;instructions.mdtracks user-visible changes. Update each in the same change as the code. Content rules:writing-readmes.md,writing-instructions.md. - A package’s
AGENTS.mdrestates nothingREADME.mdorinstructions.mdsays. Its one reader has both, so it carries only what has no home in them: repo mechanics, a change that looks right and is not (the imperative plus the one clause that says why), extension points, naming traps, and build or test invocations particular to the repo. A fact the README already states does not become a rule by being repeated here. Most packages need one to four bullets; a simple one needs none. (project-structure.md§ AGENTS.md and CLAUDE.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 version already incurrent.tscarries a migration, meaning anupwhose body does work (the template’s emptyupwithdown: IMPOSSIBLEcarries none). That migration stays with the version that introduced it and is never carried forward into a successor, idempotent or not, while 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). - Constants and helpers go in
startos/utils.ts, not a file of their own. A new top-levelstartos/file is warranted only for a large, self-contained unit, never for a single function or constant. A package repo also carries noCHANGELOG.md— release notes live inreleaseNotes— and no test suite of its own. (project-structure.md)
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. Then work new-package-checklist.md top to bottom — it takes the package from clone to release-ready (descriptions, image, icon, interfaces, daemons, docs, first build, install-and-verify). The checklist is a guide page, not a file in the package: read it, don’t copy it in. Wrapping an existing upstream Docker image? Read recipe-prebuilt-image.md first. Asked to package a named project end to end? That is the package-service skill (see Skills): it does the upstream research and the install-and-verify pass around this sequence.
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 objective 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 New Package Checklist from top to bottom — don’t hand-assemble files by copying another package. If you’re wrapping an existing upstream Docker image (the common case), read Package a Prebuilt Docker Image before you start.
Configuration
| 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 | File ownership fixes, app schema upgrades, or setup scripts re-run before every daemon start |
| 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) |
| Trust This Server’s Certificates | Reach an HTTPS address the user supplies, via getRootCa |
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 the New Package Checklist 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. An image that declares noCMDorENTRYPOINThas none to keep: name its binary inexec.commandinstead. 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, or the binary named when the image declares no command;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, 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, 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
Call sdk.setupPrimaryUrl() with the interface the URL belongs to, the file model’s reader for the stored choice (get), and a function that writes it (set). Register the action it returns, and in setupMain() pass await primaryUrl.bestUsable(effects).const() to the service as an env var or config value. That is the stored URL while its hostname is one of the interface’s addresses, and otherwise the preferred address (a public domain, else the .local address), so the service keeps running while the chosen address is gone and returns to it when it comes back. A service that must not run on an address the user didn’t choose passes fallback: false, pairs it with a critical task, and refuses to start while bestUsable is null.
Where the URL is an address of the service’s own web UI, pass the same read to createInterface’s preferredLauncherAddress in setupInterfaces as well, so StartOS’s Open UI control opens the address the service is configured for instead of the one that suits the admin’s connection. See Choosing a Primary URL for the code, and Nominating an Address to Open for what a nomination does.
To tell the user when the choice is unset or gone, list primaryUrl.setupTask(severity, { reason }) after actions in setupInit(). The task clears itself once the stored URL is one of the interface’s addresses again.
For a service whose hostname is permanent and cannot change after initial setup (Synapse), use a critical task on install with visibility: 'hidden' instead, so it’s a one-time choice.
Reference: Interfaces · Actions · Initialization · Tasks
Examples
See startos/ in: 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
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, 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).
Confirm before rotating
The UI runs a no-input action the moment it is clicked unless its metadata carries a warning; with one, it shows the text and asks for confirmation first. Once a password exists, running this action replaces it, so the metadata reads the store and sets a warning whenever a password is stored, and leaves it null on the first run:
async ({ effects }) => ({
name: i18n('Set Admin Password'),
description: i18n('Generate a new random password for the admin account. Replaces any existing password.'),
warning: (await storeJson.read((s) => s.adminPassword).const(effects))
? i18n('Replaces the current admin password.')
: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
}),
The text is a confirmation, not a prediction: it states what the package does — replace the stored password — and nothing about sessions or upstream behavior you have not verified.
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
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, 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, 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.
This is the mechanism for anything keyed to the package version — the data on disk was written by an older release and the new one cannot read it as-is. It runs once per install, covers restoring a backup taken below the current version, and never runs on a fresh install. Work whose answer can differ on the next start belongs in a oneshot instead; see main.md § Choosing Between a Oneshot, an Init, and a Migration.
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 introduced a migration need entries in the other array — VersionGraph reaches every other prior version on its own.
The latest version always lives in startos/versions/current.ts. You create a new file when the version already in current.ts carries a migration, because a migration stays with the version that introduced it and is never carried forward: 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 whatever migration it needs of its own. Bump in place only when the outgoing version’s migration is empty. See Versions — When to Create a New Version File.
Reference: Versions · File Models
A version that stops binding a host or a port has one more job: retire it in the same up(), or its external port stays claimed and dependencies keep resolving a dead address. See Retiring a Host or Binding.
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, bitcoin-core, btcpayserver, cln, ghost, immich, jitsi, mempool, monerod, nextcloud, searxng, simplex, spliit, synapse, vaultwarden, bitcoin-explorer
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
Run a MySQL/MariaDB Sidecar
Some upstream services require MySQL or MariaDB instead of PostgreSQL. Each engine has its own health checks, lifecycle tools, and dump-based backup builder.
Solution
Configure the database daemon with --bind-address=127.0.0.1 and pass its database and password environment variables. Health-check MySQL by execing mysql -e 'SELECT 1'; MariaDB images can use their healthcheck.sh script. Use sdk.Backups.withMysqlDump() for MySQL and sdk.Backups.withMariadbDump() for MariaDB. Each builder runs the engine’s native initialization, server, client, and dump tools from the selected image. A MariaDB daemon whose ready check is healthcheck.sh runs the official image’s entrypoint with MARIADB_AUTO_UPGRADE=1, which adds the healthcheck users to a restored data directory on its next start.
Reference: Main · Initialization
Examples
See startos/main.ts in: ghost (MySQL), romm (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, 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. If such a closure captures a value the reconciler must react to (say, a host the exec.fn reads before building its command), pass that value as the entry’s uses; any change to it restarts the daemon on the next reconcile. Only JSON-serializable uses values are useful — functions, symbols, cycles and undefined normalize to distinct UNSERIALIZABLE:* sentinels, so a change only visible there never triggers a restart, and BigInts hash as their decimal string.
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 the app’s own database migrations, or perform other idempotent setup. Oneshots run to completion and block dependent daemons until they finish.
A oneshot runs on every start, so it fits work whose answer can differ next time. Work keyed to the package version — relocating or repairing data an older release left behind — belongs in migrations.up instead, even when you can guard it to be idempotent. See main.md § Choosing Between a Oneshot, an Init, and a Migration.
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'],
},
},
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.
Setting these flags does not change the container’s capabilities. Each one adds a device node and nothing else.
Both devices are re-created inside the container with the host node’s device numbers and permissions, then bind-mounted onto their usual /dev paths (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 device 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 declare the versions it accepts.
Solution
In dependencies.ts, create a sdk.Dependency.required() or .optional() with a published versionRange, kind: 'running' or 'exists', and any required healthChecks. Add it to sdk.Dependencies.of() and pass that builder to both buildManifest and setupInit.
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 the dependency’s .withInit(handler), 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 dependencies.ts, 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 each optional dependency’s enabled function reads that choice to activate the selected backend.
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. Declare each backend with sdk.Dependency.optional() and read the choice in its enabled function. 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, MariaDB 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 and sdk.Backups.withMariadbDump() for 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.
Note
Progress is reported per phase, weighted so the rsync copy — the slow part — dominates the bar (
DEFAULT_SYNC_WEIGHT= 80 per sync,DEFAULT_HOOK_WEIGHT= 10 per pre/post hook). A pre/post phase appears only when you set that hook. Override any phase’s weight with the optionalweightargument tosetPreBackup/setPostBackup/setPreRestore/setPostRestore, or theweightfield on a sync (addSync/addVolume).
Reference: Main · File Models
Examples
See startos/backups.ts in: hello-world (simple volume), spliit (pg_dump), 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), an optional icon, and an optional description. This is what users will see when they browse your registry from another StartOS device; the description appears above the registry’s services and may use markdown.
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/id.key.pem (or /run/startos/id.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 identity 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 identity 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
│ ├── pr-retarget.yml # CI rebuild when a PR's base changes
│ ├── tagAndRelease.yml # Version check, tag, and release on merge
│ ├── release.yml # Release on manual tag push
│ └── syncNext.yml # Carry the base branch onto `next` on merge
├── assets/ # Supplementary files (required, can be empty)
│ └── .gitkeep
├── 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)
│ ├── main.ts # Daemon runtime and health checks
│ ├── sdk.ts # SDK initialization (boilerplate)
│ ├── utils.ts # Package-specific utilities
│ └── versions/ # Version management and migrations
├── .dockerignore # Build-context filter (see below)
├── .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
├── .prettierrc # One line pointing at the SDK's Prettier config
├── README.md # Service documentation (see Writing READMEs)
├── tsconfig.json
├── UPDATING.md # Per-package upstream-version tracking
└── upstream-project/ # Git submodule (optional)
The Package Repo Is Not a Fork of the Application
A package repo holds packaging — startos/, the manifest, the docs, the CI. The
application itself comes from one of three sources, and UPDATING.md records which one:
- A published upstream image, pinned at
images.<id>.source.dockerTag. The default, and what most packages use — see Package a Prebuilt Docker Image. - A git submodule at
upstream-project/, built by the package’s ownDockerfile, when upstream publishes no image, or none for an architecture StartOS needs. - A Start9-built image, when the software needs a build only we produce.
Copying the application’s source into the package repo and merging upstream releases into it is not a fourth option. A fork turns every upstream release into a hand-merge, moves the application’s tests, lockfiles, CI and dependency churn into a repo whose reviewers are packagers, and leaves the packaged version defined by a merge result instead of a pinned ref. It also breaks the property the layout exists for: every package reads the same way, so a reviewer or a tool that knows one knows all of them.
Carrying a fix upstream has not taken does not require a fork. Use the submodule with a
patches/ directory, each patch stating the condition under which it retires —
electrs-startos is the reference
implementation.
Core Files
Boilerplate Files
These files typically require minimal modification:
.gitignore.dockerignore- Docker does not read.gitignore, so a package that builds from source needs this to keepnode_modules,.git, and built.s9pks out of the build context thats9pk packuploads on every archMakefile- Includes the SDK’ss9pk.mkfromnode_modules(see Makefile)package.json/package-lock.json- the SDK is the only dependency; it supplies TypeScript, Prettier, ESLint and ncc.prettierrc- one line naming the SDK’s shared Prettier config, so editors format the way the build gate checkstsconfig.json
.github/workflows/
Every package should include five GitHub Actions workflows that delegate to the reusable CI workflows in this monorepo (.github/workflows/). The CI pipeline has two automatic stages, plus an optional manual path, a retarget build, and a branch-hygiene job:
PR opened/updated/marked ready ──> Build
PR base changed ──> Retarget Build
PR merged to master ──> Version check ──> Tag ──> Build ──> Release ──> Publish
└─> Sync next
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:
types: [opened, synchronize, reopened, ready_for_review]
branches: ['master']
paths-ignore: ['*.md']
permissions: {}
concurrency:
group: package-build-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
permissions:
contents: read
uses: Start9Labs/start-technologies/.github/workflows/build.yml@master
# No DEV_KEY — a PR build doesn't publish, so it doesn't need the signing key.
GitHub’s default pull_request activities cover opened, updated, and reopened PRs.
ready_for_review is listed explicitly so a draft’s first ready state runs the build.
The job gate keeps subsequent draft updates out of the build matrix.
pr-retarget.yml – rebuilds the .s9pk against a PR’s new base:
name: Retarget Build
on:
pull_request:
types: [edited]
permissions: {}
concurrency:
group: package-build-${{ github.event.changes.base && github.event.pull_request.number || format('metadata-{0}', github.event.pull_request.number) }}
cancel-in-progress: true
jobs:
build:
if: github.event.changes.base && github.event.pull_request.draft == false
permissions:
contents: read
uses: Start9Labs/start-technologies/.github/workflows/build.yml@master
A base change is an edited event. The unfiltered listener receives it even when the new
base or changed paths fall outside build.yml’s trigger filters. Base changes share the
ordinary build’s package-build-<PR> concurrency group, so the newest run replaces work
against an obsolete base. Title and body edits use a separate metadata group and leave an
active build alone.
A PR build only compiles and packs; it never publishes. The reusable workflow falls back to
start-cli init-key when no signing key is present, so passing DEV_KEY here would put the
real key on a runner executing branch-authored code for no benefit. release.yml and
tagAndRelease.yml do publish, and still need it.
tagAndRelease.yml – on merge to master, checks the version against the registry named by REFERENCE_REGISTRY. That is whichever registry the track treats as already-shipped — it is configured per repo/org and is not necessarily production. If that registry already serves the version, the workflow exits gracefully without building. Otherwise it force-moves the release tag onto the new commit, then builds and publishes to the test registry, replacing the release’s same-named assets. 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:
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
syncNext.yml – carries every change that lands on the base branch onto the paired next iteration branch, so next never falls behind what has already shipped:
name: Sync next
on:
push:
branches: ['master']
workflow_dispatch:
jobs:
sync:
uses: Start9Labs/start-technologies/.github/workflows/syncNext.yml@master
permissions:
contents: write
pull-requests: write
The paired branch is derived rather than configured: an explicit next/<base> is used where one exists — that is how the multi-branch packages keep one iteration branch per major line or flavor — and otherwise the repo’s default branch pairs with a plain next. A repo with no next gets one created at the base tip on the first run, so a package never has to be seeded by hand.
Neither next nor the base branch is force-pushed or rewritten. next is fast-forwarded when it is merely behind, given a merge commit when it carries unmerged work, and left untouched with a pull request opened for a human only when that merge conflicts. That pull request is headed by a throwaway sync-next/<base> branch rather than the base itself, so resolving its conflicts in GitHub’s web editor — which commits to the head branch — cannot land on the base. Merge it with a merge commit rather than a squash, so next comes out with the base as an ancestor; a squash leaves equivalent content under a fresh commit and the following sync conflicts all over again. workflow_dispatch is there so a package can be brought into line without waiting for its next merge.
List every base branch the package maintains under branches:, and note this is one of the two workflows whose branch list must match the branch the repo actually uses — a package on main that still says master here silently never syncs.
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 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, 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).
It is also the one package document with a single reader, so it must not restate README.md or instructions.md either — anyone changing the package has both. That leaves it carrying only what has no home in them:
- Repo mechanics — parallel version branches, a worktree layout, a vendored tree.
- Prohibitions — a change that looks right and is not, with the one clause that says why. Inline these rather than linking: an imperative behind a pointer is a suggestion.
- Extension points — where the next backend, interface, or migration gets added.
- Naming traps — a package id that differs from the repo directory, for instance.
- Build or test invocations specific to this package.
Most packages need one to four bullets here; a simple one needs none. Explaining how the package works is README.md’s job — see Writing READMEs — Who reads this file for who reads which file. 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.
It is packed into the .s9pk alongside instructions.md, so an AI assistant administering the server reads the README for the version actually installed, offline, rather than fetching whatever a repository’s default branch has since moved to. Unlike instructions.md it is optional — a package without one still builds — but nothing on the server can fall back to a copy that isn’t there, so ship one.
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 for git to track it and for the build to succeed, so it carries a .gitkeep even when the package has no assets of its own. Keep the .gitkeep when you add real assets; it costs nothing and keeps every package’s layout identical.
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
Define each dependency with sdk.Dependency.required() or .optional() and add it to sdk.Dependencies.of(). The builder supplies both the published manifest requirements and reactive runtime requirements. See Dependencies.
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.
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 holds the package’s own constants and helper functions. A constant or function that doesn’t belong in one of the files above goes here, not in a new file of its own. A new top-level file under startos/ is warranted only for a large, self-contained unit — a subsystem of several cooperating functions, or a long generated table — never for a single function or constant. 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/
├── .gitkeep
├── store.json.ts
└── config.json.ts
Like assets/, this directory carries a .gitkeep so it survives in git when a package declares no file models.
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.- Put
actionsbeforedependenciesso dependency init handlers can create tasks for registered actions;setInterfacesprecedes both. - 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.',
}
How long each one may be
| limit | |
|---|---|
short | 120 characters |
long | 2000 characters |
Every locale gets the same limit, and it is enforced when the package is validated, so a description that overruns fails the build.
short is not truncated when it overruns — the marketplace tile clamps it to
two lines with overflow: hidden, so the remainder is silently cut off in the
one place users browse. Two lines is about 80 characters, and it is the same
two lines whatever language the tile renders in. The limit sits above that so a
translation isn’t failed over a few characters’ growth — German and Polish
routinely run 20-30% longer than the same English sentence — not as permission
to run long. Write the English well inside 80 and every locale still fits the
tile.
Two more characters’ worth of advice:
- Don’t open with the service’s name. The tile renders the title in bold on the line directly above, so “Foo is a self-hosted bar” spends its first words on something the reader can already see.
- Say what it is, not what it is like. Comparisons and superlatives cost more characters than they earn at this size.
long has no such budget — it is rendered unclamped on the service’s details
page — so its limit is about the reader’s patience rather than the layout.
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 */
},
})
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 |
Pre-download alerts
A package can ask StartOS to confirm an update before downloading it when the installed version matches an ExVer range. Add preDownloadAlert to setupManifest():
preDownloadAlert: {
message: {
en_US: '**Back up this service** before updating.',
es_ES: '**Haga una copia de seguridad** de este servicio antes de actualizarlo.',
de_DE: '**Sichern Sie diesen Dienst** vor dem Update.',
fr_FR: '**Sauvegardez ce service** avant la mise à jour.',
pl_PL: '**Utwórz kopię zapasową** tej usługi przed aktualizacją.',
},
when: { sourceVersion: '<2.0.0:0' },
},
message is a localized Markdown value shown in a Continue/Cancel confirmation on both the Marketplace and Updates tab. Markdown is sanitized before display, and external links open in a new tab. Cancel leaves the installed service unchanged. when.sourceVersion matches the version already installed, not the version being downloaded; on a fresh install there is no source version to match.
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).
emulateMissing defaults to true. When a package or backup carries an image for another architecture, StartOS runs that available image under CPU emulation. Set it to false for an image that cannot run correctly under emulation; the package is then offered on architectures represented by a native image.
images: {
main: {
source: { dockerTag: 'example/service:1.0.0' },
arch: ['x86_64'],
emulateMissing: false,
},
},
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.
Minimum RAM
hardwareRequirements.ram is the memory floor below which StartOS will not offer the package. It is compared against the host’s total RAM in bytes. StartOS records MemTotal in bytes and the check is a raw comparison against the number you declare — nothing in the SDK or the OS converts units on your behalf.
hardwareRequirements: {
ram: 8 * 1024 ** 3, // 8 GiB
},
Warning
A value that reads as megabytes —
ram: 8192— declares 8 KiB, which every machine satisfies, so the requirement silently gates nothing. Write the byte count as an explicit power-of-two expression, so the unit is visible where the value is.
Leave it unset when the service has no hard floor. Bear in mind that a box failing the check is not offered the package at all, so raising the floor on an already-published package cuts existing installs below it off from further updates — call that out in the release notes when you do it.
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, so the service can create and configure tunnel interfaces. The flag grants that device and nothing else; it does not change the container’s capabilities. 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. 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).
Hardware Virtualization (KVM)
For services that run their own virtual machines — QEMU/KVM, Firecracker, or a device emulator such as the Android Emulator — set hardwareVirtualization: true at the manifest top level:
hardwareVirtualization: true,
When set, StartOS exposes /dev/kvm inside the service’s container, so the guest runs on the CPU’s virtualization extensions instead of being interpreted in software. It grants the device and nothing else: the service stays unprivileged, user-namespace mapped, and AppArmor-confined.
Important
The granted node belongs to the container’s root and carries the permissions the server gives it, which on
/dev/kvmare0660. Run the process that opens it as root, as the GPU packages do forhardwareAcceleration— that is the only arrangement StartOS guarantees.
The device appears only on a server whose CPU supports virtualization and whose kernel has KVM active for it. Where it does not, the service starts as normal with no /dev/kvm — so a service that can fall back to software emulation should test for the device and do so, and one that cannot should declare a health check saying this server does not support KVM. Give that check gracePeriod: 0, so it reports the reason instead of starting, and a cooldownTrigger — the default re-polls a failing check every second for the life of the service. See Health Checks.
Warning
/dev/kvmis a direct interface to the host kernel’s hypervisor, so it widens the kernel attack surface reachable from the service. Enable it only for a service that genuinely runs virtual machines.
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
Define dependencies in startos/dependencies.ts with sdk.Dependency.required or sdk.Dependency.optional, and pass the resulting sdk.Dependencies.of() builder to buildManifest(versionGraph, sdkManifest, dependencies). See Dependencies for base requirements and runtime narrowing.
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 | Full upstream project version | 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.
Preserve the upstream version
ExVer does not require SemVer’s three numeric components. Its numeric portion supports any number of dot-separated components, with an optional prerelease suffix. Preserve every upstream component, including build or patch counters beyond the third position. Do not truncate, pad to three components, or reinterpret an upstream version as SemVer.
For example, Collabora’s 26.04.4.2.1 becomes 26.4.4.2.1:0, not 26.4.4:0. ExVer normalizes numeric leading zeros; that normalization does not discard a component. Keep the exact upstream spelling where an artifact requires it, such as a Docker tag.
Some projects use CalVer, a single build number, or another release scheme rather than SemVer. If an upstream identifier cannot be represented directly in ExVer, document an order-preserving mapping in UPDATING.md, retaining the original identifier in the artifact pin and release notes. Distinct upstream releases must remain distinct package versions. Review scope follows upstream’s versioning scheme and release impact, not the number of components.
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 |
26.4.4.2.1:0 | 26.4.4.2.1 (stable) | 0 |
2026.9:0 | 2026.9 (stable) | 0 |
9982:0 | 9982 (stable) | 0 |
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 identify the same complete upstream release:
- The current version lives in
startos/versions/current.ts VersionInfo.versionpreserves the full upstream version, followed by:<revision>images.*.source.dockerTaginmanifest/index.tsresolves to that release (if using a pre-built image); retain artifact-specific padding, prefixes, and suffixes here- The git submodule is checked out to that release’s exact tag or commit (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
A migration belongs to the version that introduced it, permanently. It is never carried forward into a successor, and never justified by being idempotent — correctness here must not rest on a migration body being safe to apply twice.
That principle decides the file layout, so the deciding question is does the version currently in current.ts carry a migration? — not whether the bump you are making needs one. That only decides what you write into the new current.ts.
The outgoing version’s migration is empty (the common case): bump current.ts in place. Empty means the up body does nothing. The template’s up: async ({ effects }) => {} with down: IMPOSSIBLE is empty — down: IMPOSSIBLE is not a migration, it only forbids downgrades — so it earns the version no file, and the block stays as it is on the bump. Edit version and releaseNotes in startos/versions/current.ts, adding the new version’s own migration if it needs one. 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.
The outgoing version carries a migration: spin it 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 still runs for users upgrading through it. - Create a new
startos/versions/current.tsexportingcurrentwith the new version string, release notes, and its own migration if it needs one — never the one you just spun off.
Warning
Bumping in place over a version that carries a migration deletes that migration from the graph, and nothing will tell you. Every user still below that version upgrades to the new
currentin one hop — the range vertex below covers them — so the update succeeds normally, having silently skipped the data migration. Whether the migration happens to be safe to re-run is beside the point: don’t fold it into the successor, spin it off.
This keeps versions/ lean: only versions that introduced a migration 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.
A version earns a declared node only when it introduced a migration. 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 first if the version it currently holds carries 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 — edit
current.tsin place unless the version it currently holds carries a migration
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
IMPOSSIBLE keeps the version out of canMigrateTo, and StartOS refuses a downgrade it cannot reach — telling the user to uninstall and restore a backup instead.
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 materialization fails, the next use retries it. 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(...), .matchesAny(...), .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.
Narrowing the set
Three shorthands cover most needs:
| shorthand | keeps |
|---|---|
.nonLocal | what a client off the box can reach — drops loopback, IPv6 link-local, and the bridge |
.public | only addresses flagged public — WAN IPs, public domains, onions |
.bridge | only the lxcbr0 addresses other containers reach you on |
Beyond those, .filter({ kind, visibility, pluginId }) composes as an intersection and .matchesAny([...]) unions:
addresses.nonLocal.filter({ kind: 'domain' }) // domains, off-box only
addresses.matchesAny([{ kind: 'mdns' }, { kind: 'domain' }]) // either one
predicate is the escape hatch for what those cannot express; it hides its condition in a function body, where the declared forms state theirs inline.
exclude drops anything matching any field of the nested filter, so exclude: { kind: 'ipv4', visibility: 'public' } removes every IPv4 and every public address, not just the public IPv4s. Union the complements instead:
addresses.matchesAny([{ visibility: 'private' }, { exclude: { kind: 'ipv4' } }]) // everything but a public IPv4
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 commands that run to completion on every startup, before daemons. Use them for work whose answer can differ on the next start — file ownership, filesystem attributes, an app-level schema upgrade driven by the app’s own state:
// 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 work (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.
Choosing Between a Oneshot, an Init, and a Migration
All three can express work that happens once, so “it only needs to happen once” does not pick one. What picks one is what determines when the work runs.
| Mechanism | Runs | Keyed to |
|---|---|---|
migrations.up | once per install, crossing a version edge | the stored data version |
setupOnInit | every container init, then again on every change to anything it .const()s | why it came up (kind) |
.addOneshot | every main() start, before its daemons | nothing — it always runs |
Ask what the work is a function of:
- The package version that wrote the data →
migrations.up. Only the version graph knows which version produced what is on disk. Relocating files an older release left in the wrong place, rewriting a config whose shape changed, repairing permissions an older release set — all of these. It also covers restoring a backup taken below the current version, because it dispatches off the restored data version, and it never runs on a fresh install. - Why the container came up →
setupOnInit. Fresh install vs. restore vs. update vs. rebuild. Generating an internal secret must happen on install and never on restore.kindis the whole point, and no other mechanism has it. An init handler is also a reactive context in its own right — a.const()inside one re-invokes that handler for the life of the container — so ongoing work that keeps a file, a task, or a registration correct belongs there too, and does not needmain(Init Handlers Are Reactive). - The app’s or the volume’s own state, re-asked every start → a oneshot.
chown, because StartOS mounts volumes root-owned every time. An app’s schema upgrade, because which schema the data is in is the app’s state and not the package’s version.
Two traps worth naming:
- “Migration” means two unrelated things. An application schema migration (
occ upgrade,alembic upgrade,manage.py migrate) is driven by app state, re-asked every start, and belongs in a oneshot. A package data migration is driven by the package version and belongs inmigrations.up. Do not let the shared word decide the mechanism. - Idempotence does not make a oneshot the right home for one-time work. A guard that makes version-keyed work safe to repeat is a proxy for the version rather than the version itself, and it silently stops being faithful as the code around it changes. Where more than one mechanism would work, prefer the one whose scope closes soonest: work on a version edge is read once and never reasoned about again, while work in a oneshot stays live for every future reader of
main.ts.
If the work needs a running daemon it cannot be a migration — migrations execute during init, before main. Use a oneshot, or an init that spins up its own subcontainer.
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 — StartOS’s own init is PID 1 there and collects the processes your daemon orphans. With runAsInit: true that collecting is the entrypoint’s job, which is why it belongs to images that supply a real init. (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 ?? '',
},
},
// ...
})
The process also receives the image’s environment variables and the server’s language as LANG; these env values override both. Set a variable to undefined to remove it, e.g. env: { LANG: undefined }.
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 port has a listening socket, or a UDP port is bound, by reading/proc/net. A TCP connection left inTIME_WAITafter its process exits does not count. 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
Volume mounts declared readonly: true and all asset mounts are read-only; writes through them fail with EROFS. Copy assets that need modification into a writable volume and mount that copy. type: 'infer' detects existing regular files; use type: 'file' when the file may need to be created.
sdk.Mounts.of()
// Mount entire volume (directory)
.mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: '/data',
readonly: false,
})
// Mount a specific file from the volume
.mountVolume({
volumeId: 'main',
subpath: 'config.py',
mountpoint: '/app/config.py',
readonly: true,
type: 'file',
})
Warning
sdk.Mountsis an immutable builder. EverymountVolume/mountAssets/mountDependencycall 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) 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(['myapp', 'check-config'])
// 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
Commands That Run Longer Than 30 Seconds
exec and execFail take a timeout option: how long the SDK waits before it gives up and fails the call. It defaults to 30 s, so a command that legitimately takes longer — cloning a large repository, importing a database, copying a multi-gigabyte file — fails partway through unless you say otherwise. Pass null to wait as long as it takes:
// Gives up after 30 s — fine for a command that either answers quickly or is stuck
await appSub.execFail(['update-ca-certificates'], { user: 'root' })
// No limit — takes as long as the database takes
await appSub.execFail(['pg_restore', '-U', user, '-d', database, dumpFile], {
user: 'postgres',
timeout: null,
})
Opt out whenever the runtime is set by something you cannot bound: the size of the data, the speed of a disk or backup target, or another process you are waiting on. Keep the default for commands that should answer promptly, where the timeout is what stops a wedged container from hanging the service.
Note
On timeout the SDK sends
SIGKILLto the process it spawned and reportstimed out after <n>ms and was killed with SIGKILL;exec()’s result carriestimedOutAfter, set to the limit that elapsed.
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.looseObject({
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 registers a handler that runs when the container initializes — and runs again, from the top, whenever a .const() read inside it sees a new value. An init handler is a live reactive context for the life of the container, the same as setupMain. It is not a script that runs once and exits. Read Init Handlers Are Reactive before writing one.
kind identifies the lifecycle event on the first pass of each handler. Reactive re-runs receive null:
| Kind | The container came up because | Use For |
|---|---|---|
'install' | It was freshly installed | Generate internal secrets, seed file-model defaults, create critical tasks for user setup actions, bootstrap via API |
'update' | The package version changed | Re-apply config, handle post-migration setup |
'restore' | A backup was restored | Re-establish external state; credentials are already present from the restored store |
null | Container rebuild, server restart, or reactive re-run | The work that has to happen every time, watchers included |
Init Handlers Are Reactive
setupInit gives each handler its own effects context and points that context’s retry at the handler. A .const() read subscribes to its value and re-invokes the whole handler when it changes, for as long as the container lives.
The reactivity is opt-in, and .const() is the opt-in: a handler that reads nothing reactively runs once per container init and is done. Once one is present, three consequences follow:
- A watcher is not registered and left behind — the handler body is the watcher. Everything above the
.const()runs again on every change, so a handler that watches something must be cheap and idempotent. kindisnullon a re-run. Install, update, and restore work guarded bykindruns once for that event; reactive synchronization continues withnull.- Reacting to another package’s state belongs here as readily as in
setupMain. An init handler observes a dependency’s files, bindings, and addresses changing exactly asmaindoes. Re-running it does not rebuild your daemon spec, so prefer init for work that only has to keep a file, a task, or a registration correct.
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)
An unguarded handler runs on every kind — and, once it reads something with .const(), on every change to that value as well:
export const registerWatchers = sdk.setupOnInit(async (effects, kind) => {
// Runs on install, restore, and container rebuild — and again from here on
// every change to `setting`, which re-invokes this handler from the top.
const setting = await someConfig.read(c => c.setting).const(effects)
await applySetting(effects, setting)
// Re-runs receive null; preserve a secret already present on install.
const store = await storeJson.read().once()
if (kind === 'install' && !store?.jwtSecret) {
await storeJson.merge(effects, {
jwtSecret: utils.getDefaultString({ charset: 'a-z,A-Z,0-9', len: 64 }),
})
}
})
Put install-only work in a handler of its own when you can. A handler with no .const() runs once, so the guard is the only thing it needs — which is what Install Only above shows.
Watch State and Prompt (the admin-credentials pattern)
For state the user owns — admin passwords, API tokens — 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. The primary URL comes with this pair built in: Set a Primary URL.
// 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 { dependencies } 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,
actions,
dependencies,
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 steps that need a running server.
Oneshots Only
For simple sequential steps (like the app’s own 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
When it times out
If the timeout elapses before everything is ready, runUntilSuccess throws, which fails init: StartOS restores the package’s volumes from the backup it took beforehand, then reverts an update to the previous version or removes a failed install. The error names every daemon that never became ready, with its current health result and message, and — for one whose process kept dying — how many times it exited and the error from the last exit:
Timed out after 120000ms waiting for server (loading; 12 failed exit(s), last: node exited with code 1), bootstrap (waiting)
Read it as: server never came up, and its process has been crash-looping rather than merely starting slowly; bootstrap never ran, because waiting means its requires are not all ready — here, server. A daemon reported as loading or starting with no failed exits is running but not passing its ready check — look at the check, not the process.
A waiting daemon’s message lists the display names of the dependencies still holding it up, which is why there is none above: the chain declares display: null on server. Give a daemon a display and its dependents name it.
Tip
A
readyfunction that returnsloadingon a failed probe (rather thanfailure) makes a dead process look identical to a slow one in the UI. The exit count above is what distinguishes them, but the daemon’s own logs are still the place to find out why it died — a daemon’s stdout and stderr go to the service logs.
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 StartOS 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 steps, add one phase per step. 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 / reactive re-run
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) returnruns a handler only on its initial install, update, or restore pass. The inverse (if (kind) return) runs on container rebuilds and reactive re-runs.
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. A LAN IP the user has not switched follows the .local hostname, and on an interface served without TLS .local is on while one of its LAN IPs is, so switching it off switches them off. A filled address lists every address the user has enabled, the .local hostname included while the server has no LAN IP for it to resolve to.
A public domain belongs to the host, but is enabled per binding. The user adds it naming one internal port, and its addresses — the plain one and, where the binding has addSsl, the TLS one — are enabled on that binding straight away. Every other binding on the same MultiHost also gains the domain, but off by default, to be switched on individually like any other address. So a host that binds two ports needs the domain enabled twice, and a package that starts binding a second port later does not inherit the user’s earlier choice for it.
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)
Conditional exports
A setupInterfaces handler is not additive. It runs your body, then revokes everything the body did not export on that pass — clearBindings({ except }) followed by clearServiceInterfaces({ except }). Re-exporting an interface with the same id is an in-place update with no gap, but an id you skip is removed.
That makes a conditional export a live piece of state, not a one-time decision, because the handler re-runs on every change to anything it .const()s:
// the interface disappears whenever this file does
const rune = await FileHelper.string(runePath).read().const(effects)
if (rune) {
receipts.push(
await origin.export([
sdk.createInterface(effects, {
/* … query: { rune } */
}),
]),
)
}
If that file is deleted and rewritten — a credential rotation, an action that mints a replacement — the interface is revoked for as long as the gap lasts. Anything reactive downstream sees it vanish and then reappear: your own init handlers, and dependents in other packages, which read your interfaces across the package boundary.
Two rules follow.
Gate on the narrowest thing. An early return at the top of the handler (if (!conf) return []) revokes every interface on the host, including ones that never read conf. Put the guard on the exports that actually need the value.
Watch for a replacement, not for the gap. Where the export depends on a file that is deleted before it is rewritten, pass an equality that treats an absent value as unchanged, so the deletion does not re-run the handler at all:
const rune = await FileHelper.string(runePath)
.read(
v => v,
(prev, next) => next === null || prev === next,
)
.const(effects)
The handler then re-runs when the new value lands, updating the interface in place, and never for the moment in between. A first run still sees null and correctly exports nothing.
Warning
Reading another package’s interface has the same hazard from the other side.
sdk.host.get(…)mapped throughhost.bindings[…].interfaces[…]with a?? nullfallback cannot tell “not exported right now” from “gone”, and a.const()on it inmainrestarts your service when the dependency rotates a credential. Take an address fromsdk.host.getBridgeAddress, which resolves off the binding and works with no exported interface at all; only where you need something the interface alone publishes — a scheme, or a credential in itssuffix— read the interface, and pass the same null-tolerant equality.
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 | The ALPN protocols StartOS answers a client with, from those it asked for. null, the usual choice, answers with whatever it asked for. |
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 | Whether the port is safe to expose on networks StartOS does not trust, and whether it speaks TLS itself. See Choosing secure. |
An addSsl binding on any port can carry a Let’s Encrypt certificate — issuance is per name, not per port. The user’s side of that is one extra requirement: Let’s Encrypt validates on port 443 whatever port you bind, so StartOS asks their gateway to route 443 for the domain as well. On a gateway that cannot do it automatically they forward 443 by hand. Worth a line in your instructions for an interface on a non-standard port that users will reach from software validating against public roots — an Electrum client, say.
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
preferredLauncherAddress: null, // Address Open UI should prefer (see below)
})
| 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' }). |
preferredLauncherAddress | string | null | omitted | The URL of the address StartOS’s Open UI control should prefer for this interface. See Nominating an Address to Open. |
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.
Nominating an Address to Open
StartOS’s Open UI control picks the address that suits how the admin is reaching StartOS at that moment — a .local name for a .local session, an onion address for a Tor session — so it usually lands on a link that resolves in the browser they are already using.
A few services work at exactly one origin. CryptPad derives account keys from the origin it is loaded at, so opening it at a second, perfectly reachable address rejects the right password; Ghost, Gitea and Vaultwarden each build links or callbacks from one configured URL. Such a service already asks the user which URL to treat as primary — see Set a Primary URL — and preferredLauncherAddress passes that answer on, so Open UI opens the address the service was configured for.
Nominate on the interface the user opens, which is the one carrying the control: Open UI appears only for a type: 'ui' interface whose scheme is http or whose sslScheme is https, so a nomination on an api or p2p interface is stored and never acted on. Some services keep their canonical URL on an interface that is not the web UI — Synapse’s server name belongs to its federation interface, and its admin UI is a separate one on another host. There the stored URL is not this interface’s address and nominating it would match nothing, so nominate only where the URL the user picked is an address of the interface carrying the control.
Read the user’s choice reactively, so re-running the action re-runs setupInterfaces and re-nominates:
import { primaryUrl } from './primaryUrl'
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
const url = await primaryUrl.bestUsable(effects).const()
const uiMulti = sdk.MultiHost.of(effects, 'ui-multi')
const uiOrigin = await uiMulti.bindPort(uiPort, { protocol: 'http' })
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: {},
preferredLauncherAddress: url,
})
return [await uiOrigin.export([ui])]
})
Pass an absolute URL carrying a scheme and a host. Omitting preferredLauncherAddress, setting it to null, or passing a blank, whitespace-only, or malformed value leaves Open UI on its usual address-selection behavior.
StartOS compares the scheme, hostname and port of what you pass against the addresses this interface has right now, and opens the match. The nomination outranks the connection-based choice, because a service that works at one origin is better served by the address its user picked than by one derived from the admin’s connection — so Open UI stays on it until the user disables that address, at which point the choice reverts.
Some addresses are left out of the comparison, in the cases where StartOS can tell the session asking could not resolve them. Everywhere else it takes your word for it:
- An address a plugin publishes is honored only from a session on that plugin.
accessTyperecognizes Tor by the.onionthe browser is on, and no other plugin’s session, so an onion nomination is honored from a Tor session and every other plugin address reverts to the usual choice. - A Tor session honors only an onion address or a public one. Tor Browser reaches neither a
.localname nor a LAN IP. - Loopback, IPv6 link-local and the container bridge are never nominated. The primary-URL select a user picks from can list them, and none is reachable from another machine.
Warning
Nominate an address the people who use the service can actually reach, because StartOS opens it rather than second-guessing them. A public domain nominated on a home network needs the router to loop LAN traffic back to it, and a
.localname nominated for a service reached from outside resolves for nobody who is away.
Note
Only the origin has to match. The path and query of the opened URL come from this interface’s own
pathandquery, so changing either leaves the nomination standing — what pins it is the scheme, hostname and port, which is the part an origin-sensitive app checks. That also means reassigning the interface’s external port unseats the nomination, which is correct: the origin the app was configured for changed too. A nomination read fromprimaryUrl.bestUsablefollows the chosen hostname to its new port; a service whose URL is permanent has nothing to follow it.
Choosing a Primary URL
A service that builds links, invites or callbacks from one URL asks the user which of its addresses that is. sdk.setupPrimaryUrl() builds the “Set Primary URL” action over a reader and a writer for the choice, wherever the package keeps it — a field of store.json, or the service’s own config file:
// primaryUrl.ts
import { sdk } from './sdk'
import { i18n } from './i18n'
import { storeJson } from './fileModels/store.json'
export const primaryUrl = sdk.setupPrimaryUrl({
id: 'set-primary-url',
hostId: 'ui-multi',
interfaceId: 'ui',
metadata: {
name: i18n('Set Primary URL'),
description: i18n('Choose the URL Ghost puts in the links it generates. Ghost restarts to apply the change.'),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'enabled',
},
field: { name: i18n('URL'), description: null },
get: storeJson.read(s => s.primaryUrl),
set: (effects, url) => storeJson.merge(effects, { primaryUrl: url }),
})
// init/primaryUrlTask.ts — list it after `actions` in setupInit
export const primaryUrlTask = primaryUrl.setupTask('important', {
reason: i18n('The primary URL is no longer one of Ghost’s addresses. Choose a new one.'),
})
Register primaryUrl.action with sdk.Actions.of(). It offers the interface’s addresses (the nonLocal view, so loopback, link-local and the container bridge are left out), pre-selects the preferred one — a public domain, HTTPS first; else the .local address; else the first — and pre-fills the stored URL. get takes a file model’s reader as-is; any object with the same once() and watch() works. An empty string stored there counts as unset.
Three options shape the choice:
| Option | Default | Effect |
|---|---|---|
filter | {} | A Filter applied on top of nonLocal, e.g. { exclude: { kind: 'mdns' } } for a service that cannot use a .local address. |
ssl | false | true offers only addresses served over TLS, for a service that needs an https:// URL. |
fallback | true | false for a service that must run only on an address the user chose: the form and the task start with nothing selected, and bestUsable is null while nothing is stored or the stored hostname is gone. |
primaryUrl.bestUsable(effects) reads the URL to give the service, with the usual const(), once(), watch(), onChange() and waitFor() — await primaryUrl.bestUsable(effects).const() in setupMain and for Open UI above. It resolves to the stored URL at its hostname’s current port and scheme; the preferred address when that hostname is not one of the interface’s addresses or nothing is stored. It leaves the store as the user set it, so a chosen address that comes back is used again. With fallback: false it is null instead of the preferred address; pair that with a critical task, and have setupMain refuse to start on null.
primaryUrl.setupTask(severity, options) is an init script that keeps a task on the action raised while the stored URL is unset or its hostname is not one of the interface’s addresses, pre-filled with the preferred address (nothing, with fallback: false). It re-runs when the stored URL or the addresses change, and clears the task when the user picks an address or the stored one returns. An IP address leaves the interface’s addresses while its network link is down, so an IP choice raises the task then too. important suits most services: a critical task stops the service while it is active, which is right only for a service that cannot run without a valid URL. Keep id equal to the id of an action the package already ships, so its tasks’ replay key survives (see Retiring a replay key).
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.
Each internal port a host currently binds belongs to one claim. A bindPortRange covering a port the same host also passes to bindPort — or to another range — is rejected, because the two claims describe the same container socket under different exposure rules. Only what your package declares in the current pass counts, so folding existing single ports into a range works as long as you drop their bindPort calls in the same release; the disabled leftovers do not conflict, and Retiring a Host or Binding is how you give their port numbers back.
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. |
Retiring a Host or Binding
setupInterfaces() ends every pass by disabling each binding it did not just declare — it does not delete it. Disabling is the right default: it keeps the row, the external port number, and the user’s per-address choices, so a binding your package declares conditionally comes back at the same address they already bookmarked.
The cost is that a binding you stop declaring for good stays behind. It keeps its external port claimed for as long as your service is installed, it keeps recomputing its addresses, and a dependency resolving it through getBridgeAddress still gets a 10.0.3.1:<port> that nothing listens on. Retire it explicitly:
await sdk.MultiHost.of(effects, 'ui-multi').retire() // the whole host
await sdk.MultiHost.of(effects, 'api').retirePort(9090) // one port, or one range
retire() removes the host and everything under it: its bindings and port ranges, their exported service interfaces, the user’s public and private domains for that host, and their per-address enable/disable and WAN opt-in choices. retirePort() removes whichever of the single port and the port range is bound at that internalPort — and both, if both are — leaving the host and its domains in place. Both return the external ports to the server’s pool. Both are irreversible: after retire(), binding the id again starts a fresh host with none of the user’s setup.
Note what that last part means: retire() discards configuration the user created, not just your package’s. A domain they attached to the host goes with it, and nothing tells them. Name the host in your release notes whenever a release retires one, so they know to reattach the domain to a current interface.
The migration pattern
Retire in the up() of the version that stops binding, in the same release as the interfaces.ts change:
export const v2_0_0 = VersionInfo.of({
version: '2.0.0:0',
releaseNotes: {
en_US: 'Upstream 2.0. The web UI moved to a single host and the bundled metrics listener was removed.',
},
migrations: {
up: async ({ effects }) => {
await sdk.MultiHost.of(effects, 'ui-multi').retire()
await sdk.MultiHost.of(effects, 'api').retirePort(9090)
},
down: IMPOSSIBLE,
},
})
Both halves ship together. Retiring an id your setupInterfaces still binds simply recreates it on the next pass, minus the user’s domains — so the retire has to land in the release that drops the binding, not before or after it. down is IMPOSSIBLE because a downgrade cannot give the user their domains back.
Retiring from inside the sdk.setupInterfaces callback throws. That pass ends with the disable sweep, so a retire in the middle of it would depend on statement order.
Why this cannot be automatic
StartOS cannot infer it. A binding missing from one pass is indistinguishable from a binding the service will declare on the next one — under a different config, a backend the user has not selected yet, or a feature they toggled off. Deleting on absence would free the external port and drop their WAN opt-in every time they turned a feature off, and hand that port number to another package before they turned it back on. That is exactly what disabling exists to prevent.
The SDK cannot infer it either: it sees only the calls a pass actually made. Only the author knows a port is gone for good, and only knows it at a version boundary — which is what a migration is.
This is the same shape as retiring a replay key: state your package created, that outlives the release which stopped creating it, and that only your package can say is finished.
Failure modes
- Retiring an id you still bind. Migrations run before
setupInterfaces, so the port is normally reclaimed on the same pass and nothing looks wrong. The symptom is the user’s setup silently reset — a custom domain and WAN toggle back to defaults after an update. - A port that moved rather than disappeared. Retiring the old binding and adding the new one in the same release keeps the host’s domains, but StartOS isolates a public domain from a binding added after it, so the user has to re-enable that domain on the new binding. Private domains carry over on their own. Say so in your release notes.
- Treating
falseas failure. Both calls resolvefalsewhen there was nothing to remove — the normal result on a re-run, and on a server that skipped the version. Not an error. - Retiring the last binding on a host. That does not retire the host. Its domains stay, now addressing nothing. Use
retire()when the host itself is going away.
Cleaning up after the fact
A package that already shipped a version dropping a host or a port still has the row and the port claim sitting on every server that installed it. Retire is a no-op where the id was never present, so one maintenance release naming the stale ids in its up() covers the whole installed base at once. List the ids in your release notes: any domain the user attached to a host you retire is removed with it, and they will want to know where to reattach it.
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://.
Application protocols
addSsl.alpn is the list of protocols StartOS will answer a client with, chosen from the ones that client asked for. Unset — the usual case — it answers with whatever the client asked for. A client left with nothing is refused; a client that asks for no protocol at all is served as it would be without ALPN.
StartOS answers from that list itself, because it terminates the client’s TLS and forwards plain HTTP. Your container never sees the negotiation, so the list is the only thing holding a client to a protocol your container can serve — which is why protocol: 'http' and 'ws' set it to http/1.1.
A container serving its own TLS does get a say; see Rewrapping SSL to a TLS container.
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.
Your container chooses the application protocol here, which is the one thing the rewrap adds to Application protocols above. StartOS offers it whichever of addSsl.alpn the client also asked for and answers the client with its choice, so both ends of the connection carry one protocol — a container advertising h2 is reached over h2 by a client that asked for h2, and one that selects nothing leaves the client with no negotiated protocol, which an HTTP client treats as HTTP/1.1. Leave alpn unset unless you need to keep this binding off a protocol your container would otherwise select.
Advertise exactly the protocols your container can serve on its own listener. The client is only ever given one your container selects, so a list narrower than what your container speaks costs clients the better protocol — and a client sharing none of them is refused by your container, which reaches the client as a TLS alert naming the hostname rather than the protocol.
Advertising h2 is the case to think about twice, and worth re-checking on a container that advertises it today: it has to answer extended CONNECT. It commits your container to serving every HTTP/2 client, including WebSockets. On an https or wss binding StartOS advertises HTTP/2 extended CONNECT (RFC 8441) to the client whether or not your container implements it, so a browser opens its WebSocket that way and your container has to answer it. A container that advertises only http/1.1 keeps those clients on HTTP/1.1, where a WebSocket is an ordinary Upgrade.
Serving Your Own TLS (Passthrough)
There is a third arrangement, distinct from both plain termination and the rewrap: passthrough, where your container’s certificate reaches the client unmodified. Set secure: { ssl: true } with no addSsl:
const origin = await multi.bindPort(10009, {
protocol: null,
addSsl: null,
preferredExternalPort: 10009,
secure: { ssl: true },
})
StartOS still fronts the port with one of its TLS listeners, but that listener pipes the raw TLS stream through instead of terminating it, so nothing about the handshake is rewritten. The container sees the client’s real source address rather than the proxy’s — except for a client on the box itself, which appears as the bridge IP. The listener routes on the client’s TLS ClientHello, so the first bytes a client sends must be one: a protocol that opens in plaintext and upgrades in-band is dropped here, and its port is ssl: false.
The listener routes by the name the client asks for (its TLS SNI). It answers for the names enabled on the binding — its domains and the server’s .local — and for a client that asks for no name or an IP address. It refuses every other name before the connection reaches your container. A daemon whose certificate carries a fixed name that its clients ask for binds the port secure: { ssl: false } instead: StartOS forwards the TCP stream untouched, whatever the name, and the daemon’s TLS still runs end to end.
When to use it
Reach for passthrough only when the rewrap genuinely cannot serve, which is one of two cases:
- The client must verify your container’s own certificate. A wallet that pins a certificate carried in a connection URI can only do so if the certificate it pins is the one actually served.
- The client must see a protocol your container never selects. A rewrap hands the client whatever your container chose, so a client that requires one — gRPC-go rejects a connection with no selected ALPN (
missing selected ALPN property) — is served as long as your container’s listener advertises it. Reach for passthrough here only when your container’s listener cannot be made to advertise it. LND binds its gRPC interface as a passthrough.
Otherwise prefer addSsl. Passthrough gives up everything the proxy does on your behalf:
| Capability | addSsl | Passthrough |
|---|---|---|
| Certificate the client sees | The device certificate | Your container’s |
Proxy auth (addSsl.auth) | Available | Not available — auth lives under addSsl |
X-Forwarded-* headers | Available | Not applicable |
| ACME on a custom domain | StartOS obtains and renews it | Skipped — your container is the ACME client |
| UDP on the same port | Not applicable | No; the port accepts TLS only |
| Certificate issuance and renewal | Handled by the platform | Yours to handle |
Minting the certificate
sdk.getSslCertificate returns a PEM fullchain — leaf, intermediate, StartOS root CA — for the hostnames you name, and sdk.getSslKey returns the matching key. Because the chain terminates at the StartOS root CA, a client that already trusts the box validates your certificate without pinning anything.
The SANs are the whole contract. Nothing rewrites the handshake, so the certificate must be valid for every address a client actually dials — there is no proxy to paper over a mismatch:
sdk.getOsIp(10.0.3.1) — the bridge, where other services reach you127.0.0.1— your own subcontainers, which share the service’s network namespacesdk.getContainerIp— the container itself- every address the interface is published at — LAN IPs, the server’s
.localname, private and public domains, and any onion a plugin has exported
Those three constants are the internal half, and naming only them is the mistake to avoid: an off-box client dialing the LAN address or the .local name gets a certificate matching none of them. Take the external half from the binding, so it follows the addresses the operator adds and removes:
export const setupCerts = sdk.setupOnInit(async effects => {
const served = await sdk.host
.getOwn(effects, 'grpc', host =>
host
? utils
.filledAddress(host, { hostId: 'grpc', internalPort: 10009, username: null, scheme: null, sslScheme: null, suffix: '' })
// everything except the WAN IPv4, which the box does not hold
.matchesAny([{ visibility: 'private' }, { exclude: { kind: 'ipv4' } }])
// nothing dials loopback or link-local; 127.0.0.1 is added below
.filter({ exclude: { kind: ['localhost', 'link-local'] } })
.hostnames.map(h => h.hostname)
: [],
)
.const()
const hostnames = [await sdk.getContainerIp(effects).const(), '127.0.0.1', await sdk.getOsIp(effects), ...served]
const cert = (await sdk.getSslCertificate(effects, hostnames).const()).join('')
const key = await sdk.getSslKey(effects, { hostnames })
await writeFile('/media/startos/volumes/main/tls.cert', cert)
await writeFile('/media/startos/volumes/main/tls.key', key)
})
Read the addresses off the binding, never off an exported interface. They belong to the binding: utils.filledAddress looks the binding up by internalPort and derives the hostnames from it, and the AddressInfo you pass supplies nothing else unless you call toUrl/format. An interface is a view of that list, and it disappears whenever a setupInterfaces pass does not export it — a handler that skips an export while a credential file is missing, or returns early when a config reads as null, revokes it (see Conditional exports). Walking host.bindings[…].interfaces[…] and falling back to [] therefore turns “I cannot see the interface right now” into “this host has no addresses”, which silently narrows the certificate.
That distinction has teeth because reissuing the certificate restarts the service: a SAN set that collapses takes the daemon down with it.
getSslCertificate signs an IP the box itself holds — one in the container bridge subnet, or an address on one of the server’s own gateways. A WAN IPv4 fails the whole call because that address belongs to the router, not to you; an IPv6 GUA is configured on the box and is signed. See Narrowing the set for why that exclusion is a union rather than a two-field exclude.
Read the container IP with .const() rather than .once(): a container that comes back on a new IP must reissue the certificate, or every client dialing the old one fails verification.
Most daemons read their TLS pair once at startup, so reissuing the file is only half the job — something has to restart the service, or it keeps serving the old certificate against the new address set. Whatever you key that restart on must not blink: a value that momentarily reads empty will restart the daemon for nothing, and if the restart can feed back into the value, it will not converge.
Warning
Do not add a
<package-id>.startosDNS name to the SANs. That overlay DNS is deprecated and slated for removal, and it resolves to the container IP rather than the bridge — so it bypasses the platform entirely. Dependents reach you through the bridge; see Service-to-Service Networking.
A passthrough port carries its external port in net.assignedSslPort, the same as an addSsl port — which of the two fields is populated says whether the port speaks TLS, not who terminates it. Dependents should read neither field directly; sdk.host.getBridgeAddress resolves the binding’s derived address and is correct under every arrangement on this page.
Choosing secure
On a raw-TCP binding (protocol: null), secure says whether the protocol is safe to expose, as it is, on networks StartOS does not trust — the LAN and the internet — and ssl says whether it does that with TLS from its first byte:
secure | The protocol | Where StartOS publishes the port |
|---|---|---|
null | Is not safe to expose on its own | Only where StartOS trusts the path: the server itself — your subcontainers and other services — and a gateway marked secure, which none is by default. Never on the internet, so off-box clients reach the service through addSsl. |
{ ssl: false } | Is secure without TLS from its first byte: it encrypts some other way (SSH, LND’s peer protocol, or TLS negotiated after a plaintext opening, like Postgres), or it is safe to expose unencrypted (DNS) | As a plain port, on every address the user enables — LAN and public alike. |
{ ssl: true } | Speaks TLS from its first byte | On every address the user enables, as a TLS port. With addSsl: null, StartOS passes the TLS through to your container — see Serving Your Own TLS. |
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. For reaching the internet rather than a sibling package, see Outbound Network Access.
There is exactly one supported way to do this, and three 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 from its binding’s own derived address list, keyed by the dependency’s internal port:
const rpcAddr = await sdk.host
.getBridgeAddress(effects, {
packageId: 'bitcoind',
hostId: rpcHostId,
internalPort: rpcInternalPort,
ssl: false, // bitcoind's RPC publishes a plaintext *and* a TLS address
})
.const()
Three things make this correct, and each matters:
-
Read the binding’s bridge address, keyed by the dependency’s internal port — never
net.assignedPortornet.assignedSslPort. Those two fields are raw metadata, and which of them is populated depends on how the dependency bound the port.assignedSslPortcarries a port that speaks TLS — whether StartOS terminates it or the container serves its own certificate — andassignedPorta plaintext one, so a binding withaddSslandsecure.sslcarries onlyassignedSslPortwhile anhttp/wsbinding carries both. So a caller reading either field directly is asserting whether its dependency’s port speaks TLS — and silently resolvesnullthe day that changes. This is not hypothetical: it is exactly what broke every LND dependent when LND moved REST behind the OS proxy.getBridgeAddresskeys off the binding rather than an exported interface, so it also resolves bridge-only bindings — tor’s SOCKS proxy resolves through it — and it narrows reactivity to that one address, so.const()is unaffected by the box’s LAN IP changing or a Tor/clearnet address being added. Don’t hand-roll it by matchingmetadata.gateway === 'lxcbr0'.rpcHostIdandrpcInternalPortare imported from the dependency’s package (bitcoin-core-startos/startos/utils), not hardcoded — the internal port and host id are the stable contract.On
ssl: most bindings publish one bridge address and need no discriminator. A binding that usesprotocol: 'http'/'ws', orsecure: nullwithaddSsl, publishes two — bitcoind’s RPC is reachable at both10.0.3.1:8332(plaintext) and10.0.3.1:54404(TLS-terminated). Only there does filtering onsslmatter; adding it elsewhere selects nothing and re-introduces the very assumption this rule removes. -
.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.
Reading it in an action
getBridgeAddress returns the same Watchable as sdk.host.get, so it carries every read strategy. Use .const() in setupMain and setupOnInit; use .once() only inside an action, where a live snapshot rather than a subscription is what you want.
When a dependency retires the host or binding you resolve, it disappears from the database and getBridgeAddress resolves null — the same path as the dependency not being installed, so rule 3 above already covers it. With fallbackPort you get the fallback instead, as always.
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 sdk.host
.getBridgeAddress(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.looseObject({
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. the dependency enabled functions 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.
Trusting this server’s certificates
Everything above assumes you know who you are dialing — a dependency you declared, resolved to a bridge address. Some services instead dial an address the user types into the service’s own UI: a monitor target, a notification endpoint, a webhook. You cannot resolve those, and the user cannot see bridge addresses, so what they paste is whatever StartOS showed them — which on the LAN is always HTTPS, with a certificate chaining to this server’s root CA. Nothing in your container trusts that root, so the dial fails verification.
sdk.getRootCa returns that root, PEM encoded:
const rootCa = await sdk.getRootCa(effects) // a plain Promise, not reactive
await appSub.writeFile('/app/startos-root-ca.crt', rootCa)
Then point the runtime at it — NODE_EXTRA_CA_CERTS for Node, SSL_CERT_FILE for most others — or, for an image whose clients read the system store, write it under /usr/local/share/ca-certificates/ and run update-ca-certificates. Write it to the subcontainer rootfs, not a volume: it is regenerated from code on every start, so a volume only lands it in the user’s backups (see Writing to Subcontainer Rootfs).
Warning
Do not reach for the root by indexing
sdk.getSslCertificate—[0]is the leaf and installing it as a trust anchor silently trusts nothing, which is a mistake packages have shipped.getSslCertificateis for serving a certificate (Minting the certificate);getRootCais for trusting one.
This is a fallback for addresses you cannot resolve. When you do know the target — a declared dependency — use its bridge address and skip TLS entirely.
Forbidden patterns
Three 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.) - Reading
net.assignedPort/net.assignedSslPortdirectly, or matchingmetadata.gateway === 'lxcbr0'by hand. Which of the two is populated is a property of how the dependency bound the port — not something a caller may assume. Read the binding’s bridge address, which is correct either way.
Reference implementations
bitcoin-core—startos/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.
Outbound Network Access
Interfaces covers traffic inbound to your service. Service-to-Service Networking covers reaching another package. This page covers the third direction: your service reaching the internet.
Outbound connections work by default. A service container can open a TCP or UDP connection to any external host — an upstream API, a package registry, a block explorer, an SMTP relay — with no manifest flag and no SDK call.
TLS trust roots are package-scoped
start-cli server trust-ca adds CA roots to the trust store used by StartOS host processes. Service-container TLS clients use roots supplied in the package image or configured by the package.
To connect to an endpoint using a private CA, install the root in the package and configure the client to trust it. For example, a Node.js service can set NODE_EXTRA_CA_CERTS to the root certificate’s path.
Three things are blocked, and one of them is the reason most packages that “can’t reach the internet” actually fail.
DNS must go through the container’s own resolver
Every service container ships an /etc/resolv.conf containing exactly:
nameserver 10.0.3.1
That is the OS itself, on the bridge gateway, running a recursive resolver on UDP and TCP port 53. It answers .startos and private names locally and forwards everything else to whatever upstream resolvers the box is configured with, preserving the query type — so TXT, SRV and MX lookups all work.
Use it. Any library that reads /etc/resolv.conf — getaddrinfo, Go’s resolver, httpx, requests, reqwest, Node’s dns — is already correct and needs no configuration.
Important
A DNS query sent from a container straight at a public resolver or the LAN gateway —
1.1.1.1:53,8.8.8.8:53,192.168.1.1:53— is dropped. Not rejected: dropped. The caller sees no error, only a timeout, once per configured nameserver.
This bites when an upstream application hardcodes a resolver rather than reading the system one. The tell is distinctive: HTTPS to the same domain works fine, but a direct DNS lookup hangs for the client’s full timeout and then reports a timeout rather than NXDOMAIN or “no nameservers”. If a service resolves nothing while its other network calls succeed, check for a hardcoded nameserver list before anything else.
The fix belongs in the application: drop the override and let the resolver library read /etc/resolv.conf. If the upstream needs to keep a configurable nameserver for other platforms, have it default to the system resolver and treat an explicit list as an opt-in override — that is correct on StartOS and on every other runtime.
Verify inside a running container with:
start-cli package attach <id> -- cat /etc/resolv.conf
Note
The OS resolver forwards queries but does not currently pass DNSSEC records through — responses carry no
RRSIGand noADflag. A package that must validate DNSSEC itself (BIP 353 payment instructions, for example) cannot do so through it today.
Port mapping is reserved to the OS
NAT-PMP/PCP (udp/5351) and UPnP SSDP (udp/1900) are dropped from containers. Only StartOS may open ports on the user’s router, and it does so on the user’s behalf when they enable clearnet access for an interface. A package must never try to map its own port — declare the binding in interfaces.ts and let the OS decide how it is reached.
Don’t assume how you are reached
Outbound access says nothing about inbound. Whether your service has a Tor address, a clearnet domain, or LAN only is the user’s choice at runtime, and your package cannot know it — see the note in Interfaces. If the service needs to know its own public URL, use Set a Primary URL.
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 ({ effects }) => ({
name: i18n('Set Admin Password'),
description: i18n('Generate a new random password for the admin account. Replaces any existing password.'),
// A no-input action runs on click unless `warning` is set; then the UI confirms first.
warning: (await storeJson.read(s => s.adminPassword).const(effects)) ? i18n('Replaces the current admin password.') : 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, with sdk.action.run (see Running Another Service’s Action):
'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.
Knowing Who Is Calling
access decides whether another service may run the action. caller tells the action which service is running it, so it can decide what that service is allowed to touch. The run handler, the prefill function, and an input spec written as a function each receive it:
- a package id — the service that reached the action through
effects.action.runoreffects.action.getInput. A service that runs one of its own actions that way sees its own id. null— the user ran it, or StartOS is reading the form to evaluate a task.
StartOS supplies caller; the calling service cannot set or forge it. Take identity from caller, never from the input. An action that lets a service register something against “its own” host must not accept a package id as a field — any service allowed to call it could name another:
export const registerEndpoint = sdk.Action.withInput(
'register-endpoint',
async () => ({
name: i18n('Register Endpoint'),
description: i18n('Register a host of the calling service'),
warning: null,
allowedStatuses: 'any',
group: null,
visibility: 'hidden',
access: 'dependent',
}),
InputSpec.of({ hostId: Value.text({ name: 'Host', required: true, default: null }) }),
async () => null,
async ({ effects, input, caller }) => {
if (caller === null) throw new Error('Only a service can register an endpoint')
// `caller` is who asked; `input.hostId` is which of its hosts.
await register(effects, { packageId: caller, hostId: input.hostId })
},
)
Running Another Service’s Action
sdk.action.run runs one of this service’s own actions, or another service’s that its access admits. An action that takes input is run the way the user runs it: its form is opened first, and the input is checked against that form. So input is a function. It receives the opened form — its spec, and the value the action’s prefill function supplied — and returns the input to submit. prefill seeds the form, including the values its dynamic fields are computed from.
await sdk.action.run({
effects,
packageId: 'directory',
actionId: 'register-endpoint',
input: ({ value }) => ({ ...value, hostId: 'api' }),
})
An action without input takes no input, and runs without a form. Calling the effects directly works the same way: the target keys the form effects.action.getInput opens by the calling procedure’s event id, so the effects.action.run that answers it must come from the same procedure, one form at a time.
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.
message is prose shown under the title. It is rendered as Markdown — headings, lists, tables, code blocks, emphasis and links all work — and a single newline is kept as a line break, so text written as plain lines arrives as plain lines. Guidance and next steps go here.
return {
version: '1',
title: 'Diagnostics',
message: `### Checks
- database: **ok**
- search index: **rebuilding**
Restart the service once the rebuild finishes.`,
result: null,
}
result holds the values the user acts on — copies, scans, or saves. It takes one of three types:
type | Renders as | Takes |
|---|---|---|
single | a one-line field | value, plus optional copyable, qr, masked, launchable |
multiline | a read-only monospace box that keeps its line breaks | value, plus optional copyable, qr, masked, filename |
group | an accordion of named members, each of which is any of these three | value, the array of members |
A newline in a single value is not rendered — the browser strips it from the field — so anything with its own line structure is a multiline value. filename is what separates “here is some text” from “here is a file”: set it and the value is also offered as a download under that name; omit it for no download button.
Single Value
result: {
type: 'single',
value: 'abc123',
masked: true,
copyable: true,
qr: false,
}
copyable, qr, masked and launchable are all optional and default to
false. launchable puts an open-in-new-tab button beside the value, so a
result that hands the user a link — an authorization URL, an admin panel — can
be clicked straight through:
result: {
type: 'single',
value: 'https://btcpay.example.com/api-keys/authorize?permissions=btcpay.store.cancreateinvoice',
copyable: true,
launchable: true,
}
The value must be an http(s) URL for the button to go anywhere.
Multi-line Value
result: {
type: 'multiline',
value: report,
copyable: true,
filename: 'vikunja-doctor.txt',
}
Group of Values
A member carries a name, and an optional description, on top of whatever its own type takes:
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 },
{ type: 'multiline', name: 'Device Config', description: null, value: config, masked: true, copyable: true, qr: true, filename: 'start-tunnel.conf' },
],
}
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 ? i18n('New accounts can no longer be created. Existing accounts are unaffected.') : 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.
Validating Input
A text field’s patterns are checked before your handler runs, on every path into the action — the form, start-cli package action run, and a direct RPC call alike. A value that fails one is rejected with that pattern’s description, so the caller sees the same message wherever they came from.
sessionTimeout: Value.text({
name: i18n('Session Timeout'),
required: false,
default: null,
patterns: [
{
regex: '^([0-9]+(s|m|h))+$',
description: i18n('Must be a number followed by s, m, or h'),
},
],
}),
Two details worth knowing, both inherited from how the form has always behaved:
- A pattern is anchored.
[a-z]+matches the whole value, not a substring — write it as though^and$were there, because they are added if you leave them off. - An empty value skips its patterns, and is left to
required. An optional field the user leaves blank is not made invalid by a pattern it could never satisfy.
Anything a pattern can’t express — a cross-field rule, a value that has to exist on disk — still belongs in the handler, where a throw surfaces to the caller the same way.
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
Confirm Before a No-Input Action Changes State
A no-input action runs on click, so one that changes state sets warning, and the UI asks the user to confirm first. That holds for a reversible toggle too: the point is to prevent unexpected execution, not only damage. The warning names what changes — what is replaced, stops working, restarts or becomes exposed — never just “Are you sure?”. An action with input needs no warning, since the form is the confirmation, and an action that only reports (credentials, node info) needs neither.
A create-or-update action, such as setting an admin password or token, warns only when it replaces an existing value and sets warning: null on first creation, as Action Without Input shows.
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'.
That includes what a handler throws. An error out of an action handler is not a log line the user never sees — StartOS catches it and renders the message as the alert that tells them the action failed, so it is the only feedback they get and it needs a dictionary entry like any other:
if (!apiKey) {
throw new Error(i18n('An API key is required. Create one under Settings → API Keys.'))
}
Wrapping it works because setupI18n resolves eagerly against the container’s locale and hands back a finished string; StartOS renders an unrecognized string verbatim, so a translated message reaches the user in their language and an untranslated one leaks English into the alert.
Errors thrown outside an action are a different matter. A throw from setupMain, setupInit, or a migration reaches the user as a Service Launch Error — a crash report shown next to Rebuild and Uninstall buttons, not copy anyone composed. Those are diagnostics: leave them as plain strings, and keep them specific enough to debug from.
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.looseObject({
adminPassword: z.string().optional(),
secretKey: z.string().optional(),
smtp: smtpShape,
})
export const storeJson = FileHelper.json({ base: sdk.volumes.startos, 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. While the dependency is not among the service’s current dependencies, such as an optional dependency that is disabled, StartOS hides these tasks and a critical one does not prevent the service from starting.
import { someAction } from 'dependency-package/startos/actions/someAction'
const dependency = sdk.Dependency.required('dependency-id', {
description: i18n('Needed by this service'),
metadata: { title: 'Dependency', icon: 'https://example.com/icon.png' },
versionRange: '>=1.0.0:0',
kind: 'running',
healthChecks: ['dependency-id'],
}).withInit(async effects => {
await sdk.action.createTask(effects, 'dependency-id', someAction, 'critical', {
input: {
kind: 'partial',
accept: [
{
/* matching input */
},
],
set: {
/* prefill */
},
},
when: { condition: 'input-not-matches', once: false },
reason: i18n('Configure the dependency for use with this service'),
})
})
export const dependencies = sdk.Dependencies.of().addDependency(dependency)
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 — as a branch-pinned git dependency, alongside theoverridesentry that keeps a single copy of the SDK in your bundle. See Adding the Dependency topackage.json, and 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 — for as long as the key itself stays the same. See Retiring a replay key for what to do when it changes.
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')
Retiring a replay key
The default key is derived from the action id, so it is stable only for as long as that id is. Change either half — rename the action, point the task at a different action, or target a different package — and the SDK writes a new key. The old one is not rewritten and not reaped: it stays in the database exactly as last written, still enforcing a contract you no longer intend.
Clearing it is the job of the package that created the task, in the migration for the version that changes the key:
export const current = VersionInfo.of({
version: '1.2.0:1',
releaseNotes: { en_US: '…' },
migrations: {
up: async ({ effects }) => {
await sdk.action.clearTask(effects, 'bitcoind:other-config')
},
},
})
Nothing else can do this for you. StartOS cannot distinguish a key you retired from one you simply did not write on a given run, and the SDK cannot reap “keys I did not create this run” because tasks are legitimately raised from init, from actions, and by other packages.
Skipping it fails in one of two ways, neither of them visible from inside your own package:
- The retired action still exists. Both keys stay live and both keep re-arming. If they set different values, satisfying one un-satisfies the other, and the user ping-pongs between them with no way to settle.
- The retired action is gone. StartOS can no longer resolve its input, so the task’s state freezes at whatever it last held. If that was active and
critical, the package stays stopped and no user action can clear it — the task points at an action that no longer exists to be run.
The same applies when a task becomes conditional. If you only raise it for some configurations — one backend of several, say — clear the keys for the branches you are not on, so a task never lingers against a service the user no longer talks to. fulcrum-bch-startos does this with a NODE_TASK_KEYS map and a single clearTask call covering every unselected node.
A user already stuck in either state can only be recovered from the CLI, naming the package that created the task (not the one it targets):
start-cli package action clear-task <creating-package> '<replay-id>' --force
Network state has the same shape and the same remedy — see Retiring a Host or Binding.
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.
StartOS 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)
store.json holds StartOS-side state that the upstream service’s own configuration has no place for — a generated database password, a secret key, a backend the user selected, a flag an action toggles. Where the service does read a config file of its own, model that file directly instead: see Prefer Direct FileModel Over store.json.
It belongs on a volume of its own, named startos, that no subcontainer mounts. Nothing inside the container reads it, and keeping it off the data volume keeps package-generated credentials out of a directory the application can read.
import { FileHelper, z } from '@start9labs/start-sdk'
import { sdk } from '../sdk'
const shape = z.looseObject({
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.startos, subpath: 'store.json' }, shape)
- Declare the volume in the manifest —
volumes: ['main', 'startos']— and give it no mountpoint. - Back it up.
sdk.Backups.ofVolumes('main', 'startos'). Restoring the data volume alone brings back an install whose generated secrets are gone; where one of them is an encryption key, the restored data is unreadable.
YAML Configuration
import { FileHelper, z } from '@start9labs/start-sdk'
import { sdk } from '../sdk'
const serverSchema = z.looseObject({
host: z.string().catch('localhost'),
port: z.number().catch(8080),
})
const shape = z.looseObject({
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.looseObject({
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.looseObject({
// 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.looseObject({
KnownProxies: knownProxiesSchema.catch(() => knownProxiesSchema.parse({})),
})
const shape = z.looseObject({
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, signal?) | Create an async iterator of new values |
.waitFor(effects, predicate, signal?) | 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.
The enclosing context is whichever handler made the read — setupMain, where a re-run rebuilds the daemon spec and restarts the daemons whose spec changed, or a setupOnInit handler, where it re-invokes that handler alone (Init Handlers Are Reactive). The examples below say “daemon restarts” because they are written from setupMain; the mechanism is the same in both.
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)
// Give up after a minute; the signal stops the wait (it rejects with AbortedError)
const syncedInTime = await storeJson.read(s => s.fullySynced).waitFor(effects, synced => synced === true, AbortSignal.timeout(60_000))
The signal also cancels waits for a file or its parent directory to be created.
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 with exactly the data you pass: a key you leave out is gone, and a key you pass survives whether or not your schema names it — so reading a file withread()and writing the result back strips nothing. See Unknown Key Preservation for details, and for how to delete a stale key. - 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: {} },
})
Changing the Current Value
Use update() when the next value depends on the current file, including toggles and deleting entries from a typed record:
await configToml.update(effects, current => (current === null ? null : { ...current, allow_registration: !current.allow_registration }))
The callback receives the same validated value as read().once(). Return a complete replacement or null to skip writing. An unchanged serialized value also skips writing. The callback may be asynchronous. Reads inside it remain reentrant; calling write(), merge(), or update() on the same file, including through a symlink alias, throws immediately.
The callback has five seconds to return, and file access it starts on other files counts against the same deadline. On timeout, update() rejects and releases the lock; the callback cannot commit its return value or start further SDK file operations. Keep callbacks short: an in-process timer cannot interrupt synchronous code that blocks Node’s event loop or undo side effects performed directly by the callback.
write(), merge(), and update() hold a cross-process advisory lock on each target, so other SDK runtimes writing the same file through a mounted directory wait their turn. A writer waits up to ten seconds for the lock, then rejects; a service that holds its own flock on the file for longer blocks every SDK write to it. merge() and update() hold it through the entire read-modify-write. The lock is taken on the file itself; creating a missing file locks a hidden .<name>.tmp temp file that is renamed into place. A write interrupted by a crash or power loss can leave a hidden .<name>.<random>.tmp file beside the target; the first write to that file after the service restarts removes it. An interrupted create leaves .<name>.tmp, which the next create reuses.
Writes replace the file atomically, preserving its owner, access ACL, permissions, and extended attributes. New files inherit their directory’s default ACL. Own-volume file mounts follow replacement while their subcontainer is alive, and commands synchronize these mounts before launching. Reads, writes, and commands reject when their mount cannot be refreshed. A write may have replaced the source before refresh fails; rejection does not imply that the file is unchanged. A deleted source leaves the mount on its last file. A target that is itself a bind mount is written in place. Existing open descriptors retain the previous inode; applications must reopen the pathname to read the replacement.
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.looseObject({
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.looseObject({
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.looseObject({
server: z.looseObject({
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.looseObject({
host: z.string().catch('localhost'),
port: z.number().catch(8080),
})
const shape = z.looseObject({
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
.looseObject({
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
.looseObject({
port: z.number().catch(endpointDefault.port),
tls: z.boolean().catch(endpointDefault.tls),
})
.catch(endpointDefault)
// Top level
const shape = z.looseObject({
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.looseObject({
// 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.looseObject({
'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
A file model must preserve unknown keys, not strip them: upstream config files carry keys your schema doesn’t model (auto-generated secrets, internal state, plugin settings), and dropping them breaks the service.
Important
Never use
z.objectin a file model. Usez.looseObject, at every nesting level. Az.objectshape deletes every key it doesn’t declare the next time the file is written — bymerge(),write(), orupdate()— with no error and no warning.The one exception is a file you have fully modeled — every key it can ever hold — and will always keep fully modeled, so that a key added later by an upstream release, a plugin, or a user’s edit lands in your shape before it lands in the file. That is the only case in which deleting unknown keys is what you want. If you are not certain, it is
z.looseObject.
Preserving unknown keys has two consequences:
merge()never removes keys you don’t mention. Only keys explicitly passed tomerge()are updated. Everything else — including keys outside your schema, as long as every level is az.looseObject— 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,
})
A key outside your schema needs no cast:
await configToml.merge(effects, { legacy_key: undefined })
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 complete value insideupdate().
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.looseObject({
adminPassword: z.string().optional().catch(undefined),
smtp: smtpShape,
})
Don’t Call .strip() on Your Shape
.strip() undoes z.looseObject (see Unknown Key Preservation) and will silently destroy user data on the next merge() — keys outside your schema get discarded. Use .strict() only 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) — on its own startos volume, not the data volume.
// 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.update(effects, current => (current === null ? null : { ...current, allow_registration: !current.allow_registration }))
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
Define a service’s dependencies once in startos/dependencies.ts. Each base requirement is packed into the service’s manifest and published in its registry metadata. StartOS records required base requirements as current dependencies during initialization; runtime requirements refine them and activate optional integrations.
Declare the Base Requirement
import { sdk } from './sdk'
import { i18n } from './i18n'
const bitcoin = sdk.Dependency.required('bitcoind', {
description: i18n('Needed for blockchain data'),
metadata: { title: 'Bitcoin', icon: 'https://example.com/bitcoin-icon.png' },
versionRange: '>=28.4:17',
kind: 'running',
healthChecks: ['bitcoind'],
})
export const dependencies = sdk.Dependencies.of().addDependency(bitcoin)
Pass dependencies to buildManifest(versionGraph, sdkManifest, dependencies) in startos/index.ts and to sdk.setupInit(..., dependencies, ...) in startos/init/index.ts.
The base is serializable at pack time, without effects: every dependency needs a description (which may be null), inline title and icon, version range, kind, and health checks when kind is running. Write '*' when any version is accepted. Name multi-flavor dependencies generically: for bitcoind, use Bitcoin, not Bitcoin Core or Bitcoin Knots. The base is the loosest requirement the service accepts in any configuration.
A range is compared against the installed version and any versions declared in the dependency’s satisfies list. One declared version must satisfy a complete && conjunction; || branches may match different declared versions. Negated ranges and != veto a matching branch.
Optional and Conditional Dependencies
An optional dependency needs an enabled function. It appears among current dependencies when enabled and the runtime requirements are published. Required dependencies appear from the manifest even when the init script does not publish runtime requirements.
const lightning = sdk.Dependency.optional('lnd', {
description: i18n('Lightning backend'),
metadata: { title: 'LND', icon: 'https://example.com/lnd-icon.png' },
versionRange: '>=0.20:0',
kind: 'exists',
enabled: async ({ effects }) => (await config.read(c => c.backend).const(effects)) === 'lnd',
}).withDynamicNarrowing(async ({ effects }) => ((await config.read(c => c.features).const(effects)).advanced ? { versionRange: '>=0.21:0', kind: 'running', healthChecks: ['lnd'] } : null))
A dynamic range is intersected with the base. A disjoint range throws; a broader range cannot loosen the published requirement. StartOS applies the same base when an enabled optional dependency is reported through effects.setDependencies, including its running status and health checks. kind may tighten from exists to running, and health checks may only be added. Returning null keeps the base. .const(effects) makes enabled, narrowing, and withInit reactive, and each has its own effects context. A watched change in enabled reruns only enabled: the runtime requirements are republished when its result changes, and enabling the dependency reruns its narrowing and init handlers. A watched change in the narrowing republishes the requirements when the result changes. A watched change in a .withInit handler reruns only that handler.
Both required and optional dependencies can use .withInit to create cross-service tasks. Chain multiple .withInit calls to add handlers in order. Handlers run only while the dependency is enabled, after the combined runtime requirements are published. While an optional dependency is disabled, StartOS keeps the tasks the service created on it but hides them, and a critical one does not prevent the service from starting. They return when the dependency is enabled again.
const bitcoin = sdk.Dependency.required('bitcoind', {
description: i18n('Blockchain data'),
metadata: { title: 'Bitcoin', icon: 'https://example.com/bitcoin-icon.png' },
versionRange: '>=28.4:17',
kind: 'running',
healthChecks: ['bitcoind'],
}).withInit(async effects => {
await sdk.action.createTask(effects, 'bitcoind', someAction, 'critical', {
input: {
kind: 'partial',
accept: [
{
/* matching input */
},
],
set: {
/* prefill */
},
},
when: { condition: 'input-not-matches', once: false },
reason: i18n('Configure Bitcoin for this service'),
})
})
See Tasks for action input matching and replay IDs. Importing another package’s action or types requires adding its repo to package.json and using "overrides": { "@start9labs/start-sdk": "$@start9labs/start-sdk" } to avoid a second SDK copy.
StartOS reports unsatisfied runtime dependencies, but their declaration does not prevent the service from starting. If the service cannot operate until a dependency is ready, handle that in setupMain and surface its own status or retry. To check the runtime requirements in an action or main, call dependencies.check(effects).
Reaching a Dependency
Resolve its live address with sdk.host.getBridgeAddress, as described in Service-to-Service Networking. For a volume mount, use .mountDependency({ dependencyId, volumeId, subpath, mountpoint, readonly: true }) in the Mounts chain. A missing dependency volume makes the mount fail.
The normal init order is restoreInit -> versionGraph -> setInterfaces -> actions -> dependencies -> setup. Put actions before dependencies when withInit creates tasks for registered actions.
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 format | Format startos/ with the SDK’s Prettier config |
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 |
TS_CHECK | npx tsc --noEmit | Type-check step of the build gate |
FORMAT_CHECK | npx prettier --check startos | Formatting step of the build gate |
JS_BUNDLE | npx ncc build … | Bundle step producing javascript/index.js |
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
# Format startos/ before committing
make format
# 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 documents how your service on StartOS differs from the upstream version. Everything else, a reader can find in the upstream docs. It is also the file that AI agents read to support, operate, and contribute to your package, so its structure is load-bearing in a way an ordinary repository README’s is not.
Who reads this file
Four readers consume a package’s documentation, and they are nested — each reads a prefix of the same list, not a file of its own.
| Reader | Reads |
|---|---|
| The user running the service | instructions.md |
| An AI support agent | instructions.md + README.md |
| An AI assistant administering the server | instructions.md + README.md + the live action/health ABI |
| A developer or AI changing the package | instructions.md + README.md + AGENTS.md |
Two consequences:
- The README is the only technical file a support agent gets. It reads this and
instructions.mdfrom your repository and nothing else — notAGENTS.md, not your source. If a fact is needed to diagnose a user’s problem, it belongs here. - Where readers conflict, the operable surface wins. An agent wants predictable structure it can address; a developer wants prose. Both are served by fixed headings with real writing underneath — not by prose that wanders across section boundaries.
instructions.md is for the user (see Writing Service Instructions); AGENTS.md is for whoever changes the package, and repeats nothing this file says (see Project Structure — AGENTS.md).
Guiding principles
Do not duplicate upstream documentation. If something is not mentioned in your README, readers should assume the upstream docs are accurate.
State differences, never sameness. The scoping note at the top of the file already says that anything this document does not mention behaves as upstream — a complete statement that stays accurate for free. An enumeration of unchanged features cannot match it: the list is unbounded, it goes stale as upstream grows, and it inverts the note’s logic, because once such a list exists a feature missing from it reads as changed. Where a reader would reasonably expect the package to have broken something and it did not, say so beside the thing that would have broken it.
Do not restate instructions.md. Every reader of this file also has that one, so a fact stated in both is a copy that will eventually disagree with itself. Upstream documentation links in particular belong only in instructions.md’s ## Documentation section — that section is parsed to source a package’s upstream docs, so a second copy here is the one nothing validates and the one that rots when upstream moves. Say what the package does differently; let instructions.md say how to use it.
Do not re-encode what StartOS can introspect. An agent administering your service already has every action’s id, name, description, warning, visibility, allowedStatuses and input schema from the OS, along with health-check ids and live status. Restating those here adds a second copy that goes stale and costs the agent context to read. Document instead what the ABI cannot express: when to run a thing, what it costs, whether it is safe to repeat, what state it changes, and which symptom it resolves.
The heading set is fixed, not suggested. Section headings are how an agent retrieves part of a README without loading all of it, so they are an addressing scheme. A package that renames ## Actions to ## Available Actions does not fail loudly — it silently degrades retrieval to “load the whole file”. Use the headings below verbatim, in this order. No heading is optional: a section with nothing to say still says “None.” An empty section is a fact worth stating — “no config file on disk”, “no dependencies”, “no actions”, “no tasks” each answer a question outright, where a missing heading is ambiguous, since the reader cannot tell an absent section from an unwritten one and an agent addressing that heading gets nothing back either way.
The order runs in four groups: what the package is made of (runtime, volumes, file models, dependencies, interfaces), how it behaves (install, actions, tasks, health, backups), what to expect when it doesn’t (limitations), then the machine-readable summary. Keep a new section inside the group it belongs to.
Nothing here is about contributing to the package. Build workflow, repo conventions, and the packaging guide live in AGENTS.md — restating them here produces a section identical in every package, useful to none of this file’s readers.
Open every H2 with prose. One or two sentences between the heading and the first table or subsection, describing what the section covers. This text is extracted as the section’s summary in the generated index, and it is what an agent reads to decide whether to fetch the section at all.
No version numbers anywhere. Not upstream versions, not image tags, not dependency version ranges. The dependency builder in dependencies.ts is the source of truth; a copy here is wrong from the next bump onward.
Required structure
<p align="center">
<img src="icon.svg" alt="[Service Name] Logo" width="21%">
</p>
# [Service Name] on StartOS
> 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 — see the
> Documentation section of `instructions.md` for links.
[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
## Volume and Data Layout
## File Models
## Dependencies
## Network Access and Interfaces
## Installation and First-Run Flow
## Actions
## Tasks
## Health Checks
## Backups and Restore
## Limitations and Differences
---
## Quick Reference for AI Consumers
```yaml
package_id: string
image: registry/name # never a tag
architectures: [list]
subcontainers: [list]
volumes:
volume_name: mount_path
file_models:
- path/to/config.json
startos_managed_env_vars:
- VAR_NAME
dependencies: [list or "none"]
interfaces:
interface_id: { type: ui | api | p2p, port: number }
range_interface_id: { type: api, port: number, port_range: number } # first port, number of ports
actions:
- action-id
tasks:
- { action: action-id, severity: critical | important | optional }
health_checks:
- check-id
```
Sections
Logo
Every README begins with the service icon centered above the title:
<p align="center">
<img src="icon.svg" alt="[Service Name] Logo" width="21%" />
</p>
Adjust src to the actual icon filename.
Image and Container Runtime
Where the image comes from and how it runs.
| What to Document | Example |
|---|---|
| Image source | Upstream unmodified, or custom Dockerfile |
| Architectures | x86_64, aarch64, riscv64 |
| Entrypoint | Default or custom |
Name the subcontainers the package runs and what each is for. An agent needs them to attach to a running install (start-cli package attach <id> -n <subcontainer-name>), and it cannot introspect them.
Volume and Data Layout
Where the service’s data lives.
| 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 |
File Models
Which configuration files the package owns, how they get their values, and what happens to an edit the user makes by hand.
For each model: the file it maps to and its format, how it is seeded (merged defaults at install, generated from a resolved dependency address, written by an action), what rewrites it afterwards and when, and whether a hand edit survives.
Ownership is the part that generates support tickets. “Why did my setting revert” is almost always a value the package re-asserts on every start. Say plainly which keys are re-asserted, which are seeded once and then belong to the user, and whether an action exists to hand a key back.
Note store.json if the package keeps one — it holds StartOS-side state rather than upstream configuration, and it is usually what makes an ownership decision survive a restart.
Where a setting is delivered by environment variable instead of a file, say so and say why: a variable the application re-reads on every launch behaves nothing like one it consumes only on the launch that finds its value unset, and treating the second kind as authoritative is a common packaging bug. A configuration file the package writes without a model belongs in this section too.
If the package writes no configuration at all, state “None” and say so plainly — that there is nothing on disk to inspect or correct is an answer, and a useful one.
Dependencies
What this service needs from other services.
For each dependency: its name, whether it is required or optional, the health checks that must pass before this service starts, any mounted volume (with mount point and read-only status), and why it is needed.
Do not restate the version range — dependencies.ts declares it, and a copy goes stale the first time you raise the floor. If the service has no dependencies, state “None” explicitly.
Network Access and Interfaces
What the service exposes. For each interface: its id, type (ui/api/p2p), port (the full span, for a port range), protocol, and purpose.
Describe what the interface serves, not how StartOS interface controls work — LAN/Tor/domain addressing is a platform feature documented once, not per package.
Installation and First-Run Flow
How setup differs from upstream. Document if your package skips a setup wizard, auto-generates credentials, pre-configures settings, boots the service during init, or creates tasks for initial setup — and any ordering constraint the user or an agent must respect.
Actions
What can be done to the service, and when.
The OS supplies each action’s id, name, description, warning, visibility, allowedStatuses and input schema. Do not restate them. For each user-facing action, document what the metadata cannot carry:
- When to run it — the situation that calls for it.
- What it changes — which files, volumes, or application state.
- Cost — roughly how long it takes, and whether it interrupts service.
- Repeat safety — idempotent, a no-op while already running, or destructive on a second run.
- What happens next — restarts, where to watch progress.
- Outputs — credentials or values the caller receives.
Flag actions with visibility: 'hidden' as not user-facing, so a support agent never tells a user to run one. Where an action exists to satisfy a task, leave the trigger and clearing rules to Tasks rather than describing them twice. If the package declares no actions, state “None”.
Tasks
What the service asks the user to do, and what makes the prompt go away.
StartOS reports which tasks are currently raised, but the condition that raises one and the thing that clears it are your package’s logic and cannot be introspected. This is the section that answers “my service won’t start and I can’t press anything” — a critical task blocks startup and suspends the ordinary controls, which is among the most common support reports a package generates.
For each task the package creates:
- What raises it — the condition, not the
reasonstring the UI already shows. - Severity —
critical(blocks the service from starting),important(prominent, non-blocking), oroptional. - What clears it — running the target action, a configuration reaching an acceptable state, or the underlying condition resolving on its own. Say whether it can return.
- Where it appears — for a dependency task (
createTask), name the dependency and its action. The user sees that prompt on another service’s page, and nothing there explains which package asked for it.
If the package creates no tasks, state “None” — that the service is never held on a prompt, and its ordinary controls are always available, is itself worth saying.
Health Checks
How to tell whether the service is working.
For each check: what it probes, its grace period, and — most importantly — what a failure means and what to do about it. “Not ready” is a state an agent can already read; the diagnostic value is in what distinguishes a slow start from a real fault.
Backups and Restore
What survives a backup, and what a restored instance has to rebuild.
Lead with the strategy, because it decides what the guarantee actually is: volumes copied wholesale (ofVolumes), a database dumped and replayed rather than copied (withPgDump / withMysqlDump / withMariadbDump), or a mix. A volume that is dumped is not a volume that is backed up — its files are never captured, and restore reconstructs it by starting the engine and replaying the dump. Saying only that it is “included” tells a reader the opposite of what happens.
Then: what is deliberately excluded and why (a cache or an index that rebuilds is a feature, not a gap), and what a restored instance still has to do before it is usable — a resync from a dependency, a credential to re-enter, a dependency that must be present first.
Limitations and Differences
A numbered list of what does not work, works differently, or is unavailable compared to upstream — including unsupported dependencies and deliberately disabled features.
Quick Reference for AI Consumers
A YAML block summarizing the package’s operable surface — the fields in the template above. It exists so an agent can establish what the package is in one cheap read before deciding which prose section to fetch.
Its keys mirror the sections, in section order, so it doubles as an index. A section earns a key only when its content is a flat enumeration; where the meaningful fact is a behavior rather than a list, the section stays narrative and gets no key — installation, backups and limitations are all in that group. A key that flattens a behavior into a list is worse than no key, because it reads as precise: backups: {included, excluded} would file a dumped database under “included” and tell a reader their volume is captured when it never is.
Include no versions of any kind: not upstream_version, not image tags, not dependency version constraints.
Pre-publish checklist
- Centered logo header at the top of the file
- Upstream-behavior scoping note at the top; no upstream documentation links (those live in
instructions.md) - Nothing restated from
instructions.md - Headings match the required set, verbatim and in order
- Every H2 opens with one or two sentences of prose before any table or subsection
- All volumes and mount points documented
- Subcontainer names documented
- Every user-facing action covers when to run it, what it changes, its cost, and its repeat safety — without restating the OS metadata
- Hidden actions flagged as not user-facing
- Every task documented with what raises it, its severity, and what clears it — or “None” stated explicitly
- Health-check failures explained, not just listed
- All dependencies documented (or “None” stated explicitly)
- Every file model documented — how it is seeded, what rewrites it, and whether a hand edit survives
- All limitations listed explicitly
- YAML quick reference block present and version-free
- No version numbers anywhere — upstream version, image tags, dependency ranges
- Documented features match actual behavior, verified against a running install
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 written for the human running the service — not for developers, and not a place for the package’s internals. AI agents do read this file, but only to learn what the user has been shown: a support agent answers in the terms the user sees here, and an assistant administering the server relies on it to know what the user was walked through. Writing for anyone but the human is what makes it useless to all three. 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 | AI support and administering agents; developers | 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, and packed into the .s9pk | 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.
A package carries a third documentation file, AGENTS.md, for whoever changes the package. Nothing that belongs in it belongs here. For which reader gets which file, see Who reads this file.
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. List the upstream documentation URLs, 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. Link to canonical, stable URLs the upstream maintains — not specific commits, not your own README. These bullets are also machine-read; see Choosing documentation URLs before writing them. -
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 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.
Choosing documentation URLs
The ## Documentation bullets have a second reader: Start9’s support indexer parses them and crawls each URL into the package’s upstream-documentation index, and it looks nowhere else. What you list decides what a support agent can answer about the service — and a URL that classifies badly costs a wasted crawl on every indexing run, silently.
The bullet must parse. Exactly - [Title](URL), optionally followed by — a few words (em-dash, en-dash, or hyphen), and nothing else on the line. A bullet that opens with prose and puts the link mid-sentence is skipped. The heading is matched as Documentation at any depth, and the section ends at the next heading of equal or shallower depth. A package with no parseable bullet gets no upstream docs and no instructions indexed at all.
How a URL is treated:
| URL | Treated as | What is fetched |
|---|---|---|
GitHub /blob/ or /tree/ ending in a file extension; Gitea/Forgejo /src/branch/…/file.ext; anything ending .md, .txt, .rst | a file | that one page |
A bare repo URL, or /tree/<ref>/<subdir> | a repo | every .md in the tree, scoped to the subdir (CONTRIBUTING, CHANGELOG, LICENSE, SECURITY, CODE_OF_CONDUCT, and dot- or underscore-prefixed paths skipped) |
| Anything else, GitHub wikis included | a site | llms-full.txt, probed at the URL and then at each parent path; failing that, a breadth-first crawl of the site, bounded only by time |
Append /* to a site URL — https://example.org/docs/* — to confine the crawl to that path.
Choose accordingly:
- A repo docs tree is the best link when the tree is the doc set — one bounded, canonical fetch. List what it would pull first: a
docs/folder is often mostly developer documentation, and a hundred pages on internals bury the three on email and admin. When that is the case, link the pages a self-hoster needs instead. - Leave out docs that don’t apply to the package. Upstream install guides describe a deployment the package deliberately does not use, and indexing them teaches support to recommend steps that break a StartOS install.
- A dedicated docs site is fine when it publishes
llms-full.txt—curl -sI https://docs.example.org/llms-full.txtreturns 200. Without it the site is crawled, which is acceptable for a small static site and bad for anything large or dynamic.llms.txtalone does not help. - Never link a forum, a wiki-as-community, or anything with unbounded user content. It crawls topic pages, user profiles, and category listings for as long as the crawler runs.
- Never link a JS-rendered page. The crawler is a plain fetch: a ReDoc, Swagger, or single-page-app docs site returns an empty shell.
curlit and look for prose. - One bullet per distinct source, aimed at the root the crawler should walk — two bullets into the same site index the same pages twice. A specific page worth citing goes in the prose of the step that needs it.
- Avoid commit-pinned URLs. A
blob/<sha>/link freezes the index at that commit forever.
One good link beats three that muddy the index.
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.).
(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 lists the upstream documentation URLs, each with a few words of context, one- [Title](URL) — contextbullet per line, each URL checked against Choosing documentation URLs. 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-beta — https://community-beta-registry.start9.com — receives every PR-merge build automatically
- community (production) — https://community-registry.start9.com — promoted from beta on your go-ahead
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 reviews it against this guide — correctness, conformance, docs, localization, CI. The review lands as a pull request on the fork, so you see every change before it merges.
- The fork is the upstream from that point on. Anything left for you to fix, and every later version, is a PR against the fork — not your original repo.
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-beta. You don’t run any publish commands yourself; the automation handles it.
- Test the beta build. This is where a release soaks: install it from community-beta, exercise it, and give other users a chance to.
- Promote to production — when you’re ready to ship broadly, email submissions@start9.com or open an issue on the fork. Start9 promotes the current beta build to community. The go-ahead is yours to give, not Start9’s.
Every subsequent change or version bump is another PR through the same cycle — merge publishes to beta, email/issue promotes onward.
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-beta.
- 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.