Package a Prebuilt Docker Image
The most common packaging task is wrapping an existing upstream Docker image — linuxserver/*, an official org/app image, a community image — rather than building your own from a Dockerfile. It looks simple, and the happy path is. But the same handful of mistakes sink these packages over and over: the image name is guessed instead of verified, only one of the image’s data paths gets mounted, non-UI ports are forgotten, an image with its own init system crashes because it isn’t PID 1, and credentials are “set” by hand-editing a config format that was never confirmed. This recipe is the checklist that keeps those from happening.
This page assumes the service shape from Set Up a Basic Service — daemon, interface, health check, backup — and covers only what’s different when you don’t control the image. If you’re starting a brand-new package, scaffold first (start-cli s9pk init-package "My Service") and work its TODO.md top to bottom; this recipe expands the “replace the hello-world image” line of that checklist.
Solution
Build the basic-service skeleton first, then apply these prebuilt-image concerns:
- Verify the image before you pin it. Do not guess a
org/name. Confirm the exact repository exists, the tag you want is published, and it ships the architectures StartOS needs (x86_64andaarch64at minimum). Pinimages.<id>.source.dockerTagto that confirmedimage:tagand setarchaccordingly. See Verify the image below. - Mount every path the image persists. Inspect the image (or its docs) for all data and config paths — there is usually more than one (e.g. a config dir and a downloads/data dir). Mount each one, or that data lands on the container’s ephemeral filesystem and is lost on every restart. See Mount all data paths.
- Expose every port the service needs — not just the web UI. A torrent client needs its peer port; a mail server needs SMTP/IMAP; a database needs its wire port. Bind the UI in
setupInterfaces()and add the others via Expose Multiple Interfaces. A constant likepeerPortthat is declared but never bound is a tell that a port was forgotten. - Run the image’s entrypoint, and make it PID 1 if it has its own init system. Use
sdk.useEntrypoint()to keep the upstream startup behavior. If the image bundles an init/supervisor —s6-overlay(everylinuxserver/*image),tini,dumb-init,supervisord— setrunAsInit: trueon the daemon’sexec, or the supervisor crashes because it is not PID 1. See Images with their own init system. - Pass the env vars the image expects. Many community images are configured through environment variables —
linuxserver/*images readPUID,PGID, andTZto drop privileges and set ownership; others takeAPP_*settings. Set them viaexec.env. See Pass Config via Environment Variables. - Apply credentials through the app’s own mechanism — never a hand-written hash. If the service needs an admin password, follow Prompt User to Create Admin Credentials. Do not invent the on-disk credential format; see Credentials.
- Verify by installing, not by compiling. A clean
tscand a successfuls9pk packprove the code type-checks — not that the service runs. Install on a StartOS box, open the UI, and exercise the actual feature (log in, add data) before calling it done. See Development Workflow — Verify against reality.
Reference: Set Up a Basic Service (the underlying skeleton) · Main · Manifest · Interfaces
Verify the image
Before writing dockerTag, confirm three things from the registry — never from memory:
- The repository exists at the name you think it does. Image names are easy to misremember (
qbittorrentserver/qbittorrentdoes not exist;linuxserver/qbittorrentdoes). Pulling, or fetching the registry’s tags endpoint, tells you for sure. - The tag is published.
latestalmost always exists; a specificX.Y.Zmay not, or may be spelled differently (5.2.1,v5.2.1,version-5.2.1). - It is multi-arch. Inspect the manifest list for
amd64/x86_64andarm64/aarch64. An image that only shipsamd64cannot target StartOS’s ARM hardware.
# List published tags (Docker Hub library/community image):
curl -s "https://hub.docker.com/v2/repositories/linuxserver/qbittorrent/tags?page_size=25" \
| jq -r '.results[].name'
# Confirm the tag is multi-arch:
docker manifest inspect linuxserver/qbittorrent:5.2.1 \
| jq -r '.manifests[].platform.architecture'
images: {
qbittorrent: {
source: { dockerTag: 'linuxserver/qbittorrent:5.2.1' },
arch: ['x86_64', 'aarch64'],
},
},
Mount all data paths
Enumerate the paths the image writes to and persists — its documentation lists them, or you can run the image and watch where it creates files. Mount each path that must survive a restart. Missing a data mount does not produce an error; it silently discards that data on every restart, which is far worse.
subcontainer: await sdk.SubContainer.of(
effects,
{ imageId: 'qbittorrent' },
sdk.Mounts.of()
.mountVolume({ volumeId: 'main', subpath: 'config', mountpoint: '/config', readonly: false })
.mountVolume({ volumeId: 'main', subpath: 'downloads', mountpoint: '/downloads', readonly: false }),
'qbittorrent-sub',
),
A torrent client that mounts
/configbut not/downloads“works” in every quick test and loses every download the moment the service restarts. Map the data path, not just the config path.
Images with their own init system
linuxserver/* images (and anything built on s6-overlay, tini, dumb-init, or supervisord) expect their init system to run as PID 1. In a StartOS subcontainer the daemon command is not PID 1 by default, so the supervisor aborts (s6 logs s6-overlay-suexec: fatal: can only run as pid 1). Set runAsInit: true:
exec: {
command: sdk.useEntrypoint(),
runAsInit: true, // image bundles s6-overlay, which must be PID 1
env: { PUID: '1000', PGID: '1000', TZ: 'Etc/UTC' },
},
See Main — runAsInit for the full description. If an image’s bundled init system genuinely cannot be made to work, the fallback is to build your own image from a Dockerfile and invoke the binary directly — but reach for runAsInit first; it resolves the common case.
Credentials
If the service has a web login, follow Prompt User to Create Admin Credentials: a setupOnInit watcher surfaces a critical task, and a setAdminPassword action generates, stores, and returns the credential.
The trap specific to prebuilt images is how the password reaches the application. Do not assume the on-disk format. Many apps store the web password as a salted PBKDF2 or bcrypt value with app-specific framing — not as a bare hash you can compute and drop into a config key. Writing the wrong format does not error; the app silently rejects the login. So:
- Apply the credential through the app’s own API or CLI (run it in
sdk.SubContainer.withTemp()from the action — see Reset a Password), or - If you must write the config directly, first confirm the real format by setting a password through the app once and reading back exactly what it wrote.
Either way, verify a real login succeeds before shipping. A credential flow that has never been logged into is not done.
Two more traps surface only when you actually test the login:
- Reverse-proxy guards. StartOS fronts the service with its own proxy, so the request the app sees has a different
Host/Origin/port than it served. Apps with host-header or CSRF validation (qBittorrent’sWebUI\HostHeaderValidation, many others) reject every proxied request — often with a401that looks like a bad password but isn’t. Check the app’s log for the real reason, and disable the guard the app provides for running behind a proxy. Watch the inverse too: a “trust localhost” auth bypass can let proxy-local requests skip the password entirely — disable it. - Config you write while the app runs can be clobbered. Many apps rewrite their whole config file on shutdown from in-memory state. If your action edits the config and then restarts the service, the shutdown flush overwrites your edit before the new instance reads it. Write config-file changes from
setupMainbefore the daemon launches (the previous instance has already stopped and flushed), or apply them through the running app’s API instead.
Examples
See startos/main.ts and startos/manifest/index.ts in packages that wrap prebuilt images: ollama, jellyfin, vaultwarden, immich, home-assistant.
Checklist
- Image repository, tag, and arches confirmed from the registry (not from memory)
- Every persisted path mounted (config and data)
- Every required port exposed (UI and non-UI)
-
sdk.useEntrypoint()used;runAsInit: trueif the image has its own init system - Required env vars set (
PUID/PGID/TZforlinuxserver/*, etc.) - Credentials applied via the app’s own mechanism; a real login verified
- Installed on a StartOS box and the feature exercised — not just
tscgreen