Extension Examples
Real-world examples from the Sulla Desktop marketplace, annotated to explain each design decision.
Example 1: Stirling-PDF (Simple — Compose Ships With Recipe)
The simplest pattern. No git clone, no setup steps. Just a Docker Compose file and an installation manifest.
File Structure
recipes/stirling-pdf/
├── installation.yaml
├── manifest.yaml
├── docker-compose.yml
└── stirling.svg
docker-compose.yml
version: '3.8'
services:
stirling-pdf:
image: stirlingtools/stirling-pdf:latest
container_name: stirling-pdf
ports:
- '30201:8080' # External 30201 → Internal 8080
volumes:
- stirling-data:/configs
- ./custom:/custom # Relative to APP_DIR
environment:
- SYSTEM_LANGUAGE=en-GB
- DOCKER_ENABLE_SECURITY=false
restart: unless-stopped
volumes:
stirling-data:
Key points:
- Uses port
30201(in the 30000+ range to avoid conflicts) - Mounts a named volume for persistent data
- Uses
./customrelative path — this resolves to${APP_DIR}/custom restart: unless-stoppedensures the container survives reboots
installation.yaml
id: stirling-pdf
name: Stirling-PDF
description: Full-featured open-source PDF toolbox
icon: stirling.svg
version: '2026.02'
category: productivity
# Compose file ships with the recipe — no special config needed
compose:
composeFile: 'docker-compose.yml'
# No setup needed — docker compose pull happens automatically on start
setup: []
# Standard compose commands
commands:
start: 'docker compose -f ${COMPOSE_FILE} up -d'
stop: 'docker compose -f ${COMPOSE_FILE} down'
restart: 'docker compose -f ${COMPOSE_FILE} restart'
status: 'docker compose -f ${COMPOSE_FILE} ps'
update: 'docker compose -f ${COMPOSE_FILE} pull && docker compose -f ${COMPOSE_FILE} up -d'
logs: 'docker compose -f ${COMPOSE_FILE} logs --tail=100'
# One URL — the main dashboard
extraUrls:
- label: 'Stirling-PDF Dashboard'
url: 'http://localhost:30201'
Why this works:
setup: []— nothing to do. The compose file is already in the recipe folder.- When the user clicks "Install", Sulla downloads
docker-compose.ymlfrom the recipe folder into the extension directory. - When the user starts it, Sulla runs
docker compose -f /path/to/stirling-pdf/docker-compose.yml up -d. - The "Stirling-PDF Dashboard" URL appears in the extension card and system tray.
What happens at install time
1. Fetch installation.yaml from GitHub
2. Create extensions/stirling-pdf/ directory
3. Download all recipe assets:
- docker-compose.yml
- manifest.yaml
- stirling.svg
4. Save installation.yaml, labels.json
5. Run setup steps: (none)
6. Mark installed (version.txt)
What happens at start time
1. Read installation.yaml from disk
2. Resolve: ${COMPOSE_FILE} → /path/to/extensions/stirling-pdf/docker-compose.yml
3. Run: docker compose -f /path/to/.../docker-compose.yml up -d
4. Docker pulls stirlingtools/stirling-pdf:latest (if not cached)
5. Container starts on port 30201
Example 2: Mailcow (Advanced — Git Clone + Interactive Setup)
A complex extension that clones an upstream repository and has an interactive config script.
File Structure
recipes/mailcow/
├── installation.yaml
├── manifest.yaml
└── cow_mailcow.svg
Note: no docker-compose.yml in the recipe folder. The compose file comes from the cloned git repo.
installation.yaml
id: mailcow
name: Mailcow
description: Full-featured open-source mail server
icon: mailcow.png
version: '2026.02'
category: email
# Compose file lives inside the cloned repo
compose:
composeFile: 'docker-compose.yml'
# Setup clones the repo, then runs the config generator
setup:
- command: 'git clone --branch master --depth 1 https://github.com/mailcow/mailcow-dockerized.git ${APP_DIR}/repo'
optional: false # Clone MUST succeed
- command: 'cd ${APP_DIR}/repo && ./generate_config.sh'
optional: true # Interactive script — will fail in automated mode, that's OK
# Commands point to the compose file inside the cloned repo
commands:
start: 'docker compose -f ${APP_DIR}/repo/docker-compose.yml up -d'
stop: 'docker compose -f ${APP_DIR}/repo/docker-compose.yml down'
restart: 'docker compose -f ${APP_DIR}/repo/docker-compose.yml restart'
status: 'docker compose -f ${APP_DIR}/repo/docker-compose.yml ps'
update: 'cd ${APP_DIR}/repo && git pull && docker compose pull && docker compose up -d'
logs: 'docker compose -f ${APP_DIR}/repo/docker-compose.yml logs --tail=100'
defaultPort: 8080
adminPath: '/mailcow'
webmailPath: '/webmail'
openInBrowser: true
extraUrls:
- label: 'Roundcube Webmail'
url: 'http://localhost:8080/webmail'
- label: 'SOGo Calendar'
url: 'http://localhost:8080/SOGo'
- label: 'Admin Panel (direct)'
url: 'http://localhost:8080/mailcow'
env:
DOMAIN: 'yourdomain.com'
SKIP_LETS_ENCRYPT: 'n'
Key points:
- First setup step clones the mailcow repo into
${APP_DIR}/repo. It's markedoptional: falsebecause the whole extension depends on it. - Second step runs
generate_config.sh— this is interactive (asks for domain, timezone). It'soptional: trueso installation succeeds even though the script fails. The user can run it manually later. - Commands reference
${APP_DIR}/repo/docker-compose.ymlsince the compose file is inside the cloned repository, not in the recipe folder. updatecommand does agit pullfirst to get upstream changes.- Multiple
extraUrls— webmail, calendar, and admin panel all appear in the dropdown and tray.
What happens at install time
1. Fetch installation.yaml from GitHub
2. Create extensions/mailcow/ directory
3. Download recipe assets: manifest.yaml, cow_mailcow.svg
4. Save installation.yaml, labels.json
5. Run setup steps:
a. git clone mailcow-dockerized → extensions/mailcow/repo/
b. ./generate_config.sh → FAILS (interactive) → logged as warning, continues
6. Mark installed (version.txt)
Directory after install
extensions/mailcow/
├── installation.yaml
├── manifest.yaml
├── cow_mailcow.svg
├── labels.json
├── version.txt
└── repo/ ← cloned from GitHub
├── docker-compose.yml
├── generate_config.sh
├── data/
└── ...
Example 3: Minimal Template
Copy this to get started quickly.
docker-compose.yml
version: '3.8'
services:
app:
image: your-org/your-app:latest
container_name: my-extension
ports:
- '30XXX:8080'
environment:
- APP_ENV=production
restart: unless-stopped
installation.yaml
id: my-extension
name: My Extension
description: What it does
icon: icon.png
version: '1.0.0'
category: utility-tools
setup: []
commands:
start: 'docker compose -f ${COMPOSE_FILE} up -d'
stop: 'docker compose -f ${COMPOSE_FILE} down'
extraUrls:
- label: 'Open My Extension'
url: 'http://localhost:30XXX'
Replace 30XXX with your chosen port.
Example 4: Plex (Media Server — User Directory Mounts)
A media server that mounts the user's local Movies, Music, and Pictures folders.
docker-compose.yml
version: "3.8"
services:
plex:
image: plexinc/pms-docker:latest
container_name: plex
ports:
- "30210:32400"
volumes:
- {{path.data}}/config:/config
- {{path.movies}}:/data/movies
- {{path.music}}:/data/music
- {{path.pictures}}:/data/pictures
environment:
- TZ=America/Los_Angeles
- PLEX_CLAIM=
restart: unless-stopped
Key points:
{{path.movies}}resolves to the user's Movies folder (e.g./Users/jonathon/Movies){{path.music}}and{{path.pictures}}similarly resolve to system directories{{path.data}}/configuses the extension's persistent data directory — survives uninstall- Sulla auto-creates any path directories that don't already exist
installation.yaml
id: plex
name: Plex Media Server
description: Stream your personal media library
icon: plex.png
version: '2026.02'
category: media
setup: []
commands:
start: 'docker compose -f ${COMPOSE_FILE} up -d'
stop: 'docker compose -f ${COMPOSE_FILE} down'
restart: 'docker compose -f ${COMPOSE_FILE} restart'
status: 'docker compose -f ${COMPOSE_FILE} ps'
update: 'docker compose -f ${COMPOSE_FILE} pull && docker compose -f ${COMPOSE_FILE} up -d'
logs: 'docker compose -f ${COMPOSE_FILE} logs --tail=100'
extraUrls:
- label: 'Plex Web UI'
url: 'http://localhost:30210/web'
Example 5: Twenty CRM (Database Credentials from Settings)
A CRM with its own Postgres database that uses Sulla's auto-generated service password.
docker-compose.yml
version: '3.8'
services:
server:
image: twentycrm/twenty:latest
container_name: twenty-server
ports:
- '30207:3000'
volumes:
- twenty-server-data:/app/packages/twenty-server/.local-storage
environment:
SERVER_URL: 'http://localhost:30207'
PG_DATABASE_URL: postgres://twenty:{{sullaServicePassword|urlencode}}@twenty-postgres:5432/twenty
REDIS_URL: redis://host.docker.internal:30117
APP_SECRET: '{{sullaN8nEncryptionKey}}'
depends_on:
twenty-postgres:
condition: service_healthy
restart: unless-stopped
twenty-postgres:
image: postgres:16-alpine
container_name: twenty-postgres
ports:
- '30208:5432'
environment:
POSTGRES_DB: twenty
POSTGRES_USER: twenty
POSTGRES_PASSWORD: {{sullaServicePassword}}
volumes:
- twenty-postgres-data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U twenty']
interval: 10s
timeout: 5s
retries: 5
volumes:
twenty-server-data:
twenty-postgres-data:
Key points:
{{sullaServicePassword|urlencode}}in thePG_DATABASE_URL— the password may contain@,/, or other URL-special characters. Theurlencodemodifier percent-encodes them so the connection string stays valid.{{sullaServicePassword}}(without modifier) forPOSTGRES_PASSWORD— this is a plain env var, not inside a URL, so no encoding needed.{{sullaN8nEncryptionKey}}forAPP_SECRET— reuses an existing auto-generated key from Sulla settings.- Every user gets unique, auto-generated credentials. No more
changemepasswords.
Example 6: Using Integration Credentials
An extension that connects to Slack and sends notifications.
docker-compose.yml
version: '3.8'
services:
notifier:
image: my-org/slack-notifier:latest
container_name: slack-notifier
environment:
SLACK_BOT_TOKEN: '{{SLACK.BOT_KEY}}'
SLACK_WEBHOOK: '{{SLACK.WEBHOOK_URL}}'
ADMIN_EMAIL: '{{sullaEmail}}'
ADMIN_NAME: '{{primaryUserName}}'
restart: unless-stopped
Key points:
{{SLACK.BOT_KEY}}pulls from the IntegrationService — whatever the user configured in Sulla's Integrations panel- If the user hasn't configured Slack yet, the placeholder stays as
{{SLACK.BOT_KEY}}in the file — making it easy to debug - Settings like
{{sullaEmail}}and integration values like{{SLACK.BOT_KEY}}can be mixed freely
Example 7: Using the env Field with Variables
Instead of hardcoding variables in docker-compose.yml, use the env field in installation.yaml. Sulla writes a .env file that Docker Compose reads automatically.
docker-compose.yml
version: '3.8'
services:
app:
image: my-org/my-app:latest
container_name: my-app
ports:
- '30215:8080'
environment:
- ADMIN_EMAIL=${ADMIN_EMAIL}
- DB_PASSWORD=${DB_PASSWORD}
- SLACK_TOKEN=${SLACK_TOKEN}
restart: unless-stopped
installation.yaml
id: my-app
name: My App
description: Example using env field
icon: icon.png
version: '1.0.0'
category: utility-tools
setup: []
commands:
start: 'docker compose -f ${COMPOSE_FILE} up -d'
stop: 'docker compose -f ${COMPOSE_FILE} down'
env:
ADMIN_EMAIL: '{{sullaEmail}}'
DB_PASSWORD: '{{sullaServicePassword}}'
SLACK_TOKEN: '{{SLACK.BOT_KEY}}'
extraUrls:
- label: 'Open My App'
url: 'http://localhost:30215'
Key points:
- The docker-compose.yml uses standard
${VAR}env var syntax — no Sulla-specific placeholders - The
envfield in installation.yaml uses{{var}}placeholders that Sulla resolves - Sulla writes the resolved values to
.envbefore each start - Advantage: the docker-compose.yml stays portable — you can run it outside Sulla with a manual
.envfile
Patterns
Using Shared Infrastructure
If Sulla already runs a database your extension needs:
# docker-compose.yml
services:
my-app:
image: my-app:latest
environment:
- DB_HOST=sulla-mariadb
- DB_PORT=3306
- DB_NAME=${DB_NAME:-myapp}
networks:
- sulla-shared
- default
networks:
sulla-shared:
external: true
# installation.yaml
env:
DB_NAME: 'myapp'
DB_USER: 'myapp'
DB_PASSWORD: '{{sullaServicePassword}}'
Mounting User Directories
# docker-compose.yml
volumes:
- {{path.movies}}:/media/movies
- {{path.music}}:/media/music
- {{path.documents}}:/media/documents
- {{path.downloads}}:/imports
Persistent Data That Survives Uninstall
# docker-compose.yml
volumes:
- {{path.data}}/db:/var/lib/postgresql/data
- {{path.data}}/uploads:/app/uploads
- {{path.data}}/config:/etc/myapp
The data/ directory inside the extension folder is preserved on uninstall by default.
Passwords in Connection Strings
Always use |urlencode when embedding credentials inside URLs:
# WRONG — breaks if password contains @ / # ? & = +
PG_URL: postgres://user:{{sullaServicePassword}}@db:5432/mydb
# RIGHT — special characters are percent-encoded
PG_URL: postgres://user:{{sullaServicePassword|urlencode}}@db:5432/mydb
Downloading Additional Files During Setup
setup:
- command: 'curl -sL https://example.com/config.tar.gz | tar xz -C ${APP_DIR}'
optional: false
- command: 'chmod +x ${APP_DIR}/scripts/*.sh'
Custom Update Logic
commands:
update: >
cd ${APP_DIR} &&
docker compose -f ${COMPOSE_FILE} pull &&
docker compose -f ${COMPOSE_FILE} down &&
docker compose -f ${COMPOSE_FILE} up -d
Multiple Services with Separate Logs
commands:
logs: 'docker compose -f ${COMPOSE_FILE} logs --tail=50 app worker scheduler'