Write one
packages/<svc>/manifest.yaml. Run one command. The framework picks the service up, configures SSO, generates a route, sets up backup. No framework code changes.
Level: 2 · Reading time: 15 min
Already know this rung? Skip to Level 3 — multi-service stack where you switch profile and 12 services come up at once.
The framework finds new services by walking packages/*. Each
directory with a manifest.yaml is a service it knows about.
Adding a service is adding a directory.
In this rung we'll add a small service — say, a self-hosted
Linkding bookmarks app. Three files in a new packages/linkding/
and the framework does the rest. The same pattern scales to ERPNext
or Superset — bigger services need more manifest fields, not a
different mechanism.
1. Create the package directory:
mkdir -p packages/linkding/scripts
2. Write packages/linkding/manifest.yaml:
name: linkding
display_name: Linkding
category: documentation
classification: optional
priority: 80
image:
name: sissbruecker/linkding
tag: '1.36'
env_var: LINKDING_IMAGE_TAG
containers:
- name: linkding
env_var: LINKDING_CONTAINER_NAME
role: app
required: true
health_check: true
requirements:
ports: 1
internal_port: 9090
port_env:
- env: LINKDING_PORT
default: 9090
health:
endpoint: /health
expected_code: 200
sso:
type: oauth
redirect_path: /oidc/callback
configured: true
backup:
enabled: true
type: volume
volumes:
- suffix: linkding-data
framework:
profiles:
- development
- full
startup_priority: 80
abbreviation: "LD"
tags:
- bookmarks
- knowledge
installation:
status: available
3. Write packages/linkding/net.manifest.yaml:
id: "${{ macro.concat('env:ENV_PREFIX', 'linkding') }}"
name: Linkding
seedDefaultRegistry: true
endpoint:
url: "http://127.0.0.1:${{ env.LINKDING_PORT }}"
caddy:
publishPreferred: true
hostRoute:
host: "${{ macro.concat('env:ENV_PREFIX', 'linkding') }}-${{ macro.tunnelClientId() }}.${{ macro.tunnelEndpoint() }}"
tunnel:
public: true
env:
LINKDING_URL: "${{ macro.exposedOrigin() }}"
4. Write packages/linkding/tpl.env:
# Linkding service config
LINKDING_PORT=9090
LINKDING_IMAGE_TAG=1.36
LINKDING_SUPERUSER_NAME=admin
LINKDING_SUPERUSER_PASSWORD=changeme-on-first-boot
LD_ENABLE_OIDC=True
LD_OIDC_PROVIDER=${AUTHENTIK_URL}/application/o/linkding/
5. Write packages/linkding/docker-compose.yaml:
services:
linkding:
image: sissbruecker/linkding:${LINKDING_IMAGE_TAG}
container_name: ${LINKDING_CONTAINER_NAME}
ports:
- "${LINKDING_PORT}:9090"
volumes:
- linkding-data:/etc/linkding/data
environment:
LD_SUPERUSER_NAME: ${LINKDING_SUPERUSER_NAME}
LD_SUPERUSER_PASSWORD: ${LINKDING_SUPERUSER_PASSWORD}
LD_ENABLE_OIDC: ${LD_ENABLE_OIDC}
OIDC_RP_PROVIDER_ENDPOINT: ${LD_OIDC_PROVIDER}
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:9090/health"]
interval: 30s
restart: unless-stopped
volumes:
linkding-data:
6. Install:
just package install linkding
The framework reads your manifest, configures SSO with Authentik (creates the OAuth app, configures redirect URIs, populates the client id/secret in .env), generates the Caddy route, brings the container up.
$ just status | grep linkding
[asd-dev-linkding] ✓ healthy
$ grep ^LINKDING_URL= .env
LINKDING_URL=https://linkding-xyz1.eu2.tn.example.com
$ curl -sI ${LINKDING_URL} | head -1
HTTP/2 200
Open LINKDING_URL in your browser — Linkding's login page → click
"Sign in with OIDC" → Authentik handles authentication → you land
back in Linkding, logged in.
To remove the service later:
just uninstall linkding
# Stops container, drops Caddy route, removes Authentik OAuth app,
# preserves data volume by default (use --force to also drop data).
Three things to internalise:
The filesystem is the registry. No just service add step.
The framework walks packages/* at bootstrap time and on every
just install, picks up everything with a manifest.yaml.
Removing a service = deleting the directory.
SSO from one field. sso.type: oauth triggers the generic
OAuth-with-Authentik flow. The redirect path tells the flow
where Authentik should send the user back; the rest is
inferred from the service URL. No per-service auth code in
the framework.
Backup is a manifest property. backup.enabled: true +
backup.type: volume + backup.volumes tells the standard
just backup linkding flow what to snapshot. Same pattern
for type: database (uses database.* fields) or
type: workspace (uses the workspace dir).
Look at any existing packages/<svc>/manifest.yaml (redmine,
mattermost, n8n, …) for a real-world example with more fields.
They're all the same shape — different values.
Reference: /pma/reference/manifest (planned) — full manifest schema.
/pma/reference/manifest (planned).backup.* fields, schedule via cron in services.yaml.