server-up/docs/apps-maken.md
Ramon 1575db42c2
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 5m5s
v0.8.14-beta - Raspberry Pi compatibiliteit en UniFi Mongo herstellen
2026-08-04 16:49:24 +02:00

16 KiB
Raw Permalink Blame History

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

{
  "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

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":

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
architectures Optionele lijst met aantoonbaar ondersteunde Docker-architecturen: amd64, arm64, arm/v7 of arm/v6
architecture_note Uitleg die bij de platformmelding in de catalogus en het installatieformulier staat

Laat architectures weg als de image-ondersteuning niet is geverifieerd. Een expliciete mismatch blokkeert de installatie zowel in de interface als in de API; ontbrekende of onbekende metadata blokkeert bewust niets. Voorbeeld:

"architectures": ["amd64", "arm64"],
"architecture_note": "Op een Raspberry Pi is een 64-bits OS vereist."

Controleer alle images uit de compose-stack. Zodra een database- of hulpcontainer alleen AMD64 aanbiedt, is de hele app alleen geschikt voor AMD64.

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.

{
  "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:

{"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:

{"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.

{"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:

{
  "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:

{"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:

{
  "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:

{
  "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:

<%- 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:

  << 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:

"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
{"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:

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:

python -m pytest tests/test_apps.py -q -k mijn-app