Real PNG icons are coming, so this is the path they arrive by: a sidecar ships its assets beside its own code, and install copies them to public/plugins/<id>/ where one static route serves them. Copied rather than served in place because a sidecar shipping from its own repository has its assets wherever that repository was unpacked, which is not a path the web server can be taught at build time. One predictable destination means the serving rule never has to know how many plugins exist or where any came from. It also makes assets a property of the INSTALL: uninstall removes them, and a plugin nobody installed serves nothing. Needed a new route, and the reason is a trap worth recording. `publicRoutes` in server.tsx is built by globbing ./public at BOOT, so anything copied there afterwards is invisible to it — the first install of a plugin would show a broken image until the server was restarted, and "install it, then restart to see the icon" is not an install. `/plugins/*` resolves per request, like /novnc/* and /vendor/* already do. Unlike those two it answers 404 rather than 500 for a missing file: an unpublished icon is an ordinary state on a fresh machine, and a 500 would put a red line in the log for every dock render. Proven end to end with a real asset: slskd's icon moved from public/slskd.png into the sidecar's own assets/, published against an ALREADY RUNNING server, and fetched at 200 with the right bytes and content-type — 404 before publishing, no restart between. public/plugins/ is gitignored: it holds copies, and the originals live with each sidecar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Compose templates and their setup scripts
One directory per provisionable service. Each holds a docker-compose.yaml and a setup.sh, and
together they are everything needed to bring that service up.
This is deliberately the shape a sidecar will need when it lives in its own repository: metadata (the catalogue entry), a compose template, a setup script, and a schema. Nothing here may assume it is being read out of this repo.
The setup.sh contract
Answers come from the environment. It never prompts when they are already there.
The app store collects them in a web form and passes them as environment variables. A person running it by hand on a VPS gets prompted for anything missing, but only when stdin is a TTY — so the same script serves both, and neither is a second code path.
OFFICER_SERVICE_DIR=~/officerdev/dockers/transmission \
OFFICER_UID=1000 OFFICER_GID=1000 \
TRANSMISSION_PORT=9091 \
bash setup.sh
Every script must:
| Rule | Why |
|---|---|
| Be idempotent. Running twice must be safe and must not create a second anything. | Install is resumable; a retry after a half-failure re-runs steps that already succeeded. |
Never prompt when OFFICER_NONINTERACTIVE=1. Fail with a clear message instead. |
A prompt behind a web form is a hang with no output, which is the worst failure to diagnose. |
Write only inside OFFICER_SERVICE_DIR. |
The app store owns that directory and nothing else. The user's own estate is never touched. |
| Emit progress on stdout. | The installer streams it to a terminal panel in the UI, so the user watches it happen rather than staring at a spinner. |
Print OFFICER_RESULT_<KEY>=value for anything the platform must store. |
How a generated secret or a resolved port gets back to service_connections without the installer parsing free text. |
Exit non-zero on failure, with the reason on stderr. The installer records it in last_error and the
row stays failed rather than pretending to be installed.
Volumes are always relative bind mounts
./data, ./config, ./database — never named volumes. Configuration and data sit beside the compose
file so both the platform and a human can see exactly what a service keeps and where. A named volume
hides it behind docker volume inspect, which is the opposite of the point.
Containers run as the owner
user: "${OFFICER_UID}:${OFFICER_GID}", so files a container writes are owned by the person who
installed it and not by root. This is the convention already in use across the owner's own services.
Ports bind to loopback unless the service genuinely needs to be reachable
127.0.0.1:9091:9091, not 9091:9091. Officer reaches these over loopback; anything published on all
interfaces is a service exposed to the network by an installer the user trusted to be careful. The
exception is a service whose whole function is inbound connections — slskd's P2P listener, for example —
and those say so in a comment.
No external networks
The owner's own composes attach to an external nginx network that exists on his machine. Templates
must not require one: a fresh VPS has no such network and docker compose up would fail before it
started. Default network only.
Three provisioning shapes, not one
Worth knowing before writing a template, because the design doc originally assumed only the first:
- We set the credentials. Passed as environment to the container, so the connection is fully known
the moment it is up. Transmission (
USER/PASS), Vaultwarden (ADMIN_TOKEN). - We generate a secret into a config file. The bind mount lets us write it before first boot, so it
is still known without asking. slskd's API key lives in its
slskd.yml. - A human must mint a token in the service's own UI after it boots. Immich, Jellyfin and Memos all work this way — there is no environment variable that pre-seeds an API key.
Shape 3 means an install can be provisioned and running, but not yet connected. That is a real state, not an error: the container is up, the compose file is written, and the platform is waiting for a token. The installer stops there with the step recorded, and the UI asks for the key with a link to the page that mints it. Resuming finishes the job.