server-up/docs/apps-maken.md
Ramon b54e7003e8
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 13m41s
v0.8.30-beta - depends_on in sjablonen, en vijftien domotica-apps
- Nieuwe metadata-sleutel depends_on: een sjabloon kan zeggen welke andere app
  het nodig heeft en waarom. Het installatiescherm toont dat voor het invullen,
  met een knop naar dat sjabloon. Advies, geen blokkade -- de broker of Home
  Assistant kan net zo goed op een andere machine staan
- De reden is verplicht; een waarschuwing zonder uitleg klik je weg. De korte
  vorm ["mosquitto"] werkt ook, voor sjablonen uit externe repo's
- Toegepast op acht sjablonen waar het gemis onzichtbaar was: zigbee2mqtt,
  alloy, alertmanager, node-exporter, cadvisor, collabora, double-take, soularr.
  Niet op element, open-webui, exportarr, unpackerr en onlyoffice: die werken
  met meerdere alternatieven, en depends_on kent geen "een van deze"
- Vijftien domotica-apps, van 12 naar 27 in die categorie: Piper, Whisper en
  openWakeWord (spraakbediening van Home Assistant, tot nu toe volledig
  afwezig), Matter Server, Matter Hub, AppDaemon, go2rtc, Double Take,
  Domoticz, openHAB, DSMR-reader (P1-poort slimme meter), rtl_433 (433 MHz
  sensoren naar MQTT), Traccar, Snapcast en EMQX
- Catalogus van 241 naar 256 apps; alle 285 images gecontroleerd

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
2026-08-01 04:58:50 +02:00

