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.