457 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Eigen apps toevoegen
Een app in Server Up is een map met twee dingen: `template.json` beschrijft wat
de app is en welke vragen je bij het installeren krijgt, en `files/compose.yaml`
is het compose-bestand met plaatshouders erin.
```
apps/mijn-app/
├── template.json
└── files/
└── compose.yaml
```
Zet die map in `apps/`, of in je eigen git-repo die je toevoegt bij
**Instellingen → App Store → Repository**.
---
## Het kortste voorbeeld dat werkt
**`template.json`**
```json
{
"kind": "compose",
"metadata": {
"name": "Mijn App",
"description": "Wat de app doet, in één zin.",
"tags": ["voorbeeld"],
"categories": ["productiviteit"],
"icon": {"provider": "selfhst", "id": "mijn-app"},
"version": {"name": "latest"}
},
"variables": [
{
"title": "Algemeen",
"items": [
{"name": "service_name", "type": "str", "title": "Servicenaam",
"default": "mijn-app", "required": true},
{"name": "port", "type": "int", "title": "Web poort",
"default": 8200, "required": true},
{"name": "appdata_dir", "type": "str", "title": "Appdata-map",
"default": "/opt/serverup/appdata", "required": true},
{"name": "timezone", "type": "str", "title": "Tijdzone",
"default": "Europe/Amsterdam", "required": true}
]
}
]
}
```
**`files/compose.yaml`**
```yaml
services:
<< service_name >>:
image: voorbeeld/mijn-app:latest
container_name: << service_name >>
ports:
- "<< port >>:8080"
environment:
- TZ=<< timezone >>
volumes:
- << appdata_dir >>/<< service_name >>:/config
restart: unless-stopped
```
Dat is genoeg. De rest van dit document beschrijft wat je er nog meer mee kunt.
---
## Compose-syntaxis
De sjablonen gebruiken Jinja met afwijkende haakjes, zodat ze niet botsen met
de `${VAR}` van docker compose zelf:
| | |
|---|---|
| `<< variabele >>` | waarde invullen |
| `<%- if variabele %>``<%- endif %>` | blok alleen opnemen als de waarde waar is |
| `<< waarde \| lower >>` | filters van Jinja werken gewoon |
Het renderen gebeurt in een sandbox: je kunt niet bij Python-internals.
### Named volumes moeten gedeclareerd worden
Gebruik je een named volume, zet het dan óók in het top-level `volumes:`-blok.
Vergeet je dat, dan weigert docker compose met *"refers to undefined volume"*:
```yaml
services:
<< service_name >>-db:
image: postgres:16-alpine
volumes:
- << service_name >>_pgdata:/var/lib/postgresql/data
volumes:
<< service_name >>_pgdata:
```
De testsuite controleert dit voor elke app.
---
## Metadata
| Veld | Betekenis |
|---|---|
| `name` | Naam zoals getoond in de catalogus |
| `description` | Eén zin: wat is het, en waarvoor gebruik je het |
| `tags` | Vrije trefwoorden, gebruikt voor zoeken en filteren |
| `categories` | Eén of meer categorie-ids (zie onder). Ontbreken ze, dan worden ze afgeleid uit de tags |
| `icon` | `{"provider": "selfhst", "id": "…"}` of `dashboard-icons`. Bestaat het icoon niet, dan valt de interface terug op een emoji |
| `version` | Alleen ter informatie in de catalogus |
### Categorieën
Een app mag in meerdere categorieën zitten. De ids:
`media` · `fotos` · `downloaden` · `domotica` · `3d-printen` · `netwerk` ·
`beveiliging` · `opslag` · `documenten` · `productiviteit` · `communicatie` ·
`financien` · `gezin` · `monitoring` · `ontwikkeling` · `ai` · `beheer` ·
`overig`
Elke categorie heeft een vaste kleur die overal in de interface terugkomt. De
definities staan in `server-up/core/categories.py`.
---
## Velden
Elk item onder `items` wordt één vraag in het installatieformulier.
```json
{
"name": "port",
"type": "int",
"title": "Web poort",
"default": 8200,
"required": true,
"description": "Korte tekst onder het veld",
"help": "Langere uitleg, zichtbaar achter het info-icoon",
"advanced": false,
"config": {"placeholder": "bv. 8200", "options": ["a", "b"]}
}
```
| Type | Wordt |
|---|---|
| `str` | tekstveld |
| `int` | getalveld |
| `bool` | aanvinkvakje |
| `enum` | keuzelijst, met de waarden uit `config.options` |
### Vaste namen
Deze namen worden speciaal behandeld:
- **`service_name`** — verplicht. Wordt de containernaam en het voorvoegsel van
alles in de stack. Wordt automatisch gevuld met de instantienaam.
- **Velden met `port` in de naam en type `int`** — krijgen bij het installeren
automatisch een vrij poortnummer voorgesteld als de standaard al bezet is.
- **Velden met `token`, `password`, `secret`, `wachtwoord`, `jwt` of `key` in de
naam** — worden als geheim behandeld: de waarde wordt gemaskeerd in het
installatielog en het veld krijgt een eigen weergave. Je kunt het ook
afdwingen met `"secret": true`.
### Waarden laten genereren
Voor een databasewachtwoord of een JWT-sleutel heeft de gebruiker geen zinnige
keuze; die moet gewoon willekeurig zijn. Zet daarvoor `generate`:
```json
{"name": "db_password", "type": "str", "title": "Databasewachtwoord",
"default": "", "required": true, "generate": "auto"}
```
| Waarde | Wat er gebeurt |
|---|---|
| `"auto"` | Het veld wordt bij het openen van het formulier gevuld met 32 willekeurige tekens. Voor waarden die de gebruiker nooit hoeft te kennen. |
| `true` | Alleen een knop naast het veld. Voor wachtwoorden waarmee de gebruiker zélf inlogt: die moet hij noteren, dus vul ze niet ongevraagd in. |
| weglaten | Geen knop. Voor waarden met een eigen formaat of herkomst — een bcrypt-hash, een token uit een andere app, een sleutel die bij iets anders moet passen. Iets verzonnens is daar juist fout. |
> **Zet nooit een wachtwoord als `default`.** Dan krijgt iedereen die de app
> installeert hetzelfde, en de meesten klikken erdoorheen. Een test weigert
> sjablonen met een vaste waarde op een geheim veld.
### In welke stap komt een veld?
Het installatieformulier is een invulmenu met vaste stappen. Server Up leidt af
waar een veld hoort, en meestal klopt dat:
| Stap | Wat er terechtkomt |
|---|---|
| **Basis** | `service_name`, poortvelden (type `int` met `port` in de naam), `appdata_dir`, `data_root`, `timezone`, `puid`/`pgid` |
| **Verbinden** | velden met `connect` |
| **Toegang** | alles wat als geheim geldt, plus gebruikersnamen en e-mailadressen |
| **Instellingen** | de rest |
Klopt dat voor jouw veld niet, zet het dan zelf:
```json
{"name": "retention", "type": "str", "title": "Bewaartermijn", "step": "basis"}
```
Geldige waarden: `basis`, `verbinden`, `instellingen`, `geheimen`. Een onbekende
waarde valt terug op `instellingen`.
### Inloggegevens
Velden met `credential` komen terug in het paneel **Inloggegevens** op de
stackkaart, zodat iemand na de installatie kan opzoeken waarmee hij moet
inloggen — handig bij een gegenereerd wachtwoord.
```json
{"name": "admin_user", "type": "str", "title": "Gebruikersnaam",
"default": "admin", "credential": "username"}
```
Meestal hoef je dit niet te zetten: een veld met `user`, `login` of `email` in de
naam geldt als `username`, en een geheim veld met `password` erin als `password`.
Tokens en sleutels tellen bewust níét mee — daar log je niet mee in.
### Verplichte velden
`"required": true` wordt afgedwongen: een lege waarde levert een foutmelding op
in plaats van een container die niet start of met een leeg geheim draait. Zit
het veld achter een groepsschakelaar die uitstaat, dan telt het niet mee.
### Geavanceerde velden
`"advanced": true` zet een veld achter het uitklapblok *Geavanceerde opties*.
Bedoeld voor wat je meestal met rust laat: database-wachtwoorden, PUID/PGID,
USB-paden, bewaartermijnen. Zet niet álles op geavanceerd — een test controleert
dat er per app zichtbare velden overblijven.
Je kunt het ook op een hele groep zetten: `{"title": "…", "advanced": true, …}`.
### Uitleg
`description` staat als kleine tekst onder het veld. `help` is de langere uitleg
achter het info-icoon; ontbreekt die, dan wordt `description` getoond. Gebruik
`help` voor dingen die een gebruiker echt moet weten:
```json
{
"name": "downloads_dir",
"type": "str",
"title": "Downloadmap",
"default": "/mnt/downloads",
"help": "Zet deze map op hetzelfde bestandssysteem als je mediamap. Staan ze op verschillende volumes, dan kopieert elke voltooide download in plaats van te verplaatsen."
}
```
### Velden afhankelijk maken
`needs` verbergt een veld tot een ander veld een bepaalde waarde heeft:
```json
{"name": "smtp_host", "type": "str", "title": "SMTP-server",
"needs": ["mail_enabled == true"]}
```
---
## Apps aan elkaar koppelen
Verwijst een veld naar een andere app, zet er dan `connect` op. De interface
toont dan een keuzelijst met wat er al geïnstalleerd is, in plaats van een leeg
tekstveld:
```json
{
"name": "mqtt_server",
"type": "str",
"title": "MQTT-server",
"default": "mqtt://mosquitto:1883",
"connect": {"app": "mosquitto", "scheme": "mqtt", "port": 1883},
"help": "Heb je de Mosquitto-app geïnstalleerd, kies die dan hier."
}
```
| Sleutel | Betekenis |
|---|---|
| `app` | De mapnaam van de app waarnaar verwezen wordt (`apps/mosquitto`) |
| `scheme` | Wat voor het adres komt: `http`, `mqtt`, … |
| `port` | Poort binnen het gedeelde netwerk (de interne poort, niet die op de host) |
| `path` | Optioneel pad achter het adres |
Dat levert bijvoorbeeld `mqtt://mosquitto:1883` op. Staat de app er nog niet, dan
meldt het veld dat en kun je alsnog handmatig invullen.
**Let op de poort:** dit gaat over de poort *binnen* de container, niet de poort
die op de host gepubliceerd is. De stacks zitten samen op een gedeeld netwerk en
bereiken elkaar op containernaam.
---
## Onderdelen optioneel maken
Voor stacks met meerdere componenten kun je per groep een schakelaar zetten:
```json
{
"title": "Bazarr",
"section": "Ondertitels en verwerking",
"toggle": "enable_bazarr",
"toggle_default": true,
"description": "Haalt automatisch ondertitels op",
"items": [
{"name": "port_bazarr", "type": "int", "title": "Poort", "default": 6767,
"advanced": true, "step": "instellingen"}
]
}
```
In het compose-bestand gebruik je die schakelaar als voorwaarde:
```yaml
<%- if enable_bazarr %>
<< service_name >>-bazarr:
image: lscr.io/linuxserver/bazarr:latest
ports:
- "<< port_bazarr >>:6767"
<%- endif %>
```
`toggle_default` bepaalt of het onderdeel standaard aan staat. Zonder die sleutel
staat hij aan.
**Vanaf twee schakelbare groepen krijgt het kiezen een eigen eerste stap.** Alle
schakelaars staan dan bij elkaar in *Onderdelen*, en `section` bepaalt onder welk
kopje ze daar terechtkomen — bij vijftig onderdelen is dat het verschil tussen
een overzicht en een lijst waar je in verdwaalt. Bij één schakelbare groep blijft
de schakelaar gewoon bij zijn eigen velden staan.
De velden van een onderdeel zet je op `"step": "instellingen"` en meestal ook op
`"advanced": true`: de poorten kloppen al, en ze horen niet in het eerste scherm
te staan als je door vijftig onderdelen loopt. De schakelaar zélf wordt nooit
geavanceerd — dat is juist wat je eerst kiest. Wil je een héél onderdeel achter
het uitklapblok, gebruik dan `toggle_advanced`.
De ARR-stack (`apps/arr-stack`) gebruikt dit voor negenenveertig onderdelen en is
het beste voorbeeld om van af te kijken. Let op: die wordt gegenereerd — pas
`tools/arr_sjablonen.py` aan en draai dat script, anders draait
`tests/test_arr_sjablonen.py` je wijziging terug.
---
## Waar je op moet letten
**Poorten.** Kies een standaardpoort die nog niet door een andere app gebruikt
wordt. De testsuite controleert dat, en meldt welke botsen. Alternatieven voor
dezelfde taak (twee reverse proxies, twee DNS-blokkers) mogen wel dezelfde poort
delen; die staan in een allowlist in `tests/test_apps.py`. Een onderdeel van de
ARR-stack mag dezelfde poort hebben als zijn losse sjabloon — dat is dezelfde
container, alleen anders verpakt.
**Een app met een eigen database.** Zet de database als tweede service in
hetzelfde compose-bestand, met het voorvoegsel van de app ervoor:
```yaml
<< service_name >>-db:
image: postgres:16-alpine
container_name: << service_name >>-db
environment:
- POSTGRES_PASSWORD=<< db_password >>
```
Dat voorvoegsel is geen opmaak: installeer je de app twee keer, dan botsen de
containernamen zonder. Het wachtwoord is een veld met `"secret": true` en
`"generate": "auto"`, zodat het formulier het invult en de waarde in `.env`
belandt in plaats van in het compose-bestand. Vergeet `depends_on` niet, anders
start de app vóór zijn database en valt hij om op de eerste verbinding.
**Een app die een andere nodig heeft.** Een `connect`-veld wijst naar een andere
app en vult een adres in, maar zegt niet dát je die nodig hebt. Zigbee2MQTT
zonder MQTT-broker start prima en doet niets. Zeg het daarom expliciet, in
`metadata`:
```json
"depends_on": [
{"app": "mosquitto",
"reason": "Zigbee2MQTT publiceert alles naar een MQTT-broker. Zonder broker start het wel, maar komt er niets aan."}
]
```
Het installatiescherm toont vóór het invullen wat er nog ontbreekt, met een knop
die je meteen naar dat sjabloon brengt. **Het is advies, geen blokkade** — je kunt
de broker net zo goed op een andere machine hebben staan.
De reden is verplicht: een waarschuwing zonder uitleg is een waarschuwing die
mensen wegklikken. Gebruik het alleen waar het écht klopt. `depends_on` kent geen
"een van deze", dus bij een app die met Nextcloud óf Seafile werkt laat je het
weg; een halve waarheid is daar erger dan geen.
`["mosquitto"]` zonder reden mag ook — die korte vorm is er voor sjablonen uit
externe repo's, die niets van deze sleutel hoeven te weten.
**Sleutels in een bepaald formaat.** `"generate": "auto"` maakt url-veilige
base64 van 24 bytes — 32 tekens. Wil de app iets anders, zeg dat er dan bij:
| `secret_format` | Levert | Voor |
|---|---|---|
| *(weglaten)* | url-veilige base64, 32 tekens | het meeste |
| `hex` | hexadecimaal, `secret_bytes` × 2 tekens | Homarr, LibreChat |
| `laravel` | `base64:` + standaard-base64 | Snipe-IT, Invoice Ninja, Pixelfed |
```json
{"name": "app_key", "type": "str", "title": "Applicatiesleutel",
"default": "", "required": true, "secret": true, "generate": "auto",
"secret_format": "laravel", "secret_bytes": 32}
```
Dit is geen kosmetiek: een sleutel in het verkeerde formaat levert een container
op die niet start, met een foutmelding die nergens naar de sleutel wijst.
**Een geheim dat je nergens vandaan kunt genereren** — het client-secret van een
OIDC-toepassing, een wachtwoord dat de gebruiker zelf kiest — markeer je met
`"eigen_invoer": true`. Anders klaagt de testsuite terecht dat er een verplicht
geheim zonder waarde en zonder generator in staat.
**De appdata-map heet `appdata_dir`, de gedeelde datamap `data_root`.** De eerste
is de plek waar déze app zijn instellingen bewaart (`/config`), de tweede is de
gedeelde berg met media en downloads die als `/data` in elke container komt. Zit
je app aan dezelfde bibliotheek als de *arr-apps, gebruik dan `data_root` — en
géén losse `/downloads`- of `/tv`-mount, want daarmee sneuvelen hardlinks.
**`network_mode`.** Heeft een service `network_mode: host` of
`network_mode: service:…`, dan kan hij niet aan het gedeelde netwerk hangen.
Server Up slaat die services over, dus het werkt — maar zo'n app kan andere apps
niet op naam bereiken.
**Images.** Gebruik een image waarvan je zeker weet dat hij bestaat en welke
architecturen hij ondersteunt. De testsuite controleert wel de vorm van je
compose, maar haalt geen images op.
---
## Testen
Elke app in `apps/` wordt automatisch meegenomen in de testsuite:
```bash
python -m pytest tests/test_apps.py -q
```
Gecontroleerd wordt: geldige metadata, renderen naar geldige YAML, elke service
heeft een `image` of `build`, named volumes zijn gedeclareerd, geen onvervangen
`<< variabelen >>`, geen onbedoeld dubbele poorten, geldige categorieën, en dat
`network_mode` niet botst met het gedeelde netwerk.
Wil je alleen jouw app zien:
```bash
python -m pytest tests/test_apps.py -q -k mijn-app
```