diff --git a/README.md b/README.md
index b48897e..54c237c 100644
--- a/README.md
+++ b/README.md
@@ -76,11 +76,15 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
(`/proc/net/tcp[6]`) and running container bindings, with a confirm dialog.
- **Resource limits**: CPU/memory sliders in the editor → `deploy.resources.limits`.
- **Template library**: each template is a ready-to-run **stack folder** in git
- (`backend/templates//` — `compose.yaml` + `.env.example` + `template.json`).
- Bundled set: Jellyfin, Vaultwarden, Uptime-Kuma, Paperless-NGX, Gitea. "Pull"
- copies the whole folder into a new stack (`.env.example` → `.env`) which you then
- edit and deploy. Save any stack back as a custom template (stored under
- `${DATA_DIR}/templates/`). Add your own by dropping a folder into the templates dir.
+ (`backend/templates//` — `compose.yaml` + `.env.example` + `template.json`,
+ plus any extra config the app needs, e.g. `prometheus.yml` or a `Caddyfile`).
+ 83 bundled homelab apps across media, *arr automation, networking, reverse
+ proxies, VPN, SSO, monitoring, dashboards, files/backup, notes and wikis, home
+ automation, dev tooling, databases, local AI and more — searchable and
+ filterable by tag on the Templates page. "Pull" copies the whole folder into a
+ new stack (`.env.example` → `.env`) which you then edit and deploy. Save any
+ stack back as a custom template (stored under `${DATA_DIR}/templates/`). Add
+ your own by dropping a folder into the templates dir.
- **Healthcheck status** surfaced per container in the stack overview.
### Phase 4 — Operations
diff --git a/backend/templates/actual-budget/.env.example b/backend/templates/actual-budget/.env.example
new file mode 100644
index 0000000..09a0c6d
--- /dev/null
+++ b/backend/templates/actual-budget/.env.example
@@ -0,0 +1,3 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/actual
+HTTP_PORT=5006
diff --git a/backend/templates/actual-budget/compose.yaml b/backend/templates/actual-budget/compose.yaml
new file mode 100644
index 0000000..49ae6b3
--- /dev/null
+++ b/backend/templates/actual-budget/compose.yaml
@@ -0,0 +1,11 @@
+services:
+ actual:
+ image: ghcr.io/actualbudget/actual-server:latest
+ container_name: actual-budget
+ restart: unless-stopped
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${HTTP_PORT:-5006}:5006"
+ volumes:
+ - ${DATA_PATH:-/srv/actual}:/data
diff --git a/backend/templates/actual-budget/template.json b/backend/templates/actual-budget/template.json
new file mode 100644
index 0000000..2bfd8f4
--- /dev/null
+++ b/backend/templates/actual-budget/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Actual Budget",
+ "description": "Local-first envelope budgeting with end-to-end encrypted sync across devices.",
+ "tags": [
+ "finance",
+ "productivity"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/adguard-home/.env.example b/backend/templates/adguard-home/.env.example
new file mode 100644
index 0000000..adc5bad
--- /dev/null
+++ b/backend/templates/adguard-home/.env.example
@@ -0,0 +1,4 @@
+DATA_PATH=/srv/adguardhome
+DNS_PORT=53
+SETUP_PORT=3300
+HTTP_PORT=8082
diff --git a/backend/templates/adguard-home/compose.yaml b/backend/templates/adguard-home/compose.yaml
new file mode 100644
index 0000000..5be2531
--- /dev/null
+++ b/backend/templates/adguard-home/compose.yaml
@@ -0,0 +1,14 @@
+services:
+ adguardhome:
+ image: adguard/adguardhome:latest
+ container_name: adguardhome
+ restart: unless-stopped
+ ports:
+ - "${DNS_PORT:-53}:53/tcp"
+ - "${DNS_PORT:-53}:53/udp"
+ # Setup wizard on first run; the UI moves to HTTP_PORT afterwards.
+ - "${SETUP_PORT:-3300}:3000/tcp"
+ - "${HTTP_PORT:-8082}:80/tcp"
+ volumes:
+ - ${DATA_PATH:-/srv/adguardhome}/work:/opt/adguardhome/work
+ - ${DATA_PATH:-/srv/adguardhome}/conf:/opt/adguardhome/conf
diff --git a/backend/templates/adguard-home/template.json b/backend/templates/adguard-home/template.json
new file mode 100644
index 0000000..21da9ec
--- /dev/null
+++ b/backend/templates/adguard-home/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "AdGuard Home",
+ "description": "DNS server with ad and tracker blocking, DoH/DoT and per-client rules.",
+ "tags": [
+ "network",
+ "dns",
+ "ad-blocking"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/audiobookshelf/.env.example b/backend/templates/audiobookshelf/.env.example
new file mode 100644
index 0000000..97e504e
--- /dev/null
+++ b/backend/templates/audiobookshelf/.env.example
@@ -0,0 +1,5 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/audiobookshelf
+AUDIOBOOKS_PATH=/srv/media/audiobooks
+PODCASTS_PATH=/srv/media/podcasts
+HTTP_PORT=13378
diff --git a/backend/templates/audiobookshelf/compose.yaml b/backend/templates/audiobookshelf/compose.yaml
new file mode 100644
index 0000000..3c99de1
--- /dev/null
+++ b/backend/templates/audiobookshelf/compose.yaml
@@ -0,0 +1,14 @@
+services:
+ audiobookshelf:
+ image: ghcr.io/advplyr/audiobookshelf:latest
+ container_name: audiobookshelf
+ restart: unless-stopped
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${HTTP_PORT:-13378}:80"
+ volumes:
+ - ${DATA_PATH:-/srv/audiobookshelf}/config:/config
+ - ${DATA_PATH:-/srv/audiobookshelf}/metadata:/metadata
+ - ${AUDIOBOOKS_PATH:-/srv/media/audiobooks}:/audiobooks
+ - ${PODCASTS_PATH:-/srv/media/podcasts}:/podcasts
diff --git a/backend/templates/audiobookshelf/template.json b/backend/templates/audiobookshelf/template.json
new file mode 100644
index 0000000..435ffc1
--- /dev/null
+++ b/backend/templates/audiobookshelf/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Audiobookshelf",
+ "description": "Audiobook and podcast server that keeps progress in sync across devices.",
+ "tags": [
+ "media",
+ "books",
+ "streaming"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/authelia/.env.example b/backend/templates/authelia/.env.example
new file mode 100644
index 0000000..ce9fcc4
--- /dev/null
+++ b/backend/templates/authelia/.env.example
@@ -0,0 +1,3 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/authelia
+HTTP_PORT=9091
diff --git a/backend/templates/authelia/compose.yaml b/backend/templates/authelia/compose.yaml
new file mode 100644
index 0000000..7e270b7
--- /dev/null
+++ b/backend/templates/authelia/compose.yaml
@@ -0,0 +1,13 @@
+services:
+ authelia:
+ image: authelia/authelia:latest
+ container_name: authelia
+ restart: unless-stopped
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${HTTP_PORT:-9091}:9091"
+ volumes:
+ - ./configuration.yml:/config/configuration.yml:ro
+ - ./users_database.yml:/config/users_database.yml
+ - ${DATA_PATH:-/srv/authelia}:/config/db
diff --git a/backend/templates/authelia/configuration.yml b/backend/templates/authelia/configuration.yml
new file mode 100644
index 0000000..072ab2e
--- /dev/null
+++ b/backend/templates/authelia/configuration.yml
@@ -0,0 +1,34 @@
+# Minimal Authelia config. Replace every "change-me" and the example domains.
+theme: dark
+
+identity_validation:
+ reset_password:
+ jwt_secret: change-me-jwt-secret
+
+server:
+ address: tcp://0.0.0.0:9091
+
+authentication_backend:
+ file:
+ path: /config/users_database.yml
+
+access_control:
+ default_policy: deny
+ rules:
+ - domain: "*.example.com"
+ policy: two_factor
+
+session:
+ secret: change-me-session-secret
+ cookies:
+ - domain: example.com
+ authelia_url: https://auth.example.com
+
+storage:
+ encryption_key: change-me-encryption-key-at-least-20-chars
+ local:
+ path: /config/db/db.sqlite3
+
+notifier:
+ filesystem:
+ filename: /config/db/notification.txt
diff --git a/backend/templates/authelia/template.json b/backend/templates/authelia/template.json
new file mode 100644
index 0000000..dd0ef12
--- /dev/null
+++ b/backend/templates/authelia/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Authelia",
+ "description": "Lightweight authentication and 2FA portal for reverse-proxy forward auth.",
+ "tags": [
+ "security",
+ "identity",
+ "sso"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/authelia/users_database.yml b/backend/templates/authelia/users_database.yml
new file mode 100644
index 0000000..1febf05
--- /dev/null
+++ b/backend/templates/authelia/users_database.yml
@@ -0,0 +1,10 @@
+# Generate a password hash with:
+# docker run --rm authelia/authelia:latest authelia crypto hash generate argon2 --password 'yourpassword'
+users:
+ admin:
+ disabled: false
+ displayname: "Admin"
+ password: "$argon2id$v=19$m=65536,t=3,p=4$REPLACE_ME"
+ email: admin@example.com
+ groups:
+ - admins
diff --git a/backend/templates/authentik/.env.example b/backend/templates/authentik/.env.example
new file mode 100644
index 0000000..72c4742
--- /dev/null
+++ b/backend/templates/authentik/.env.example
@@ -0,0 +1,7 @@
+DATA_PATH=/srv/authentik
+AUTHENTIK_TAG=2026.8.0
+HTTP_PORT=9200
+HTTPS_PORT=9243
+# Both required. Generate with: openssl rand -base64 36
+PG_PASS=change-me
+AUTHENTIK_SECRET_KEY=change-me
diff --git a/backend/templates/authentik/compose.yaml b/backend/templates/authentik/compose.yaml
new file mode 100644
index 0000000..5a4909e
--- /dev/null
+++ b/backend/templates/authentik/compose.yaml
@@ -0,0 +1,61 @@
+services:
+ postgresql:
+ image: postgres:16-alpine
+ container_name: authentik-db
+ restart: unless-stopped
+ environment:
+ - POSTGRES_DB=authentik
+ - POSTGRES_USER=authentik
+ - POSTGRES_PASSWORD=${PG_PASS:?database password required}
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -d authentik -U authentik"]
+ interval: 30s
+ timeout: 5s
+ retries: 5
+ start_period: 20s
+ volumes:
+ - ${DATA_PATH:-/srv/authentik}/database:/var/lib/postgresql/data
+
+ server:
+ image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-2026.8.0}
+ container_name: authentik-server
+ command: server
+ restart: unless-stopped
+ shm_size: 512mb
+ depends_on:
+ postgresql:
+ condition: service_healthy
+ environment:
+ - AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY:?secret key required}
+ - AUTHENTIK_POSTGRESQL__HOST=postgresql
+ - AUTHENTIK_POSTGRESQL__NAME=authentik
+ - AUTHENTIK_POSTGRESQL__USER=authentik
+ - AUTHENTIK_POSTGRESQL__PASSWORD=${PG_PASS}
+ ports:
+ - "${HTTP_PORT:-9200}:9000"
+ - "${HTTPS_PORT:-9243}:9443"
+ volumes:
+ - ${DATA_PATH:-/srv/authentik}/data:/data
+ - ${DATA_PATH:-/srv/authentik}/custom-templates:/templates
+
+ worker:
+ image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-2026.8.0}
+ container_name: authentik-worker
+ command: worker
+ restart: unless-stopped
+ shm_size: 512mb
+ user: root
+ depends_on:
+ postgresql:
+ condition: service_healthy
+ environment:
+ - AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}
+ - AUTHENTIK_POSTGRESQL__HOST=postgresql
+ - AUTHENTIK_POSTGRESQL__NAME=authentik
+ - AUTHENTIK_POSTGRESQL__USER=authentik
+ - AUTHENTIK_POSTGRESQL__PASSWORD=${PG_PASS}
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ - ${DATA_PATH:-/srv/authentik}/data:/data
+ - ${DATA_PATH:-/srv/authentik}/certs:/certs
+ - ${DATA_PATH:-/srv/authentik}/custom-templates:/templates
diff --git a/backend/templates/authentik/template.json b/backend/templates/authentik/template.json
new file mode 100644
index 0000000..a814da2
--- /dev/null
+++ b/backend/templates/authentik/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "authentik",
+ "description": "Identity provider and SSO gateway (OAuth2, SAML, LDAP, forward auth). Finish setup at /if/flow/initial-setup/.",
+ "tags": [
+ "security",
+ "identity",
+ "sso"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/bazarr/.env.example b/backend/templates/bazarr/.env.example
new file mode 100644
index 0000000..6a82641
--- /dev/null
+++ b/backend/templates/bazarr/.env.example
@@ -0,0 +1,6 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+DATA_PATH=/srv/bazarr
+MEDIA_PATH=/srv/media
+HTTP_PORT=6767
diff --git a/backend/templates/bazarr/compose.yaml b/backend/templates/bazarr/compose.yaml
new file mode 100644
index 0000000..46fd88d
--- /dev/null
+++ b/backend/templates/bazarr/compose.yaml
@@ -0,0 +1,14 @@
+services:
+ bazarr:
+ image: lscr.io/linuxserver/bazarr:latest
+ container_name: bazarr
+ restart: unless-stopped
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${HTTP_PORT:-6767}:6767"
+ volumes:
+ - ${DATA_PATH:-/srv/bazarr}:/config
+ - ${MEDIA_PATH:-/srv/media}:/media
diff --git a/backend/templates/bazarr/template.json b/backend/templates/bazarr/template.json
new file mode 100644
index 0000000..4094c23
--- /dev/null
+++ b/backend/templates/bazarr/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Bazarr",
+ "description": "Companion to Sonarr and Radarr that downloads matching subtitles.",
+ "tags": [
+ "media",
+ "automation",
+ "arr"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/beszel/.env.example b/backend/templates/beszel/.env.example
new file mode 100644
index 0000000..54eff02
--- /dev/null
+++ b/backend/templates/beszel/.env.example
@@ -0,0 +1,4 @@
+DATA_PATH=/srv/beszel
+HTTP_PORT=8090
+AGENT_PORT=45876
+AGENT_KEY=
diff --git a/backend/templates/beszel/compose.yaml b/backend/templates/beszel/compose.yaml
new file mode 100644
index 0000000..0ea37f1
--- /dev/null
+++ b/backend/templates/beszel/compose.yaml
@@ -0,0 +1,22 @@
+services:
+ beszel:
+ image: henrygd/beszel:latest
+ container_name: beszel
+ restart: unless-stopped
+ ports:
+ - "${HTTP_PORT:-8090}:8090"
+ volumes:
+ - ${DATA_PATH:-/srv/beszel}/data:/beszel_data
+
+ beszel-agent:
+ image: henrygd/beszel-agent:latest
+ container_name: beszel-agent
+ restart: unless-stopped
+ network_mode: host
+ environment:
+ - LISTEN=${AGENT_PORT:-45876}
+ # Copy this from the "add system" dialog in the Beszel UI.
+ - KEY=${AGENT_KEY:-}
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ - ${DATA_PATH:-/srv/beszel}/agent:/var/lib/beszel-agent
diff --git a/backend/templates/beszel/template.json b/backend/templates/beszel/template.json
new file mode 100644
index 0000000..762baa5
--- /dev/null
+++ b/backend/templates/beszel/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Beszel",
+ "description": "Lightweight server monitoring with historical charts, alerts and Docker stats.",
+ "tags": [
+ "monitoring",
+ "metrics",
+ "docker"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/bookstack/.env.example b/backend/templates/bookstack/.env.example
new file mode 100644
index 0000000..4ae0beb
--- /dev/null
+++ b/backend/templates/bookstack/.env.example
@@ -0,0 +1,10 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+DATA_PATH=/srv/bookstack
+HTTP_PORT=6875
+APP_URL=http://localhost:6875
+# Required, must be "base64:..." — generate with: echo "base64:$(openssl rand -base64 32)"
+APP_KEY=
+DB_PASSWORD=change-me
+DB_ROOT_PASSWORD=change-me
diff --git a/backend/templates/bookstack/compose.yaml b/backend/templates/bookstack/compose.yaml
new file mode 100644
index 0000000..b8ec2e5
--- /dev/null
+++ b/backend/templates/bookstack/compose.yaml
@@ -0,0 +1,38 @@
+services:
+ bookstack:
+ image: lscr.io/linuxserver/bookstack:latest
+ container_name: bookstack
+ restart: unless-stopped
+ depends_on:
+ - bookstack-db
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ - TZ=${TZ:-Europe/Berlin}
+ - APP_URL=${APP_URL:-http://localhost:6875}
+ # Required. Generate with: openssl rand -base64 32
+ - APP_KEY=${APP_KEY:?app key required}
+ - DB_HOST=bookstack-db
+ - DB_PORT=3306
+ - DB_DATABASE=bookstackapp
+ - DB_USERNAME=bookstack
+ - DB_PASSWORD=${DB_PASSWORD:-bookstack}
+ ports:
+ - "${HTTP_PORT:-6875}:80"
+ volumes:
+ - ${DATA_PATH:-/srv/bookstack}/config:/config
+
+ bookstack-db:
+ image: lscr.io/linuxserver/mariadb:latest
+ container_name: bookstack-db
+ restart: unless-stopped
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ - TZ=${TZ:-Europe/Berlin}
+ - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD:-bookstack}
+ - MYSQL_DATABASE=bookstackapp
+ - MYSQL_USER=bookstack
+ - MYSQL_PASSWORD=${DB_PASSWORD:-bookstack}
+ volumes:
+ - ${DATA_PATH:-/srv/bookstack}/db:/config
diff --git a/backend/templates/bookstack/template.json b/backend/templates/bookstack/template.json
new file mode 100644
index 0000000..28bf788
--- /dev/null
+++ b/backend/templates/bookstack/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "BookStack",
+ "description": "Documentation platform organised into shelves, books, chapters and pages. First login: admin@admin.com / password.",
+ "tags": [
+ "documents",
+ "wiki",
+ "productivity"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/caddy/.env.example b/backend/templates/caddy/.env.example
new file mode 100644
index 0000000..857334f
--- /dev/null
+++ b/backend/templates/caddy/.env.example
@@ -0,0 +1,3 @@
+DATA_PATH=/srv/caddy
+HTTP_PORT=80
+HTTPS_PORT=443
diff --git a/backend/templates/caddy/Caddyfile b/backend/templates/caddy/Caddyfile
new file mode 100644
index 0000000..b0d29a6
--- /dev/null
+++ b/backend/templates/caddy/Caddyfile
@@ -0,0 +1,11 @@
+# Caddy issues and renews TLS certificates automatically for any real hostname.
+# Replace the examples below with your own, then restart the stack.
+
+app.example.com {
+ reverse_proxy host.docker.internal:8080
+}
+
+# Local-only site on plain HTTP (no certificate needed):
+# http://nas.lan {
+# reverse_proxy 192.168.1.10:5000
+# }
diff --git a/backend/templates/caddy/compose.yaml b/backend/templates/caddy/compose.yaml
new file mode 100644
index 0000000..16a7088
--- /dev/null
+++ b/backend/templates/caddy/compose.yaml
@@ -0,0 +1,13 @@
+services:
+ caddy:
+ image: caddy:2-alpine
+ container_name: caddy
+ restart: unless-stopped
+ ports:
+ - "${HTTP_PORT:-80}:80"
+ - "${HTTPS_PORT:-443}:443"
+ - "${HTTPS_PORT:-443}:443/udp"
+ volumes:
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
+ - ${DATA_PATH:-/srv/caddy}/data:/data
+ - ${DATA_PATH:-/srv/caddy}/config:/config
diff --git a/backend/templates/caddy/template.json b/backend/templates/caddy/template.json
new file mode 100644
index 0000000..bd39158
--- /dev/null
+++ b/backend/templates/caddy/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Caddy",
+ "description": "Reverse proxy with automatic HTTPS, configured through a short Caddyfile.",
+ "tags": [
+ "network",
+ "proxy"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/calibre-web/.env.example b/backend/templates/calibre-web/.env.example
new file mode 100644
index 0000000..76f671b
--- /dev/null
+++ b/backend/templates/calibre-web/.env.example
@@ -0,0 +1,6 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+DATA_PATH=/srv/calibre-web
+LIBRARY_PATH=/srv/media/books
+HTTP_PORT=8083
diff --git a/backend/templates/calibre-web/compose.yaml b/backend/templates/calibre-web/compose.yaml
new file mode 100644
index 0000000..de48c33
--- /dev/null
+++ b/backend/templates/calibre-web/compose.yaml
@@ -0,0 +1,16 @@
+services:
+ calibre-web:
+ image: lscr.io/linuxserver/calibre-web:latest
+ container_name: calibre-web
+ restart: unless-stopped
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ - TZ=${TZ:-Europe/Berlin}
+ # Adds ebook format conversion (downloads Calibre on first start)
+ - DOCKER_MODS=linuxserver/mods:universal-calibre
+ ports:
+ - "${HTTP_PORT:-8083}:8083"
+ volumes:
+ - ${DATA_PATH:-/srv/calibre-web}:/config
+ - ${LIBRARY_PATH:-/srv/media/books}:/books
diff --git a/backend/templates/calibre-web/template.json b/backend/templates/calibre-web/template.json
new file mode 100644
index 0000000..0ed99c3
--- /dev/null
+++ b/backend/templates/calibre-web/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Calibre-Web",
+ "description": "Web front-end for a Calibre ebook library, with OPDS and send-to-Kindle.",
+ "tags": [
+ "media",
+ "books"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/changedetection-io/.env.example b/backend/templates/changedetection-io/.env.example
new file mode 100644
index 0000000..78c675a
--- /dev/null
+++ b/backend/templates/changedetection-io/.env.example
@@ -0,0 +1,3 @@
+DATA_PATH=/srv/changedetection
+HTTP_PORT=5000
+BASE_URL=http://localhost:5000
diff --git a/backend/templates/changedetection-io/compose.yaml b/backend/templates/changedetection-io/compose.yaml
new file mode 100644
index 0000000..3ea38d3
--- /dev/null
+++ b/backend/templates/changedetection-io/compose.yaml
@@ -0,0 +1,25 @@
+services:
+ changedetection:
+ image: ghcr.io/dgtlmoon/changedetection.io:latest
+ container_name: changedetection
+ restart: unless-stopped
+ environment:
+ - BASE_URL=${BASE_URL:-http://localhost:5000}
+ - PLAYWRIGHT_DRIVER_URL=ws://browser:3000
+ ports:
+ - "${HTTP_PORT:-5000}:5000"
+ volumes:
+ - ${DATA_PATH:-/srv/changedetection}:/datastore
+ depends_on:
+ - browser
+
+ browser:
+ image: dgtlmoon/sockpuppetbrowser:latest
+ container_name: changedetection-browser
+ restart: unless-stopped
+ cap_add:
+ - SYS_ADMIN
+ environment:
+ - SCREEN_WIDTH=1920
+ - SCREEN_HEIGHT=1024
+ - MAX_CONCURRENT_CHROME_PROCESSES=10
diff --git a/backend/templates/changedetection-io/template.json b/backend/templates/changedetection-io/template.json
new file mode 100644
index 0000000..af296fe
--- /dev/null
+++ b/backend/templates/changedetection-io/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "changedetection.io",
+ "description": "Watches web pages for changes and notifies you — with a headless browser for JS-heavy sites.",
+ "tags": [
+ "monitoring",
+ "automation"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/code-server/.env.example b/backend/templates/code-server/.env.example
new file mode 100644
index 0000000..870f282
--- /dev/null
+++ b/backend/templates/code-server/.env.example
@@ -0,0 +1,9 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+DATA_PATH=/srv/code-server
+PROJECTS_PATH=/srv/projects
+HTTP_PORT=8443
+# Required — this is the web login password.
+PASSWORD=
+SUDO_PASSWORD=
diff --git a/backend/templates/code-server/compose.yaml b/backend/templates/code-server/compose.yaml
new file mode 100644
index 0000000..a6b6dfd
--- /dev/null
+++ b/backend/templates/code-server/compose.yaml
@@ -0,0 +1,17 @@
+services:
+ code-server:
+ image: lscr.io/linuxserver/code-server:latest
+ container_name: code-server
+ restart: unless-stopped
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ - TZ=${TZ:-Europe/Berlin}
+ - PASSWORD=${PASSWORD:?password required}
+ - SUDO_PASSWORD=${SUDO_PASSWORD:-}
+ - DEFAULT_WORKSPACE=/config/workspace
+ ports:
+ - "${HTTP_PORT:-8443}:8443"
+ volumes:
+ - ${DATA_PATH:-/srv/code-server}:/config
+ - ${PROJECTS_PATH:-/srv/projects}:/config/workspace
diff --git a/backend/templates/code-server/template.json b/backend/templates/code-server/template.json
new file mode 100644
index 0000000..dcc15fe
--- /dev/null
+++ b/backend/templates/code-server/template.json
@@ -0,0 +1,8 @@
+{
+ "name": "code-server",
+ "description": "VS Code in the browser, running on the server where your files already are.",
+ "tags": [
+ "dev"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/dashy/.env.example b/backend/templates/dashy/.env.example
new file mode 100644
index 0000000..630755f
--- /dev/null
+++ b/backend/templates/dashy/.env.example
@@ -0,0 +1,2 @@
+DATA_PATH=/srv/dashy
+HTTP_PORT=4000
diff --git a/backend/templates/dashy/compose.yaml b/backend/templates/dashy/compose.yaml
new file mode 100644
index 0000000..ad87352
--- /dev/null
+++ b/backend/templates/dashy/compose.yaml
@@ -0,0 +1,12 @@
+services:
+ dashy:
+ image: lissy93/dashy:latest
+ container_name: dashy
+ restart: unless-stopped
+ environment:
+ - NODE_ENV=production
+ ports:
+ - "${HTTP_PORT:-4000}:8080"
+ volumes:
+ # Dashy writes conf.yml back into this directory when you edit in the UI.
+ - ${DATA_PATH:-/srv/dashy}:/app/user-data
diff --git a/backend/templates/dashy/template.json b/backend/templates/dashy/template.json
new file mode 100644
index 0000000..8ababb7
--- /dev/null
+++ b/backend/templates/dashy/template.json
@@ -0,0 +1,8 @@
+{
+ "name": "Dashy",
+ "description": "Highly themeable dashboard with status checks, editable from the browser.",
+ "tags": [
+ "dashboard"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/docker-registry/.env.example b/backend/templates/docker-registry/.env.example
new file mode 100644
index 0000000..32ed92d
--- /dev/null
+++ b/backend/templates/docker-registry/.env.example
@@ -0,0 +1,4 @@
+DATA_PATH=/srv/registry
+HTTP_PORT=5050
+UI_PORT=5080
+REGISTRY_TITLE=Homelab Registry
diff --git a/backend/templates/docker-registry/compose.yaml b/backend/templates/docker-registry/compose.yaml
new file mode 100644
index 0000000..98e93d3
--- /dev/null
+++ b/backend/templates/docker-registry/compose.yaml
@@ -0,0 +1,29 @@
+services:
+ registry:
+ image: registry:2
+ container_name: registry
+ restart: unless-stopped
+ environment:
+ - REGISTRY_STORAGE_DELETE_ENABLED=true
+ ports:
+ - "${HTTP_PORT:-5050}:5000"
+ volumes:
+ - ${DATA_PATH:-/srv/registry}:/var/lib/registry
+ # Basic auth. Create it with:
+ # docker run --rm httpd:alpine htpasswd -Bbn user pass > htpasswd
+ # then uncomment the mount and the three REGISTRY_AUTH_* vars above.
+ # - ./htpasswd:/auth/htpasswd:ro
+
+ registry-ui:
+ image: joxit/docker-registry-ui:latest
+ container_name: registry-ui
+ restart: unless-stopped
+ depends_on:
+ - registry
+ environment:
+ - SINGLE_REGISTRY=true
+ - REGISTRY_TITLE=${REGISTRY_TITLE:-Homelab Registry}
+ - NGINX_PROXY_PASS_URL=http://registry:5000
+ - DELETE_IMAGES=true
+ ports:
+ - "${UI_PORT:-5080}:80"
diff --git a/backend/templates/docker-registry/template.json b/backend/templates/docker-registry/template.json
new file mode 100644
index 0000000..02e0c71
--- /dev/null
+++ b/backend/templates/docker-registry/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Docker Registry",
+ "description": "Private container registry with a browsable web UI. Add auth and TLS before exposing it.",
+ "tags": [
+ "docker",
+ "dev"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/dockge/.env.example b/backend/templates/dockge/.env.example
new file mode 100644
index 0000000..55a0ad4
--- /dev/null
+++ b/backend/templates/dockge/.env.example
@@ -0,0 +1,3 @@
+DATA_PATH=/srv/dockge
+STACKS_DIR=/opt/stacks
+HTTP_PORT=5001
diff --git a/backend/templates/dockge/compose.yaml b/backend/templates/dockge/compose.yaml
new file mode 100644
index 0000000..979b0b4
--- /dev/null
+++ b/backend/templates/dockge/compose.yaml
@@ -0,0 +1,14 @@
+services:
+ dockge:
+ image: louislam/dockge:1
+ container_name: dockge
+ restart: unless-stopped
+ environment:
+ - DOCKGE_STACKS_DIR=${STACKS_DIR:-/opt/stacks}
+ ports:
+ - "${HTTP_PORT:-5001}:5001"
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ - ${DATA_PATH:-/srv/dockge}:/app/data
+ # Host path and container path must match, or compose lookups break.
+ - ${STACKS_DIR:-/opt/stacks}:${STACKS_DIR:-/opt/stacks}
diff --git a/backend/templates/dockge/template.json b/backend/templates/dockge/template.json
new file mode 100644
index 0000000..8a755b3
--- /dev/null
+++ b/backend/templates/dockge/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Dockge",
+ "description": "Compose-stack manager with a live editor and terminal output.",
+ "tags": [
+ "docker",
+ "dashboard"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/dozzle/.env.example b/backend/templates/dozzle/.env.example
new file mode 100644
index 0000000..46bb51b
--- /dev/null
+++ b/backend/templates/dozzle/.env.example
@@ -0,0 +1 @@
+HTTP_PORT=8888
diff --git a/backend/templates/dozzle/compose.yaml b/backend/templates/dozzle/compose.yaml
new file mode 100644
index 0000000..b1b716f
--- /dev/null
+++ b/backend/templates/dozzle/compose.yaml
@@ -0,0 +1,12 @@
+services:
+ dozzle:
+ image: amir20/dozzle:latest
+ container_name: dozzle
+ restart: unless-stopped
+ environment:
+ - DOZZLE_LEVEL=info
+ - DOZZLE_NO_ANALYTICS=true
+ ports:
+ - "${HTTP_PORT:-8888}:8080"
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
diff --git a/backend/templates/dozzle/template.json b/backend/templates/dozzle/template.json
new file mode 100644
index 0000000..e5dd542
--- /dev/null
+++ b/backend/templates/dozzle/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Dozzle",
+ "description": "Live container log viewer in the browser — no log shipping, no storage.",
+ "tags": [
+ "monitoring",
+ "docker",
+ "logs"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/duplicati/.env.example b/backend/templates/duplicati/.env.example
new file mode 100644
index 0000000..9ef1670
--- /dev/null
+++ b/backend/templates/duplicati/.env.example
@@ -0,0 +1,10 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+DATA_PATH=/srv/duplicati
+BACKUPS_PATH=/srv/backups
+SOURCE_PATH=/srv
+HTTP_PORT=8201
+WEB_PASSWORD=change-me
+# Required. Generate with: openssl rand -hex 32
+SETTINGS_ENCRYPTION_KEY=
diff --git a/backend/templates/duplicati/compose.yaml b/backend/templates/duplicati/compose.yaml
new file mode 100644
index 0000000..451d004
--- /dev/null
+++ b/backend/templates/duplicati/compose.yaml
@@ -0,0 +1,17 @@
+services:
+ duplicati:
+ image: lscr.io/linuxserver/duplicati:latest
+ container_name: duplicati
+ restart: unless-stopped
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ - TZ=${TZ:-Europe/Berlin}
+ - SETTINGS_ENCRYPTION_KEY=${SETTINGS_ENCRYPTION_KEY:?encryption key required}
+ - DUPLICATI__WEBSERVICE_PASSWORD=${WEB_PASSWORD:-change-me}
+ ports:
+ - "${HTTP_PORT:-8201}:8200"
+ volumes:
+ - ${DATA_PATH:-/srv/duplicati}:/config
+ - ${BACKUPS_PATH:-/srv/backups}:/backups
+ - ${SOURCE_PATH:-/srv}:/source:ro
diff --git a/backend/templates/duplicati/template.json b/backend/templates/duplicati/template.json
new file mode 100644
index 0000000..cf299cd
--- /dev/null
+++ b/backend/templates/duplicati/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Duplicati",
+ "description": "Scheduled, encrypted, deduplicated backups to local disks or any cloud target.",
+ "tags": [
+ "backup",
+ "files"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/emby/.env.example b/backend/templates/emby/.env.example
new file mode 100644
index 0000000..52e600c
--- /dev/null
+++ b/backend/templates/emby/.env.example
@@ -0,0 +1,6 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+CONFIG_PATH=/srv/emby/config
+MEDIA_PATH=/srv/media
+HTTP_PORT=8105
diff --git a/backend/templates/emby/compose.yaml b/backend/templates/emby/compose.yaml
new file mode 100644
index 0000000..176789a
--- /dev/null
+++ b/backend/templates/emby/compose.yaml
@@ -0,0 +1,17 @@
+services:
+ emby:
+ image: lscr.io/linuxserver/emby:latest
+ container_name: emby
+ restart: unless-stopped
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${HTTP_PORT:-8105}:8096"
+ volumes:
+ - ${CONFIG_PATH:-/srv/emby/config}:/config
+ - ${MEDIA_PATH:-/srv/media}:/media
+ # Uncomment for Intel/AMD hardware transcoding (Emby Premiere required):
+ # devices:
+ # - /dev/dri:/dev/dri
diff --git a/backend/templates/emby/template.json b/backend/templates/emby/template.json
new file mode 100644
index 0000000..635f3f4
--- /dev/null
+++ b/backend/templates/emby/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Emby",
+ "description": "Media server for movies, TV and music, with live TV and DVR support.",
+ "tags": [
+ "media",
+ "streaming"
+ ],
+ "gpu": "NVIDIA / Intel optional"
+}
diff --git a/backend/templates/esphome/.env.example b/backend/templates/esphome/.env.example
new file mode 100644
index 0000000..3632e96
--- /dev/null
+++ b/backend/templates/esphome/.env.example
@@ -0,0 +1,6 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/esphome
+# Optional basic auth for the dashboard.
+USERNAME=
+PASSWORD=
+# Runs on host networking — the UI is on port 6052 of the host itself.
diff --git a/backend/templates/esphome/compose.yaml b/backend/templates/esphome/compose.yaml
new file mode 100644
index 0000000..e22b288
--- /dev/null
+++ b/backend/templates/esphome/compose.yaml
@@ -0,0 +1,15 @@
+services:
+ esphome:
+ image: ghcr.io/esphome/esphome:latest
+ container_name: esphome
+ restart: unless-stopped
+ # Host networking is needed for OTA discovery of your devices.
+ network_mode: host
+ privileged: true
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ - USERNAME=${USERNAME:-}
+ - PASSWORD=${PASSWORD:-}
+ volumes:
+ - ${DATA_PATH:-/srv/esphome}:/config
+ - /etc/localtime:/etc/localtime:ro
diff --git a/backend/templates/esphome/template.json b/backend/templates/esphome/template.json
new file mode 100644
index 0000000..efd72c9
--- /dev/null
+++ b/backend/templates/esphome/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "ESPHome",
+ "description": "Build and flash firmware for ESP32/ESP8266 sensors straight from the browser.",
+ "tags": [
+ "home-automation",
+ "iot",
+ "dev"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/filebrowser/.env.example b/backend/templates/filebrowser/.env.example
new file mode 100644
index 0000000..15864b1
--- /dev/null
+++ b/backend/templates/filebrowser/.env.example
@@ -0,0 +1,5 @@
+PUID=1000
+PGID=1000
+DATA_PATH=/srv/filebrowser
+FILES_PATH=/srv
+HTTP_PORT=8088
diff --git a/backend/templates/filebrowser/compose.yaml b/backend/templates/filebrowser/compose.yaml
new file mode 100644
index 0000000..9528b35
--- /dev/null
+++ b/backend/templates/filebrowser/compose.yaml
@@ -0,0 +1,16 @@
+services:
+ filebrowser:
+ # Pinned to the v2 line — v3 changes the on-disk layout.
+ image: filebrowser/filebrowser:v2
+ container_name: filebrowser
+ restart: unless-stopped
+ user: "${PUID:-1000}:${PGID:-1000}"
+ environment:
+ - FB_ROOT=/srv
+ - FB_DATABASE=/database/filebrowser.db
+ - FB_PORT=80
+ ports:
+ - "${HTTP_PORT:-8088}:80"
+ volumes:
+ - ${FILES_PATH:-/srv}:/srv
+ - ${DATA_PATH:-/srv/filebrowser}/database:/database
diff --git a/backend/templates/filebrowser/template.json b/backend/templates/filebrowser/template.json
new file mode 100644
index 0000000..e2597e6
--- /dev/null
+++ b/backend/templates/filebrowser/template.json
@@ -0,0 +1,8 @@
+{
+ "name": "File Browser",
+ "description": "Web file manager for a directory tree. Default login admin/admin — change it immediately.",
+ "tags": [
+ "files"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/firefly-iii/.env.example b/backend/templates/firefly-iii/.env.example
new file mode 100644
index 0000000..676bd87
--- /dev/null
+++ b/backend/templates/firefly-iii/.env.example
@@ -0,0 +1,7 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/firefly-iii
+HTTP_PORT=8104
+APP_URL=http://localhost:8104
+# Required, exactly 32 characters. Generate with: openssl rand -hex 16
+APP_KEY=
+DB_PASSWORD=change-me
diff --git a/backend/templates/firefly-iii/compose.yaml b/backend/templates/firefly-iii/compose.yaml
new file mode 100644
index 0000000..8877fc9
--- /dev/null
+++ b/backend/templates/firefly-iii/compose.yaml
@@ -0,0 +1,37 @@
+services:
+ app:
+ image: fireflyiii/core:latest
+ container_name: firefly-iii
+ restart: unless-stopped
+ depends_on:
+ - db
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ - APP_ENV=production
+ - APP_URL=${APP_URL:-http://localhost:8095}
+ # Required, exactly 32 characters. Generate with: openssl rand -hex 16
+ - APP_KEY=${APP_KEY:?app key required, exactly 32 characters}
+ - TRUSTED_PROXIES=**
+ - DB_CONNECTION=mysql
+ - DB_HOST=db
+ - DB_PORT=3306
+ - DB_DATABASE=firefly
+ - DB_USERNAME=firefly
+ - DB_PASSWORD=${DB_PASSWORD:-firefly}
+ ports:
+ - "${HTTP_PORT:-8104}:8080"
+ volumes:
+ - ${DATA_PATH:-/srv/firefly-iii}/upload:/var/www/html/storage/upload
+
+ db:
+ image: mariadb:11
+ container_name: firefly-iii-db
+ restart: unless-stopped
+ environment:
+ - MARIADB_AUTO_UPGRADE=1
+ - MYSQL_DATABASE=firefly
+ - MYSQL_USER=firefly
+ - MYSQL_PASSWORD=${DB_PASSWORD:-firefly}
+ - MYSQL_RANDOM_ROOT_PASSWORD=yes
+ volumes:
+ - ${DATA_PATH:-/srv/firefly-iii}/db:/var/lib/mysql
diff --git a/backend/templates/firefly-iii/template.json b/backend/templates/firefly-iii/template.json
new file mode 100644
index 0000000..4068ca5
--- /dev/null
+++ b/backend/templates/firefly-iii/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Firefly III",
+ "description": "Double-entry personal finance manager with budgets, rules and reports.",
+ "tags": [
+ "finance",
+ "productivity"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/forgejo/.env.example b/backend/templates/forgejo/.env.example
new file mode 100644
index 0000000..bffbaac
--- /dev/null
+++ b/backend/templates/forgejo/.env.example
@@ -0,0 +1,7 @@
+PUID=1000
+PGID=1000
+DATA_PATH=/srv/forgejo
+HTTP_PORT=3301
+SSH_PORT=2223
+ROOT_URL=http://localhost:3000/
+DOMAIN=localhost
diff --git a/backend/templates/forgejo/compose.yaml b/backend/templates/forgejo/compose.yaml
new file mode 100644
index 0000000..095d176
--- /dev/null
+++ b/backend/templates/forgejo/compose.yaml
@@ -0,0 +1,17 @@
+services:
+ forgejo:
+ image: codeberg.org/forgejo/forgejo:16
+ container_name: forgejo
+ restart: unless-stopped
+ environment:
+ - USER_UID=${PUID:-1000}
+ - USER_GID=${PGID:-1000}
+ - FORGEJO__server__ROOT_URL=${ROOT_URL:-http://localhost:3000/}
+ - FORGEJO__server__DOMAIN=${DOMAIN:-localhost}
+ ports:
+ - "${HTTP_PORT:-3301}:3000"
+ - "${SSH_PORT:-2223}:22"
+ volumes:
+ - ${DATA_PATH:-/srv/forgejo}:/data
+ - /etc/timezone:/etc/timezone:ro
+ - /etc/localtime:/etc/localtime:ro
diff --git a/backend/templates/forgejo/template.json b/backend/templates/forgejo/template.json
new file mode 100644
index 0000000..d381d66
--- /dev/null
+++ b/backend/templates/forgejo/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Forgejo",
+ "description": "Community-run Git forge with CI (Forgejo Actions) — the Gitea fork.",
+ "tags": [
+ "git",
+ "dev"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/freshrss/.env.example b/backend/templates/freshrss/.env.example
new file mode 100644
index 0000000..adffcd0
--- /dev/null
+++ b/backend/templates/freshrss/.env.example
@@ -0,0 +1,4 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/freshrss
+HTTP_PORT=8093
+CRON_MIN=*/20
diff --git a/backend/templates/freshrss/compose.yaml b/backend/templates/freshrss/compose.yaml
new file mode 100644
index 0000000..8e81018
--- /dev/null
+++ b/backend/templates/freshrss/compose.yaml
@@ -0,0 +1,13 @@
+services:
+ freshrss:
+ image: freshrss/freshrss:latest
+ container_name: freshrss
+ restart: unless-stopped
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ - CRON_MIN=${CRON_MIN:-*/20}
+ ports:
+ - "${HTTP_PORT:-8093}:80"
+ volumes:
+ - ${DATA_PATH:-/srv/freshrss}/data:/var/www/FreshRSS/data
+ - ${DATA_PATH:-/srv/freshrss}/extensions:/var/www/FreshRSS/extensions
diff --git a/backend/templates/freshrss/template.json b/backend/templates/freshrss/template.json
new file mode 100644
index 0000000..d027205
--- /dev/null
+++ b/backend/templates/freshrss/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "FreshRSS",
+ "description": "Self-hosted RSS aggregator with an API for mobile readers, SQLite by default.",
+ "tags": [
+ "rss",
+ "productivity"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/frigate/.env.example b/backend/templates/frigate/.env.example
new file mode 100644
index 0000000..53e38a5
--- /dev/null
+++ b/backend/templates/frigate/.env.example
@@ -0,0 +1,8 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/frigate
+MEDIA_PATH=/srv/frigate/media
+HTTP_PORT=8971
+RTSP_PORT=8554
+WEBRTC_PORT=8555
+SHM_SIZE=256mb
+RTSP_PASSWORD=change-me
diff --git a/backend/templates/frigate/compose.yaml b/backend/templates/frigate/compose.yaml
new file mode 100644
index 0000000..6d88267
--- /dev/null
+++ b/backend/templates/frigate/compose.yaml
@@ -0,0 +1,32 @@
+services:
+ frigate:
+ image: ghcr.io/blakeblackshear/frigate:stable
+ container_name: frigate
+ restart: unless-stopped
+ privileged: true
+ shm_size: ${SHM_SIZE:-256mb}
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ - FRIGATE_RTSP_PASSWORD=${RTSP_PASSWORD:-change-me}
+ ports:
+ - "${HTTP_PORT:-8971}:8971"
+ - "${RTSP_PORT:-8554}:8554"
+ - "${WEBRTC_PORT:-8555}:8555/tcp"
+ - "${WEBRTC_PORT:-8555}:8555/udp"
+ volumes:
+ # The config lives with the stack so you can edit it here; the directory
+ # underneath holds Frigate's database and model cache.
+ - ${DATA_PATH:-/srv/frigate}/config:/config
+ - ./config.yml:/config/config.yml
+ - ${MEDIA_PATH:-/srv/frigate/media}:/media/frigate
+ - /etc/localtime:/etc/localtime:ro
+ - type: tmpfs
+ target: /tmp/cache
+ tmpfs:
+ size: 1000000000
+ # Uncomment what your host actually has:
+ # devices:
+ # # Google Coral USB accelerator
+ # - /dev/bus/usb:/dev/bus/usb
+ # # Intel iGPU for hardware decoding
+ # - /dev/dri/renderD128:/dev/dri/renderD128
diff --git a/backend/templates/frigate/config.yml b/backend/templates/frigate/config.yml
new file mode 100644
index 0000000..0619e38
--- /dev/null
+++ b/backend/templates/frigate/config.yml
@@ -0,0 +1,38 @@
+mqtt:
+ enabled: false
+
+detectors:
+ cpu1:
+ type: cpu
+ # Swap for a Coral TPU once you have one:
+ # coral:
+ # type: edgetpu
+ # device: usb
+
+cameras:
+ front_door:
+ ffmpeg:
+ inputs:
+ - path: rtsp://user:{FRIGATE_RTSP_PASSWORD}@192.168.1.50:554/stream1
+ roles:
+ - detect
+ - record
+ detect:
+ width: 1280
+ height: 720
+ fps: 5
+ objects:
+ track:
+ - person
+ - car
+
+record:
+ enabled: true
+ retain:
+ days: 7
+ mode: motion
+
+snapshots:
+ enabled: true
+ retain:
+ default: 14
diff --git a/backend/templates/frigate/template.json b/backend/templates/frigate/template.json
new file mode 100644
index 0000000..cdff524
--- /dev/null
+++ b/backend/templates/frigate/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Frigate NVR",
+ "description": "Network video recorder with real-time object detection. Edit config.yml with your cameras before deploying.",
+ "tags": [
+ "home-automation",
+ "video",
+ "monitoring"
+ ],
+ "gpu": "Coral TPU / Intel / NVIDIA optional"
+}
diff --git a/backend/templates/gotify/.env.example b/backend/templates/gotify/.env.example
new file mode 100644
index 0000000..19ddcc6
--- /dev/null
+++ b/backend/templates/gotify/.env.example
@@ -0,0 +1,6 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/gotify
+HTTP_PORT=8098
+ADMIN_USER=admin
+# Required.
+ADMIN_PASSWORD=
diff --git a/backend/templates/gotify/compose.yaml b/backend/templates/gotify/compose.yaml
new file mode 100644
index 0000000..51189cb
--- /dev/null
+++ b/backend/templates/gotify/compose.yaml
@@ -0,0 +1,13 @@
+services:
+ gotify:
+ image: gotify/server:latest
+ container_name: gotify
+ restart: unless-stopped
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ - GOTIFY_DEFAULTUSER_NAME=${ADMIN_USER:-admin}
+ - GOTIFY_DEFAULTUSER_PASS=${ADMIN_PASSWORD:?admin password required}
+ ports:
+ - "${HTTP_PORT:-8098}:80"
+ volumes:
+ - ${DATA_PATH:-/srv/gotify}:/app/data
diff --git a/backend/templates/gotify/template.json b/backend/templates/gotify/template.json
new file mode 100644
index 0000000..ec68ccb
--- /dev/null
+++ b/backend/templates/gotify/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Gotify",
+ "description": "Simple push notification server with per-application tokens and an Android app.",
+ "tags": [
+ "notifications",
+ "monitoring"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/grafana/.env.example b/backend/templates/grafana/.env.example
new file mode 100644
index 0000000..8e6b570
--- /dev/null
+++ b/backend/templates/grafana/.env.example
@@ -0,0 +1,8 @@
+PUID=1000
+PGID=1000
+DATA_PATH=/srv/grafana
+HTTP_PORT=3030
+ROOT_URL=http://localhost:3000
+ADMIN_USER=admin
+ADMIN_PASSWORD=change-me
+INSTALL_PLUGINS=
diff --git a/backend/templates/grafana/compose.yaml b/backend/templates/grafana/compose.yaml
new file mode 100644
index 0000000..f782cfd
--- /dev/null
+++ b/backend/templates/grafana/compose.yaml
@@ -0,0 +1,15 @@
+services:
+ grafana:
+ image: grafana/grafana:latest
+ container_name: grafana
+ restart: unless-stopped
+ user: "${PUID:-1000}:${PGID:-1000}"
+ environment:
+ - GF_SECURITY_ADMIN_USER=${ADMIN_USER:-admin}
+ - GF_SECURITY_ADMIN_PASSWORD=${ADMIN_PASSWORD:-change-me}
+ - GF_SERVER_ROOT_URL=${ROOT_URL:-http://localhost:3000}
+ - GF_INSTALL_PLUGINS=${INSTALL_PLUGINS:-}
+ ports:
+ - "${HTTP_PORT:-3030}:3000"
+ volumes:
+ - ${DATA_PATH:-/srv/grafana}:/var/lib/grafana
diff --git a/backend/templates/grafana/template.json b/backend/templates/grafana/template.json
new file mode 100644
index 0000000..c330a4a
--- /dev/null
+++ b/backend/templates/grafana/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Grafana",
+ "description": "Dashboards and alerting on top of Prometheus, InfluxDB, Loki and friends.",
+ "tags": [
+ "monitoring",
+ "metrics",
+ "dashboard"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/grocy/.env.example b/backend/templates/grocy/.env.example
new file mode 100644
index 0000000..12f248d
--- /dev/null
+++ b/backend/templates/grocy/.env.example
@@ -0,0 +1,5 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+DATA_PATH=/srv/grocy
+HTTP_PORT=9283
diff --git a/backend/templates/grocy/compose.yaml b/backend/templates/grocy/compose.yaml
new file mode 100644
index 0000000..5d86f97
--- /dev/null
+++ b/backend/templates/grocy/compose.yaml
@@ -0,0 +1,13 @@
+services:
+ grocy:
+ image: lscr.io/linuxserver/grocy:latest
+ container_name: grocy
+ restart: unless-stopped
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${HTTP_PORT:-9283}:80"
+ volumes:
+ - ${DATA_PATH:-/srv/grocy}:/config
diff --git a/backend/templates/grocy/template.json b/backend/templates/grocy/template.json
new file mode 100644
index 0000000..00a1e7e
--- /dev/null
+++ b/backend/templates/grocy/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Grocy",
+ "description": "Groceries, stock, chores and household ERP with barcode scanning. First login: admin / admin.",
+ "tags": [
+ "productivity",
+ "recipes"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/heimdall/.env.example b/backend/templates/heimdall/.env.example
new file mode 100644
index 0000000..331718f
--- /dev/null
+++ b/backend/templates/heimdall/.env.example
@@ -0,0 +1,6 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+DATA_PATH=/srv/heimdall
+HTTP_PORT=8102
+HTTPS_PORT=8103
diff --git a/backend/templates/heimdall/compose.yaml b/backend/templates/heimdall/compose.yaml
new file mode 100644
index 0000000..24cfbcc
--- /dev/null
+++ b/backend/templates/heimdall/compose.yaml
@@ -0,0 +1,14 @@
+services:
+ heimdall:
+ image: lscr.io/linuxserver/heimdall:latest
+ container_name: heimdall
+ restart: unless-stopped
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${HTTP_PORT:-8102}:80"
+ - "${HTTPS_PORT:-8103}:443"
+ volumes:
+ - ${DATA_PATH:-/srv/heimdall}:/config
diff --git a/backend/templates/heimdall/template.json b/backend/templates/heimdall/template.json
new file mode 100644
index 0000000..77635ed
--- /dev/null
+++ b/backend/templates/heimdall/template.json
@@ -0,0 +1,8 @@
+{
+ "name": "Heimdall",
+ "description": "Simple application dashboard with enhanced tiles for common services.",
+ "tags": [
+ "dashboard"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/homarr/.env.example b/backend/templates/homarr/.env.example
new file mode 100644
index 0000000..4155c08
--- /dev/null
+++ b/backend/templates/homarr/.env.example
@@ -0,0 +1,4 @@
+DATA_PATH=/srv/homarr
+HTTP_PORT=7575
+# Required. Generate with: openssl rand -hex 32
+SECRET_ENCRYPTION_KEY=
diff --git a/backend/templates/homarr/compose.yaml b/backend/templates/homarr/compose.yaml
new file mode 100644
index 0000000..28b3e8c
--- /dev/null
+++ b/backend/templates/homarr/compose.yaml
@@ -0,0 +1,14 @@
+services:
+ homarr:
+ image: ghcr.io/homarr-labs/homarr:latest
+ container_name: homarr
+ restart: unless-stopped
+ environment:
+ # Required. Generate with: openssl rand -hex 32
+ - SECRET_ENCRYPTION_KEY=${SECRET_ENCRYPTION_KEY:?encryption key required}
+ ports:
+ - "${HTTP_PORT:-7575}:7575"
+ volumes:
+ - ${DATA_PATH:-/srv/homarr}:/appdata
+ # Optional: lets Homarr show container status
+ - /var/run/docker.sock:/var/run/docker.sock:ro
diff --git a/backend/templates/homarr/template.json b/backend/templates/homarr/template.json
new file mode 100644
index 0000000..2865324
--- /dev/null
+++ b/backend/templates/homarr/template.json
@@ -0,0 +1,8 @@
+{
+ "name": "Homarr",
+ "description": "Drag-and-drop dashboard with integrations for the *arr apps, Docker and more.",
+ "tags": [
+ "dashboard"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/home-assistant/.env.example b/backend/templates/home-assistant/.env.example
new file mode 100644
index 0000000..a621ab9
--- /dev/null
+++ b/backend/templates/home-assistant/.env.example
@@ -0,0 +1,3 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/homeassistant
+# Runs on host networking — the UI is on port 8123 of the host itself.
diff --git a/backend/templates/home-assistant/compose.yaml b/backend/templates/home-assistant/compose.yaml
new file mode 100644
index 0000000..95d10f9
--- /dev/null
+++ b/backend/templates/home-assistant/compose.yaml
@@ -0,0 +1,17 @@
+services:
+ homeassistant:
+ image: ghcr.io/home-assistant/home-assistant:stable
+ container_name: homeassistant
+ restart: unless-stopped
+ # Host networking lets HA discover devices via mDNS, SSDP and DHCP.
+ # Swap it for a ports mapping if you only use cloud/MQTT integrations.
+ network_mode: host
+ privileged: true
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ volumes:
+ - ${DATA_PATH:-/srv/homeassistant}:/config
+ - /run/dbus:/run/dbus:ro
+ # Uncomment to pass through a Zigbee/Z-Wave USB stick:
+ # devices:
+ # - /dev/ttyUSB0:/dev/ttyUSB0
diff --git a/backend/templates/home-assistant/template.json b/backend/templates/home-assistant/template.json
new file mode 100644
index 0000000..cec95d0
--- /dev/null
+++ b/backend/templates/home-assistant/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Home Assistant",
+ "description": "Home automation hub with thousands of integrations. Runs on host networking for device discovery.",
+ "tags": [
+ "home-automation",
+ "iot"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/homepage/.env.example b/backend/templates/homepage/.env.example
new file mode 100644
index 0000000..afb3da6
--- /dev/null
+++ b/backend/templates/homepage/.env.example
@@ -0,0 +1,5 @@
+PUID=1000
+PGID=1000
+DATA_PATH=/srv/homepage
+HTTP_PORT=3005
+ALLOWED_HOSTS=*
diff --git a/backend/templates/homepage/compose.yaml b/backend/templates/homepage/compose.yaml
new file mode 100644
index 0000000..e9fb7e5
--- /dev/null
+++ b/backend/templates/homepage/compose.yaml
@@ -0,0 +1,15 @@
+services:
+ homepage:
+ image: ghcr.io/gethomepage/homepage:latest
+ container_name: homepage
+ restart: unless-stopped
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ # Comma-separated hostnames the UI may be reached under, or "*" to allow all.
+ - HOMEPAGE_ALLOWED_HOSTS=${ALLOWED_HOSTS:-*}
+ ports:
+ - "${HTTP_PORT:-3005}:3000"
+ volumes:
+ - ${DATA_PATH:-/srv/homepage}:/app/config
+ - /var/run/docker.sock:/var/run/docker.sock:ro
diff --git a/backend/templates/homepage/template.json b/backend/templates/homepage/template.json
new file mode 100644
index 0000000..7023a9c
--- /dev/null
+++ b/backend/templates/homepage/template.json
@@ -0,0 +1,8 @@
+{
+ "name": "Homepage",
+ "description": "YAML-configured start page with live widgets for dozens of self-hosted services.",
+ "tags": [
+ "dashboard"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/immich/.env.example b/backend/templates/immich/.env.example
new file mode 100644
index 0000000..5e49a8f
--- /dev/null
+++ b/backend/templates/immich/.env.example
@@ -0,0 +1,11 @@
+TZ=Europe/Berlin
+IMMICH_VERSION=release
+DATA_PATH=/srv/immich
+# Where uploaded photos and videos live. Network shares are fine here.
+UPLOAD_LOCATION=/srv/immich/library
+# The database must sit on local storage — never on NFS/SMB.
+DB_DATA_LOCATION=/srv/immich/postgres
+DB_USERNAME=postgres
+DB_PASSWORD=change-me
+DB_DATABASE_NAME=immich
+HTTP_PORT=2283
diff --git a/backend/templates/immich/compose.yaml b/backend/templates/immich/compose.yaml
new file mode 100644
index 0000000..8f7c530
--- /dev/null
+++ b/backend/templates/immich/compose.yaml
@@ -0,0 +1,51 @@
+services:
+ immich-server:
+ image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
+ container_name: immich-server
+ restart: unless-stopped
+ depends_on:
+ - redis
+ - database
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ - DB_HOSTNAME=database
+ - DB_USERNAME=${DB_USERNAME:-postgres}
+ - DB_PASSWORD=${DB_PASSWORD:-postgres}
+ - DB_DATABASE_NAME=${DB_DATABASE_NAME:-immich}
+ - REDIS_HOSTNAME=redis
+ ports:
+ - "${HTTP_PORT:-2283}:2283"
+ volumes:
+ - ${UPLOAD_LOCATION:-/srv/immich/library}:/data
+ - /etc/localtime:/etc/localtime:ro
+ # Uncomment for Intel/AMD hardware transcoding:
+ # devices:
+ # - /dev/dri:/dev/dri
+
+ immich-machine-learning:
+ # Add -cuda / -openvino / -rocm to the tag for accelerated inference.
+ image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
+ container_name: immich-machine-learning
+ restart: unless-stopped
+ volumes:
+ - ${DATA_PATH:-/srv/immich}/model-cache:/cache
+
+ redis:
+ image: valkey/valkey:8-alpine
+ container_name: immich-redis
+ restart: unless-stopped
+ healthcheck:
+ test: redis-cli ping | grep -q PONG || exit 1
+
+ database:
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
+ container_name: immich-postgres
+ restart: unless-stopped
+ shm_size: 128mb
+ environment:
+ - POSTGRES_USER=${DB_USERNAME:-postgres}
+ - POSTGRES_PASSWORD=${DB_PASSWORD:-postgres}
+ - POSTGRES_DB=${DB_DATABASE_NAME:-immich}
+ - POSTGRES_INITDB_ARGS=--data-checksums
+ volumes:
+ - ${DB_DATA_LOCATION:-/srv/immich/postgres}:/var/lib/postgresql/data
diff --git a/backend/templates/immich/template.json b/backend/templates/immich/template.json
new file mode 100644
index 0000000..451ead7
--- /dev/null
+++ b/backend/templates/immich/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Immich",
+ "description": "Self-hosted photo and video backup with mobile apps and AI search.",
+ "tags": [
+ "media",
+ "photos",
+ "backup"
+ ],
+ "gpu": "NVIDIA / Intel optional"
+}
diff --git a/backend/templates/it-tools/.env.example b/backend/templates/it-tools/.env.example
new file mode 100644
index 0000000..fbe97ce
--- /dev/null
+++ b/backend/templates/it-tools/.env.example
@@ -0,0 +1 @@
+HTTP_PORT=8100
diff --git a/backend/templates/it-tools/compose.yaml b/backend/templates/it-tools/compose.yaml
new file mode 100644
index 0000000..2144b27
--- /dev/null
+++ b/backend/templates/it-tools/compose.yaml
@@ -0,0 +1,7 @@
+services:
+ it-tools:
+ image: corentinth/it-tools:latest
+ container_name: it-tools
+ restart: unless-stopped
+ ports:
+ - "${HTTP_PORT:-8100}:80"
diff --git a/backend/templates/it-tools/template.json b/backend/templates/it-tools/template.json
new file mode 100644
index 0000000..32da26d
--- /dev/null
+++ b/backend/templates/it-tools/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "IT Tools",
+ "description": "Offline collection of developer utilities — hashes, JWTs, cron parsing, converters and more.",
+ "tags": [
+ "utilities",
+ "dev"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/jellyseerr/.env.example b/backend/templates/jellyseerr/.env.example
new file mode 100644
index 0000000..5b099d4
--- /dev/null
+++ b/backend/templates/jellyseerr/.env.example
@@ -0,0 +1,3 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/jellyseerr
+HTTP_PORT=5055
diff --git a/backend/templates/jellyseerr/compose.yaml b/backend/templates/jellyseerr/compose.yaml
new file mode 100644
index 0000000..51b681a
--- /dev/null
+++ b/backend/templates/jellyseerr/compose.yaml
@@ -0,0 +1,12 @@
+services:
+ jellyseerr:
+ image: fallenbagel/jellyseerr:latest
+ container_name: jellyseerr
+ restart: unless-stopped
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ - LOG_LEVEL=info
+ ports:
+ - "${HTTP_PORT:-5055}:5055"
+ volumes:
+ - ${DATA_PATH:-/srv/jellyseerr}:/app/config
diff --git a/backend/templates/jellyseerr/template.json b/backend/templates/jellyseerr/template.json
new file mode 100644
index 0000000..100d196
--- /dev/null
+++ b/backend/templates/jellyseerr/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Jellyseerr",
+ "description": "Request portal for Jellyfin, Emby and Plex that feeds Sonarr and Radarr.",
+ "tags": [
+ "media",
+ "automation",
+ "arr"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/kavita/.env.example b/backend/templates/kavita/.env.example
new file mode 100644
index 0000000..337ee76
--- /dev/null
+++ b/backend/templates/kavita/.env.example
@@ -0,0 +1,4 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/kavita
+LIBRARY_PATH=/srv/media/books
+HTTP_PORT=5010
diff --git a/backend/templates/kavita/compose.yaml b/backend/templates/kavita/compose.yaml
new file mode 100644
index 0000000..c53ba62
--- /dev/null
+++ b/backend/templates/kavita/compose.yaml
@@ -0,0 +1,12 @@
+services:
+ kavita:
+ image: jvmilazz0/kavita:latest
+ container_name: kavita
+ restart: unless-stopped
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${HTTP_PORT:-5010}:5000"
+ volumes:
+ - ${DATA_PATH:-/srv/kavita}:/kavita/config
+ - ${LIBRARY_PATH:-/srv/media/books}:/books
diff --git a/backend/templates/kavita/template.json b/backend/templates/kavita/template.json
new file mode 100644
index 0000000..fa1a63b
--- /dev/null
+++ b/backend/templates/kavita/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Kavita",
+ "description": "Fast reading server for ebooks, manga and comics.",
+ "tags": [
+ "media",
+ "books"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/komga/.env.example b/backend/templates/komga/.env.example
new file mode 100644
index 0000000..ea2c2b4
--- /dev/null
+++ b/backend/templates/komga/.env.example
@@ -0,0 +1,6 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+DATA_PATH=/srv/komga
+COMICS_PATH=/srv/media/comics
+HTTP_PORT=25600
diff --git a/backend/templates/komga/compose.yaml b/backend/templates/komga/compose.yaml
new file mode 100644
index 0000000..2600598
--- /dev/null
+++ b/backend/templates/komga/compose.yaml
@@ -0,0 +1,13 @@
+services:
+ komga:
+ image: gotson/komga:latest
+ container_name: komga
+ restart: unless-stopped
+ user: "${PUID:-1000}:${PGID:-1000}"
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${HTTP_PORT:-25600}:25600"
+ volumes:
+ - ${DATA_PATH:-/srv/komga}:/config
+ - ${COMICS_PATH:-/srv/media/comics}:/data
diff --git a/backend/templates/komga/template.json b/backend/templates/komga/template.json
new file mode 100644
index 0000000..2925989
--- /dev/null
+++ b/backend/templates/komga/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Komga",
+ "description": "Comic, manga and BD server with a web reader and OPDS feed.",
+ "tags": [
+ "media",
+ "books"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/lidarr/.env.example b/backend/templates/lidarr/.env.example
new file mode 100644
index 0000000..6b46ef2
--- /dev/null
+++ b/backend/templates/lidarr/.env.example
@@ -0,0 +1,7 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+DATA_PATH=/srv/lidarr
+MEDIA_PATH=/srv/media
+DOWNLOADS_PATH=/srv/downloads
+HTTP_PORT=8686
diff --git a/backend/templates/lidarr/compose.yaml b/backend/templates/lidarr/compose.yaml
new file mode 100644
index 0000000..8f4551a
--- /dev/null
+++ b/backend/templates/lidarr/compose.yaml
@@ -0,0 +1,15 @@
+services:
+ lidarr:
+ image: lscr.io/linuxserver/lidarr:latest
+ container_name: lidarr
+ restart: unless-stopped
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${HTTP_PORT:-8686}:8686"
+ volumes:
+ - ${DATA_PATH:-/srv/lidarr}:/config
+ - ${MEDIA_PATH:-/srv/media}:/media
+ - ${DOWNLOADS_PATH:-/srv/downloads}:/downloads
diff --git a/backend/templates/lidarr/template.json b/backend/templates/lidarr/template.json
new file mode 100644
index 0000000..7813a96
--- /dev/null
+++ b/backend/templates/lidarr/template.json
@@ -0,0 +1,11 @@
+{
+ "name": "Lidarr",
+ "description": "Automates finding, downloading and organising music.",
+ "tags": [
+ "media",
+ "music",
+ "automation",
+ "arr"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/linkwarden/.env.example b/backend/templates/linkwarden/.env.example
new file mode 100644
index 0000000..8e13a49
--- /dev/null
+++ b/backend/templates/linkwarden/.env.example
@@ -0,0 +1,7 @@
+DATA_PATH=/srv/linkwarden
+HTTP_PORT=3010
+NEXTAUTH_URL=http://localhost:3010
+POSTGRES_PASSWORD=change-me
+# Both required. Generate each with: openssl rand -hex 32
+NEXTAUTH_SECRET=
+MEILI_MASTER_KEY=
diff --git a/backend/templates/linkwarden/compose.yaml b/backend/templates/linkwarden/compose.yaml
new file mode 100644
index 0000000..6adecf2
--- /dev/null
+++ b/backend/templates/linkwarden/compose.yaml
@@ -0,0 +1,38 @@
+services:
+ linkwarden:
+ image: ghcr.io/linkwarden/linkwarden:latest
+ container_name: linkwarden
+ restart: unless-stopped
+ depends_on:
+ - postgres
+ - meilisearch
+ environment:
+ - DATABASE_URL=postgresql://linkwarden:${POSTGRES_PASSWORD:-linkwarden}@postgres:5432/linkwarden
+ - NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3010}/api/v1/auth
+ - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:?auth secret required}
+ - MEILI_HOST=http://meilisearch:7700
+ - MEILI_MASTER_KEY=${MEILI_MASTER_KEY:?meilisearch key required}
+ ports:
+ - "${HTTP_PORT:-3010}:3000"
+ volumes:
+ - ${DATA_PATH:-/srv/linkwarden}/data:/data/data
+
+ postgres:
+ image: postgres:16-alpine
+ container_name: linkwarden-db
+ restart: unless-stopped
+ environment:
+ - POSTGRES_DB=linkwarden
+ - POSTGRES_USER=linkwarden
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-linkwarden}
+ volumes:
+ - ${DATA_PATH:-/srv/linkwarden}/pgdata:/var/lib/postgresql/data
+
+ meilisearch:
+ image: getmeili/meilisearch:v1.13
+ container_name: linkwarden-meili
+ restart: unless-stopped
+ environment:
+ - MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
+ volumes:
+ - ${DATA_PATH:-/srv/linkwarden}/meili:/meili_data
diff --git a/backend/templates/linkwarden/template.json b/backend/templates/linkwarden/template.json
new file mode 100644
index 0000000..ac805ef
--- /dev/null
+++ b/backend/templates/linkwarden/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Linkwarden",
+ "description": "Bookmark manager that archives pages as screenshots and PDFs, with full-text search.",
+ "tags": [
+ "productivity",
+ "bookmarks"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/mariadb/.env.example b/backend/templates/mariadb/.env.example
new file mode 100644
index 0000000..9a31cae
--- /dev/null
+++ b/backend/templates/mariadb/.env.example
@@ -0,0 +1,9 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/mariadb
+DB_PORT=3306
+ADMINER_PORT=8091
+MYSQL_DATABASE=app
+MYSQL_USER=app
+# Both required.
+MYSQL_PASSWORD=
+MYSQL_ROOT_PASSWORD=
diff --git a/backend/templates/mariadb/compose.yaml b/backend/templates/mariadb/compose.yaml
new file mode 100644
index 0000000..09e890b
--- /dev/null
+++ b/backend/templates/mariadb/compose.yaml
@@ -0,0 +1,28 @@
+services:
+ mariadb:
+ image: mariadb:11
+ container_name: mariadb
+ restart: unless-stopped
+ command: --transaction-isolation=READ-COMMITTED
+ environment:
+ - MARIADB_AUTO_UPGRADE=1
+ - MARIADB_DATABASE=${MYSQL_DATABASE:-app}
+ - MARIADB_USER=${MYSQL_USER:-app}
+ - MARIADB_PASSWORD=${MYSQL_PASSWORD:?database password required}
+ - MARIADB_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:?root password required}
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${DB_PORT:-3306}:3306"
+ volumes:
+ - ${DATA_PATH:-/srv/mariadb}:/var/lib/mysql
+
+ adminer:
+ image: adminer:latest
+ container_name: mariadb-adminer
+ restart: unless-stopped
+ depends_on:
+ - mariadb
+ environment:
+ - ADMINER_DEFAULT_SERVER=mariadb
+ ports:
+ - "${ADMINER_PORT:-8091}:8080"
diff --git a/backend/templates/mariadb/template.json b/backend/templates/mariadb/template.json
new file mode 100644
index 0000000..38fcef9
--- /dev/null
+++ b/backend/templates/mariadb/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "MariaDB + Adminer",
+ "description": "Standalone MySQL-compatible database for your own apps, with Adminer for browsing it.",
+ "tags": [
+ "database",
+ "dev"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/mealie/.env.example b/backend/templates/mealie/.env.example
new file mode 100644
index 0000000..43054d6
--- /dev/null
+++ b/backend/templates/mealie/.env.example
@@ -0,0 +1,6 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+DATA_PATH=/srv/mealie
+HTTP_PORT=9925
+BASE_URL=http://localhost:9925
diff --git a/backend/templates/mealie/compose.yaml b/backend/templates/mealie/compose.yaml
new file mode 100644
index 0000000..ce908d3
--- /dev/null
+++ b/backend/templates/mealie/compose.yaml
@@ -0,0 +1,19 @@
+services:
+ mealie:
+ image: ghcr.io/mealie-recipes/mealie:latest
+ container_name: mealie
+ restart: unless-stopped
+ deploy:
+ resources:
+ limits:
+ memory: 1G
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ - TZ=${TZ:-Europe/Berlin}
+ - BASE_URL=${BASE_URL:-http://localhost:9925}
+ - ALLOW_SIGNUP=false
+ ports:
+ - "${HTTP_PORT:-9925}:9000"
+ volumes:
+ - ${DATA_PATH:-/srv/mealie}:/app/data
diff --git a/backend/templates/mealie/template.json b/backend/templates/mealie/template.json
new file mode 100644
index 0000000..fd4ea71
--- /dev/null
+++ b/backend/templates/mealie/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Mealie",
+ "description": "Recipe manager and meal planner that imports recipes from any URL. First login: changeme@example.com / MyPassword.",
+ "tags": [
+ "recipes",
+ "productivity"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/memos/.env.example b/backend/templates/memos/.env.example
new file mode 100644
index 0000000..80cb2db
--- /dev/null
+++ b/backend/templates/memos/.env.example
@@ -0,0 +1,3 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/memos
+HTTP_PORT=5230
diff --git a/backend/templates/memos/compose.yaml b/backend/templates/memos/compose.yaml
new file mode 100644
index 0000000..aaf8187
--- /dev/null
+++ b/backend/templates/memos/compose.yaml
@@ -0,0 +1,11 @@
+services:
+ memos:
+ image: neosmemo/memos:stable
+ container_name: memos
+ restart: unless-stopped
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${HTTP_PORT:-5230}:5230"
+ volumes:
+ - ${DATA_PATH:-/srv/memos}:/var/opt/memos
diff --git a/backend/templates/memos/template.json b/backend/templates/memos/template.json
new file mode 100644
index 0000000..e9c63bf
--- /dev/null
+++ b/backend/templates/memos/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Memos",
+ "description": "Lightweight, Markdown-first note taking — a self-hosted take on microblogging your thoughts.",
+ "tags": [
+ "notes",
+ "productivity"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/minecraft-server/.env.example b/backend/templates/minecraft-server/.env.example
new file mode 100644
index 0000000..1319733
--- /dev/null
+++ b/backend/templates/minecraft-server/.env.example
@@ -0,0 +1,11 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/minecraft
+GAME_PORT=25565
+# VANILLA, PAPER, FABRIC, FORGE, ...
+TYPE=PAPER
+VERSION=LATEST
+MEMORY=4G
+DIFFICULTY=normal
+MOTD=Homelab Minecraft
+# Comma-separated player names granted operator rights.
+OPS=
diff --git a/backend/templates/minecraft-server/compose.yaml b/backend/templates/minecraft-server/compose.yaml
new file mode 100644
index 0000000..50217d4
--- /dev/null
+++ b/backend/templates/minecraft-server/compose.yaml
@@ -0,0 +1,20 @@
+services:
+ minecraft:
+ image: itzg/minecraft-server:latest
+ container_name: minecraft
+ restart: unless-stopped
+ stdin_open: true
+ tty: true
+ environment:
+ - EULA=TRUE
+ - TZ=${TZ:-Europe/Berlin}
+ - TYPE=${TYPE:-PAPER}
+ - VERSION=${VERSION:-LATEST}
+ - MEMORY=${MEMORY:-4G}
+ - DIFFICULTY=${DIFFICULTY:-normal}
+ - MOTD=${MOTD:-Homelab Minecraft}
+ - OPS=${OPS:-}
+ ports:
+ - "${GAME_PORT:-25565}:25565"
+ volumes:
+ - ${DATA_PATH:-/srv/minecraft}:/data
diff --git a/backend/templates/minecraft-server/template.json b/backend/templates/minecraft-server/template.json
new file mode 100644
index 0000000..45cfb4d
--- /dev/null
+++ b/backend/templates/minecraft-server/template.json
@@ -0,0 +1,8 @@
+{
+ "name": "Minecraft Server",
+ "description": "Java Edition server (Paper, Fabric, Forge or vanilla) with automatic version downloads.",
+ "tags": [
+ "games"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/miniflux/.env.example b/backend/templates/miniflux/.env.example
new file mode 100644
index 0000000..d96a52a
--- /dev/null
+++ b/backend/templates/miniflux/.env.example
@@ -0,0 +1,6 @@
+DATA_PATH=/srv/miniflux
+HTTP_PORT=8094
+DB_PASSWORD=change-me
+ADMIN_USER=admin
+# Required, at least 6 characters.
+ADMIN_PASSWORD=
diff --git a/backend/templates/miniflux/compose.yaml b/backend/templates/miniflux/compose.yaml
new file mode 100644
index 0000000..307b938
--- /dev/null
+++ b/backend/templates/miniflux/compose.yaml
@@ -0,0 +1,32 @@
+services:
+ miniflux:
+ image: miniflux/miniflux:latest
+ container_name: miniflux
+ restart: unless-stopped
+ depends_on:
+ db:
+ condition: service_healthy
+ environment:
+ - DATABASE_URL=postgres://miniflux:${DB_PASSWORD:-miniflux}@db/miniflux?sslmode=disable
+ - RUN_MIGRATIONS=1
+ - CREATE_ADMIN=1
+ - ADMIN_USERNAME=${ADMIN_USER:-admin}
+ - ADMIN_PASSWORD=${ADMIN_PASSWORD:?admin password required, min 6 chars}
+ ports:
+ - "${HTTP_PORT:-8094}:8080"
+
+ db:
+ image: postgres:16-alpine
+ container_name: miniflux-db
+ restart: unless-stopped
+ environment:
+ - POSTGRES_DB=miniflux
+ - POSTGRES_USER=miniflux
+ - POSTGRES_PASSWORD=${DB_PASSWORD:-miniflux}
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U miniflux"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ volumes:
+ - ${DATA_PATH:-/srv/miniflux}/db:/var/lib/postgresql/data
diff --git a/backend/templates/miniflux/template.json b/backend/templates/miniflux/template.json
new file mode 100644
index 0000000..720bb99
--- /dev/null
+++ b/backend/templates/miniflux/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Miniflux",
+ "description": "Minimalist, very fast RSS reader with a Google Reader compatible API.",
+ "tags": [
+ "rss",
+ "productivity"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/minio/.env.example b/backend/templates/minio/.env.example
new file mode 100644
index 0000000..3686b2f
--- /dev/null
+++ b/backend/templates/minio/.env.example
@@ -0,0 +1,7 @@
+DATA_PATH=/srv/minio
+API_PORT=9010
+CONSOLE_PORT=9011
+CONSOLE_URL=http://localhost:9011
+ROOT_USER=minioadmin
+# Required, at least 8 characters.
+ROOT_PASSWORD=
diff --git a/backend/templates/minio/compose.yaml b/backend/templates/minio/compose.yaml
new file mode 100644
index 0000000..0d4abae
--- /dev/null
+++ b/backend/templates/minio/compose.yaml
@@ -0,0 +1,15 @@
+services:
+ minio:
+ image: minio/minio:latest
+ container_name: minio
+ restart: unless-stopped
+ command: server /data --console-address ":9001"
+ environment:
+ - MINIO_ROOT_USER=${ROOT_USER:-minioadmin}
+ - MINIO_ROOT_PASSWORD=${ROOT_PASSWORD:?root password required}
+ - MINIO_BROWSER_REDIRECT_URL=${CONSOLE_URL:-http://localhost:9011}
+ ports:
+ - "${API_PORT:-9010}:9000"
+ - "${CONSOLE_PORT:-9011}:9001"
+ volumes:
+ - ${DATA_PATH:-/srv/minio}:/data
diff --git a/backend/templates/minio/template.json b/backend/templates/minio/template.json
new file mode 100644
index 0000000..8a1caf3
--- /dev/null
+++ b/backend/templates/minio/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "MinIO",
+ "description": "S3-compatible object storage — a handy backup target for Restic, Duplicati and friends.",
+ "tags": [
+ "storage",
+ "backup",
+ "files"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/mosquitto/.env.example b/backend/templates/mosquitto/.env.example
new file mode 100644
index 0000000..712db9f
--- /dev/null
+++ b/backend/templates/mosquitto/.env.example
@@ -0,0 +1,3 @@
+DATA_PATH=/srv/mosquitto
+MQTT_PORT=1883
+WS_PORT=9001
diff --git a/backend/templates/mosquitto/compose.yaml b/backend/templates/mosquitto/compose.yaml
new file mode 100644
index 0000000..c2b97d7
--- /dev/null
+++ b/backend/templates/mosquitto/compose.yaml
@@ -0,0 +1,12 @@
+services:
+ mosquitto:
+ image: eclipse-mosquitto:2
+ container_name: mosquitto
+ restart: unless-stopped
+ ports:
+ - "${MQTT_PORT:-1883}:1883"
+ - "${WS_PORT:-9001}:9001"
+ volumes:
+ - ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
+ - ${DATA_PATH:-/srv/mosquitto}/data:/mosquitto/data
+ - ${DATA_PATH:-/srv/mosquitto}/log:/mosquitto/log
diff --git a/backend/templates/mosquitto/mosquitto.conf b/backend/templates/mosquitto/mosquitto.conf
new file mode 100644
index 0000000..ac66092
--- /dev/null
+++ b/backend/templates/mosquitto/mosquitto.conf
@@ -0,0 +1,13 @@
+persistence true
+persistence_location /mosquitto/data/
+log_dest stdout
+
+listener 1883
+listener 9001
+protocol websockets
+
+# Anonymous access is fine on a trusted LAN. To require credentials:
+# 1. set allow_anonymous false
+# 2. add "password_file /mosquitto/config/passwd"
+# 3. docker compose exec mosquitto mosquitto_passwd -c /mosquitto/config/passwd
+allow_anonymous true
diff --git a/backend/templates/mosquitto/template.json b/backend/templates/mosquitto/template.json
new file mode 100644
index 0000000..0d11966
--- /dev/null
+++ b/backend/templates/mosquitto/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Mosquitto",
+ "description": "MQTT broker — the message bus behind Zigbee2MQTT, ESPHome and Home Assistant.",
+ "tags": [
+ "home-automation",
+ "iot",
+ "mqtt"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/n8n/.env.example b/backend/templates/n8n/.env.example
new file mode 100644
index 0000000..6b3561f
--- /dev/null
+++ b/backend/templates/n8n/.env.example
@@ -0,0 +1,8 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/n8n
+HTTP_PORT=5678
+N8N_HOST=localhost
+N8N_PROTOCOL=http
+WEBHOOK_URL=http://localhost:5678/
+# Required. Generate with: openssl rand -hex 32
+N8N_ENCRYPTION_KEY=
diff --git a/backend/templates/n8n/compose.yaml b/backend/templates/n8n/compose.yaml
new file mode 100644
index 0000000..bd2df6c
--- /dev/null
+++ b/backend/templates/n8n/compose.yaml
@@ -0,0 +1,19 @@
+services:
+ n8n:
+ image: n8nio/n8n:latest
+ container_name: n8n
+ restart: unless-stopped
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ - GENERIC_TIMEZONE=${TZ:-Europe/Berlin}
+ - N8N_HOST=${N8N_HOST:-localhost}
+ - N8N_PORT=5678
+ - N8N_PROTOCOL=${N8N_PROTOCOL:-http}
+ - WEBHOOK_URL=${WEBHOOK_URL:-http://localhost:5678/}
+ # Required. Generate with: openssl rand -hex 32
+ - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY:?encryption key required}
+ - N8N_RUNNERS_ENABLED=true
+ ports:
+ - "${HTTP_PORT:-5678}:5678"
+ volumes:
+ - ${DATA_PATH:-/srv/n8n}:/home/node/.n8n
diff --git a/backend/templates/n8n/template.json b/backend/templates/n8n/template.json
new file mode 100644
index 0000000..b382854
--- /dev/null
+++ b/backend/templates/n8n/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "n8n",
+ "description": "Workflow automation with 400+ integrations — the self-hosted answer to Zapier.",
+ "tags": [
+ "automation",
+ "dev",
+ "productivity"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/navidrome/.env.example b/backend/templates/navidrome/.env.example
new file mode 100644
index 0000000..1aee1dd
--- /dev/null
+++ b/backend/templates/navidrome/.env.example
@@ -0,0 +1,5 @@
+PUID=1000
+PGID=1000
+DATA_PATH=/srv/navidrome
+MUSIC_PATH=/srv/media/music
+HTTP_PORT=4533
diff --git a/backend/templates/navidrome/compose.yaml b/backend/templates/navidrome/compose.yaml
new file mode 100644
index 0000000..39e8126
--- /dev/null
+++ b/backend/templates/navidrome/compose.yaml
@@ -0,0 +1,15 @@
+services:
+ navidrome:
+ image: deluan/navidrome:latest
+ container_name: navidrome
+ restart: unless-stopped
+ user: "${PUID:-1000}:${PGID:-1000}"
+ environment:
+ - ND_LOGLEVEL=info
+ - ND_SCANSCHEDULE=1h
+ - ND_SESSIONTIMEOUT=24h
+ ports:
+ - "${HTTP_PORT:-4533}:4533"
+ volumes:
+ - ${DATA_PATH:-/srv/navidrome}:/data
+ - ${MUSIC_PATH:-/srv/media/music}:/music:ro
diff --git a/backend/templates/navidrome/template.json b/backend/templates/navidrome/template.json
new file mode 100644
index 0000000..e0b2a7d
--- /dev/null
+++ b/backend/templates/navidrome/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Navidrome",
+ "description": "Music streaming server compatible with any Subsonic client.",
+ "tags": [
+ "media",
+ "music",
+ "streaming"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/netdata/.env.example b/backend/templates/netdata/.env.example
new file mode 100644
index 0000000..165b3a6
--- /dev/null
+++ b/backend/templates/netdata/.env.example
@@ -0,0 +1,3 @@
+NETDATA_HOSTNAME=homelab
+DATA_PATH=/srv/netdata
+HTTP_PORT=19999
diff --git a/backend/templates/netdata/compose.yaml b/backend/templates/netdata/compose.yaml
new file mode 100644
index 0000000..8c38f90
--- /dev/null
+++ b/backend/templates/netdata/compose.yaml
@@ -0,0 +1,26 @@
+services:
+ netdata:
+ image: netdata/netdata:latest
+ container_name: netdata
+ restart: unless-stopped
+ hostname: ${NETDATA_HOSTNAME:-homelab}
+ pid: host
+ cap_add:
+ - SYS_PTRACE
+ - SYS_ADMIN
+ security_opt:
+ - apparmor:unconfined
+ ports:
+ - "${HTTP_PORT:-19999}:19999"
+ volumes:
+ - ${DATA_PATH:-/srv/netdata}/config:/etc/netdata
+ - ${DATA_PATH:-/srv/netdata}/lib:/var/lib/netdata
+ - ${DATA_PATH:-/srv/netdata}/cache:/var/cache/netdata
+ - /etc/passwd:/host/etc/passwd:ro
+ - /etc/group:/host/etc/group:ro
+ - /etc/localtime:/etc/localtime:ro
+ - /proc:/host/proc:ro
+ - /sys:/host/sys:ro
+ - /etc/os-release:/host/etc/os-release:ro
+ - /var/log:/host/var/log:ro
+ - /var/run/docker.sock:/var/run/docker.sock:ro
diff --git a/backend/templates/netdata/template.json b/backend/templates/netdata/template.json
new file mode 100644
index 0000000..19d735b
--- /dev/null
+++ b/backend/templates/netdata/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Netdata",
+ "description": "Per-second metrics for the host and every container, with zero configuration.",
+ "tags": [
+ "monitoring",
+ "metrics"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/nextcloud/.env.example b/backend/templates/nextcloud/.env.example
new file mode 100644
index 0000000..380a012
--- /dev/null
+++ b/backend/templates/nextcloud/.env.example
@@ -0,0 +1,9 @@
+DATA_PATH=/srv/nextcloud
+FILES_PATH=/srv/nextcloud/data
+HTTP_PORT=8087
+TRUSTED_DOMAINS=localhost
+UPLOAD_LIMIT=16G
+ADMIN_USER=admin
+ADMIN_PASSWORD=change-me
+DB_PASSWORD=change-me
+DB_ROOT_PASSWORD=change-me
diff --git a/backend/templates/nextcloud/compose.yaml b/backend/templates/nextcloud/compose.yaml
new file mode 100644
index 0000000..a250b94
--- /dev/null
+++ b/backend/templates/nextcloud/compose.yaml
@@ -0,0 +1,42 @@
+services:
+ nextcloud:
+ image: nextcloud:latest
+ container_name: nextcloud
+ restart: unless-stopped
+ depends_on:
+ - db
+ - redis
+ environment:
+ - MYSQL_HOST=db
+ - MYSQL_DATABASE=nextcloud
+ - MYSQL_USER=nextcloud
+ - MYSQL_PASSWORD=${DB_PASSWORD:-nextcloud}
+ - REDIS_HOST=redis
+ - NEXTCLOUD_ADMIN_USER=${ADMIN_USER:-admin}
+ - NEXTCLOUD_ADMIN_PASSWORD=${ADMIN_PASSWORD:-change-me}
+ - NEXTCLOUD_TRUSTED_DOMAINS=${TRUSTED_DOMAINS:-localhost}
+ - PHP_UPLOAD_LIMIT=${UPLOAD_LIMIT:-16G}
+ ports:
+ - "${HTTP_PORT:-8087}:80"
+ volumes:
+ - ${DATA_PATH:-/srv/nextcloud}/html:/var/www/html
+ - ${FILES_PATH:-/srv/nextcloud/data}:/var/www/html/data
+
+ db:
+ image: mariadb:11
+ container_name: nextcloud-db
+ restart: unless-stopped
+ command: --transaction-isolation=READ-COMMITTED --log-bin=binlog --binlog-format=ROW
+ environment:
+ - MARIADB_AUTO_UPGRADE=1
+ - MYSQL_DATABASE=nextcloud
+ - MYSQL_USER=nextcloud
+ - MYSQL_PASSWORD=${DB_PASSWORD:-nextcloud}
+ - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD:-nextcloud}
+ volumes:
+ - ${DATA_PATH:-/srv/nextcloud}/db:/var/lib/mysql
+
+ redis:
+ image: redis:7-alpine
+ container_name: nextcloud-redis
+ restart: unless-stopped
diff --git a/backend/templates/nextcloud/template.json b/backend/templates/nextcloud/template.json
new file mode 100644
index 0000000..f8726d2
--- /dev/null
+++ b/backend/templates/nextcloud/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Nextcloud",
+ "description": "File sync and share with calendar, contacts and an app store, backed by MariaDB and Redis.",
+ "tags": [
+ "files",
+ "sync",
+ "productivity"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/nginx-proxy-manager/.env.example b/backend/templates/nginx-proxy-manager/.env.example
new file mode 100644
index 0000000..57cc37c
--- /dev/null
+++ b/backend/templates/nginx-proxy-manager/.env.example
@@ -0,0 +1,4 @@
+DATA_PATH=/srv/nginx-proxy-manager
+HTTP_PORT=80
+HTTPS_PORT=443
+ADMIN_PORT=81
diff --git a/backend/templates/nginx-proxy-manager/compose.yaml b/backend/templates/nginx-proxy-manager/compose.yaml
new file mode 100644
index 0000000..4618f4d
--- /dev/null
+++ b/backend/templates/nginx-proxy-manager/compose.yaml
@@ -0,0 +1,12 @@
+services:
+ npm:
+ image: jc21/nginx-proxy-manager:latest
+ container_name: nginx-proxy-manager
+ restart: unless-stopped
+ ports:
+ - "${HTTP_PORT:-80}:80"
+ - "${HTTPS_PORT:-443}:443"
+ - "${ADMIN_PORT:-81}:81"
+ volumes:
+ - ${DATA_PATH:-/srv/nginx-proxy-manager}/data:/data
+ - ${DATA_PATH:-/srv/nginx-proxy-manager}/letsencrypt:/etc/letsencrypt
diff --git a/backend/templates/nginx-proxy-manager/template.json b/backend/templates/nginx-proxy-manager/template.json
new file mode 100644
index 0000000..2273659
--- /dev/null
+++ b/backend/templates/nginx-proxy-manager/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Nginx Proxy Manager",
+ "description": "Reverse proxy with a click-through UI for hosts and Let's Encrypt certs. First login: admin@example.com / changeme.",
+ "tags": [
+ "network",
+ "proxy"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/node-red/.env.example b/backend/templates/node-red/.env.example
new file mode 100644
index 0000000..43f70e9
--- /dev/null
+++ b/backend/templates/node-red/.env.example
@@ -0,0 +1,5 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+DATA_PATH=/srv/node-red
+HTTP_PORT=1880
diff --git a/backend/templates/node-red/compose.yaml b/backend/templates/node-red/compose.yaml
new file mode 100644
index 0000000..ebaf5ce
--- /dev/null
+++ b/backend/templates/node-red/compose.yaml
@@ -0,0 +1,12 @@
+services:
+ node-red:
+ image: nodered/node-red:latest
+ container_name: node-red
+ restart: unless-stopped
+ user: "${PUID:-1000}:${PGID:-1000}"
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${HTTP_PORT:-1880}:1880"
+ volumes:
+ - ${DATA_PATH:-/srv/node-red}:/data
diff --git a/backend/templates/node-red/template.json b/backend/templates/node-red/template.json
new file mode 100644
index 0000000..316a6e8
--- /dev/null
+++ b/backend/templates/node-red/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Node-RED",
+ "description": "Flow-based automation wiring together MQTT, HTTP, Home Assistant and custom logic.",
+ "tags": [
+ "home-automation",
+ "automation",
+ "iot"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/ntfy/.env.example b/backend/templates/ntfy/.env.example
new file mode 100644
index 0000000..e796863
--- /dev/null
+++ b/backend/templates/ntfy/.env.example
@@ -0,0 +1,6 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/ntfy
+HTTP_PORT=8097
+BASE_URL=http://localhost:8097
+DEFAULT_ACCESS=deny-all
+BEHIND_PROXY=false
diff --git a/backend/templates/ntfy/compose.yaml b/backend/templates/ntfy/compose.yaml
new file mode 100644
index 0000000..ae15b5d
--- /dev/null
+++ b/backend/templates/ntfy/compose.yaml
@@ -0,0 +1,21 @@
+services:
+ ntfy:
+ image: binwiederhier/ntfy:latest
+ container_name: ntfy
+ restart: unless-stopped
+ command: serve
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ - NTFY_BASE_URL=${BASE_URL:-http://localhost:8097}
+ # Set to true only when a reverse proxy sits in front (fixes client IPs).
+ - NTFY_BEHIND_PROXY=${BEHIND_PROXY:-false}
+ - NTFY_CACHE_FILE=/var/cache/ntfy/cache.db
+ - NTFY_AUTH_FILE=/var/lib/ntfy/user.db
+ # "read-write" is an open server; use "deny-all" plus `ntfy user add` for a private one.
+ - NTFY_AUTH_DEFAULT_ACCESS=${DEFAULT_ACCESS:-deny-all}
+ - NTFY_ENABLE_LOGIN=true
+ ports:
+ - "${HTTP_PORT:-8097}:80"
+ volumes:
+ - ${DATA_PATH:-/srv/ntfy}/cache:/var/cache/ntfy
+ - ${DATA_PATH:-/srv/ntfy}/lib:/var/lib/ntfy
diff --git a/backend/templates/ntfy/template.json b/backend/templates/ntfy/template.json
new file mode 100644
index 0000000..c89813f
--- /dev/null
+++ b/backend/templates/ntfy/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "ntfy",
+ "description": "Push notifications to your phone from any script with a plain HTTP POST. Create users with: docker compose exec ntfy ntfy user add --role=admin ",
+ "tags": [
+ "notifications",
+ "monitoring"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/ollama-open-webui/.env.example b/backend/templates/ollama-open-webui/.env.example
new file mode 100644
index 0000000..5851360
--- /dev/null
+++ b/backend/templates/ollama-open-webui/.env.example
@@ -0,0 +1,5 @@
+DATA_PATH=/srv/ollama
+HTTP_PORT=3020
+OLLAMA_PORT=11434
+# Required. Generate with: openssl rand -hex 32
+WEBUI_SECRET_KEY=
diff --git a/backend/templates/ollama-open-webui/compose.yaml b/backend/templates/ollama-open-webui/compose.yaml
new file mode 100644
index 0000000..f14ff35
--- /dev/null
+++ b/backend/templates/ollama-open-webui/compose.yaml
@@ -0,0 +1,31 @@
+services:
+ ollama:
+ image: ollama/ollama:latest
+ container_name: ollama
+ restart: unless-stopped
+ ports:
+ - "${OLLAMA_PORT:-11434}:11434"
+ volumes:
+ - ${DATA_PATH:-/srv/ollama}/models:/root/.ollama
+ # Uncomment for NVIDIA GPUs (needs the NVIDIA Container Toolkit on the host):
+ # deploy:
+ # resources:
+ # reservations:
+ # devices:
+ # - driver: nvidia
+ # count: all
+ # capabilities: [gpu]
+
+ open-webui:
+ image: ghcr.io/open-webui/open-webui:main
+ container_name: open-webui
+ restart: unless-stopped
+ depends_on:
+ - ollama
+ environment:
+ - OLLAMA_BASE_URL=http://ollama:11434
+ - WEBUI_SECRET_KEY=${WEBUI_SECRET_KEY:?secret key required}
+ ports:
+ - "${HTTP_PORT:-3020}:8080"
+ volumes:
+ - ${DATA_PATH:-/srv/ollama}/open-webui:/app/backend/data
diff --git a/backend/templates/ollama-open-webui/template.json b/backend/templates/ollama-open-webui/template.json
new file mode 100644
index 0000000..7f364fa
--- /dev/null
+++ b/backend/templates/ollama-open-webui/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Ollama + Open WebUI",
+ "description": "Local LLM runtime with a ChatGPT-style web front-end. Runs on CPU; far faster with a GPU.",
+ "tags": [
+ "ai",
+ "llm"
+ ],
+ "gpu": "NVIDIA optional"
+}
diff --git a/backend/templates/photoprism/.env.example b/backend/templates/photoprism/.env.example
new file mode 100644
index 0000000..8009645
--- /dev/null
+++ b/backend/templates/photoprism/.env.example
@@ -0,0 +1,10 @@
+PUID=1000
+PGID=1000
+DATA_PATH=/srv/photoprism
+ORIGINALS_PATH=/srv/media/photos
+HTTP_PORT=2342
+SITE_URL=http://localhost:2342/
+ADMIN_USER=admin
+ADMIN_PASSWORD=change-me
+DB_PASSWORD=change-me
+DB_ROOT_PASSWORD=change-me
diff --git a/backend/templates/photoprism/compose.yaml b/backend/templates/photoprism/compose.yaml
new file mode 100644
index 0000000..e841cb9
--- /dev/null
+++ b/backend/templates/photoprism/compose.yaml
@@ -0,0 +1,41 @@
+services:
+ photoprism:
+ image: photoprism/photoprism:latest
+ container_name: photoprism
+ restart: unless-stopped
+ depends_on:
+ - mariadb
+ security_opt:
+ - seccomp:unconfined
+ - apparmor:unconfined
+ environment:
+ - PHOTOPRISM_ADMIN_USER=${ADMIN_USER:-admin}
+ - PHOTOPRISM_ADMIN_PASSWORD=${ADMIN_PASSWORD:-change-me}
+ - PHOTOPRISM_SITE_URL=${SITE_URL:-http://localhost:2342/}
+ - PHOTOPRISM_ORIGINALS_LIMIT=5000
+ - PHOTOPRISM_DATABASE_DRIVER=mysql
+ - PHOTOPRISM_DATABASE_SERVER=mariadb:3306
+ - PHOTOPRISM_DATABASE_NAME=photoprism
+ - PHOTOPRISM_DATABASE_USER=photoprism
+ - PHOTOPRISM_DATABASE_PASSWORD=${DB_PASSWORD:-photoprism}
+ - PHOTOPRISM_UID=${PUID:-1000}
+ - PHOTOPRISM_GID=${PGID:-1000}
+ ports:
+ - "${HTTP_PORT:-2342}:2342"
+ volumes:
+ - ${ORIGINALS_PATH:-/srv/media/photos}:/photoprism/originals
+ - ${DATA_PATH:-/srv/photoprism}/storage:/photoprism/storage
+
+ mariadb:
+ image: mariadb:11
+ container_name: photoprism-db
+ restart: unless-stopped
+ command: --innodb-buffer-pool-size=512M --transaction-isolation=READ-COMMITTED
+ environment:
+ - MARIADB_AUTO_UPGRADE=1
+ - MARIADB_DATABASE=photoprism
+ - MARIADB_USER=photoprism
+ - MARIADB_PASSWORD=${DB_PASSWORD:-photoprism}
+ - MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD:-photoprism}
+ volumes:
+ - ${DATA_PATH:-/srv/photoprism}/mariadb:/var/lib/mysql
diff --git a/backend/templates/photoprism/template.json b/backend/templates/photoprism/template.json
new file mode 100644
index 0000000..2b47048
--- /dev/null
+++ b/backend/templates/photoprism/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "PhotoPrism",
+ "description": "AI-powered photo library that tags and indexes your originals.",
+ "tags": [
+ "media",
+ "photos"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/pihole/.env.example b/backend/templates/pihole/.env.example
new file mode 100644
index 0000000..2b9c441
--- /dev/null
+++ b/backend/templates/pihole/.env.example
@@ -0,0 +1,5 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/pihole
+DNS_PORT=53
+HTTP_PORT=8081
+WEB_PASSWORD=change-me
diff --git a/backend/templates/pihole/compose.yaml b/backend/templates/pihole/compose.yaml
new file mode 100644
index 0000000..f983d76
--- /dev/null
+++ b/backend/templates/pihole/compose.yaml
@@ -0,0 +1,20 @@
+services:
+ pihole:
+ image: pihole/pihole:latest
+ container_name: pihole
+ restart: unless-stopped
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ - FTLCONF_webserver_api_password=${WEB_PASSWORD:-change-me}
+ - FTLCONF_dns_listeningMode=all
+ ports:
+ - "${DNS_PORT:-53}:53/tcp"
+ - "${DNS_PORT:-53}:53/udp"
+ - "${HTTP_PORT:-8081}:80/tcp"
+ volumes:
+ - ${DATA_PATH:-/srv/pihole}/etc-pihole:/etc/pihole
+ cap_add:
+ # Only required when Pi-hole also serves DHCP
+ - NET_ADMIN
+ - SYS_TIME
+ - SYS_NICE
diff --git a/backend/templates/pihole/template.json b/backend/templates/pihole/template.json
new file mode 100644
index 0000000..747d2c5
--- /dev/null
+++ b/backend/templates/pihole/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Pi-hole",
+ "description": "Network-wide DNS ad blocker. Port 53 must be free — disable systemd-resolved's stub listener first.",
+ "tags": [
+ "network",
+ "dns",
+ "ad-blocking"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/plex/.env.example b/backend/templates/plex/.env.example
new file mode 100644
index 0000000..b43a153
--- /dev/null
+++ b/backend/templates/plex/.env.example
@@ -0,0 +1,7 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+CONFIG_PATH=/srv/plex/config
+MEDIA_PATH=/srv/media
+HTTP_PORT=32400
+PLEX_CLAIM=
diff --git a/backend/templates/plex/compose.yaml b/backend/templates/plex/compose.yaml
new file mode 100644
index 0000000..524ab4a
--- /dev/null
+++ b/backend/templates/plex/compose.yaml
@@ -0,0 +1,20 @@
+services:
+ plex:
+ image: lscr.io/linuxserver/plex:latest
+ container_name: plex
+ restart: unless-stopped
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ - TZ=${TZ:-Europe/Berlin}
+ - VERSION=docker
+ # Grab a claim token at https://plex.tv/claim (valid 4 minutes)
+ - PLEX_CLAIM=${PLEX_CLAIM:-}
+ ports:
+ - "${HTTP_PORT:-32400}:32400"
+ volumes:
+ - ${CONFIG_PATH:-/srv/plex/config}:/config
+ - ${MEDIA_PATH:-/srv/media}:/media
+ # Uncomment for Intel/AMD hardware transcoding (Plex Pass required):
+ # devices:
+ # - /dev/dri:/dev/dri
diff --git a/backend/templates/plex/template.json b/backend/templates/plex/template.json
new file mode 100644
index 0000000..e19c72d
--- /dev/null
+++ b/backend/templates/plex/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Plex",
+ "description": "Media server for movies, TV and music with polished client apps.",
+ "tags": [
+ "media",
+ "streaming"
+ ],
+ "gpu": "NVIDIA / Intel optional"
+}
diff --git a/backend/templates/portainer/.env.example b/backend/templates/portainer/.env.example
new file mode 100644
index 0000000..a9e2d4f
--- /dev/null
+++ b/backend/templates/portainer/.env.example
@@ -0,0 +1,3 @@
+DATA_PATH=/srv/portainer
+HTTP_PORT=9000
+HTTPS_PORT=9443
diff --git a/backend/templates/portainer/compose.yaml b/backend/templates/portainer/compose.yaml
new file mode 100644
index 0000000..4f4eae9
--- /dev/null
+++ b/backend/templates/portainer/compose.yaml
@@ -0,0 +1,11 @@
+services:
+ portainer:
+ image: portainer/portainer-ce:latest
+ container_name: portainer
+ restart: unless-stopped
+ ports:
+ - "${HTTPS_PORT:-9443}:9443"
+ - "${HTTP_PORT:-9000}:9000"
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ - ${DATA_PATH:-/srv/portainer}:/data
diff --git a/backend/templates/portainer/template.json b/backend/templates/portainer/template.json
new file mode 100644
index 0000000..9502e72
--- /dev/null
+++ b/backend/templates/portainer/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Portainer CE",
+ "description": "Docker management UI. The admin account must be created within a few minutes of first start.",
+ "tags": [
+ "docker",
+ "dashboard"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/postgres/.env.example b/backend/templates/postgres/.env.example
new file mode 100644
index 0000000..9914925
--- /dev/null
+++ b/backend/templates/postgres/.env.example
@@ -0,0 +1,8 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/postgres
+DB_PORT=5432
+ADMINER_PORT=8089
+POSTGRES_DB=app
+POSTGRES_USER=app
+# Required.
+POSTGRES_PASSWORD=
diff --git a/backend/templates/postgres/compose.yaml b/backend/templates/postgres/compose.yaml
new file mode 100644
index 0000000..d45354a
--- /dev/null
+++ b/backend/templates/postgres/compose.yaml
@@ -0,0 +1,30 @@
+services:
+ postgres:
+ image: postgres:16-alpine
+ container_name: postgres
+ restart: unless-stopped
+ environment:
+ - POSTGRES_DB=${POSTGRES_DB:-app}
+ - POSTGRES_USER=${POSTGRES_USER:-app}
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?database password required}
+ - TZ=${TZ:-Europe/Berlin}
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-app}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ ports:
+ - "${DB_PORT:-5432}:5432"
+ volumes:
+ - ${DATA_PATH:-/srv/postgres}:/var/lib/postgresql/data
+
+ adminer:
+ image: adminer:latest
+ container_name: postgres-adminer
+ restart: unless-stopped
+ depends_on:
+ - postgres
+ environment:
+ - ADMINER_DEFAULT_SERVER=postgres
+ ports:
+ - "${ADMINER_PORT:-8089}:8080"
diff --git a/backend/templates/postgres/template.json b/backend/templates/postgres/template.json
new file mode 100644
index 0000000..63f78b6
--- /dev/null
+++ b/backend/templates/postgres/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "PostgreSQL + Adminer",
+ "description": "Standalone Postgres for your own apps, with Adminer for browsing it.",
+ "tags": [
+ "database",
+ "dev"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/prometheus/.env.example b/backend/templates/prometheus/.env.example
new file mode 100644
index 0000000..bf80799
--- /dev/null
+++ b/backend/templates/prometheus/.env.example
@@ -0,0 +1,5 @@
+PUID=1000
+PGID=1000
+DATA_PATH=/srv/prometheus
+HTTP_PORT=9090
+RETENTION=30d
diff --git a/backend/templates/prometheus/compose.yaml b/backend/templates/prometheus/compose.yaml
new file mode 100644
index 0000000..626a1ae
--- /dev/null
+++ b/backend/templates/prometheus/compose.yaml
@@ -0,0 +1,39 @@
+services:
+ prometheus:
+ image: prom/prometheus:latest
+ container_name: prometheus
+ restart: unless-stopped
+ user: "${PUID:-1000}:${PGID:-1000}"
+ command:
+ - --config.file=/etc/prometheus/prometheus.yml
+ - --storage.tsdb.path=/prometheus
+ - --storage.tsdb.retention.time=${RETENTION:-30d}
+ - --web.enable-lifecycle
+ ports:
+ - "${HTTP_PORT:-9090}:9090"
+ volumes:
+ - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ - ${DATA_PATH:-/srv/prometheus}:/prometheus
+
+ node-exporter:
+ image: prom/node-exporter:latest
+ container_name: node-exporter
+ restart: unless-stopped
+ pid: host
+ command:
+ - --path.rootfs=/host
+ volumes:
+ - /:/host:ro,rslave
+
+ cadvisor:
+ image: gcr.io/cadvisor/cadvisor:latest
+ container_name: cadvisor
+ restart: unless-stopped
+ privileged: true
+ devices:
+ - /dev/kmsg
+ volumes:
+ - /:/rootfs:ro
+ - /var/run:/var/run:ro
+ - /sys:/sys:ro
+ - /var/lib/docker/:/var/lib/docker:ro
diff --git a/backend/templates/prometheus/prometheus.yml b/backend/templates/prometheus/prometheus.yml
new file mode 100644
index 0000000..415d585
--- /dev/null
+++ b/backend/templates/prometheus/prometheus.yml
@@ -0,0 +1,16 @@
+global:
+ scrape_interval: 15s
+ evaluation_interval: 15s
+
+scrape_configs:
+ - job_name: prometheus
+ static_configs:
+ - targets: ["localhost:9090"]
+
+ - job_name: node
+ static_configs:
+ - targets: ["node-exporter:9100"]
+
+ - job_name: cadvisor
+ static_configs:
+ - targets: ["cadvisor:8080"]
diff --git a/backend/templates/prometheus/template.json b/backend/templates/prometheus/template.json
new file mode 100644
index 0000000..0ba49a5
--- /dev/null
+++ b/backend/templates/prometheus/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Prometheus",
+ "description": "Metrics database with node-exporter and cAdvisor already wired up for host and container stats.",
+ "tags": [
+ "monitoring",
+ "metrics"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/prowlarr/.env.example b/backend/templates/prowlarr/.env.example
new file mode 100644
index 0000000..68e6d16
--- /dev/null
+++ b/backend/templates/prowlarr/.env.example
@@ -0,0 +1,5 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+DATA_PATH=/srv/prowlarr
+HTTP_PORT=9696
diff --git a/backend/templates/prowlarr/compose.yaml b/backend/templates/prowlarr/compose.yaml
new file mode 100644
index 0000000..aa6772e
--- /dev/null
+++ b/backend/templates/prowlarr/compose.yaml
@@ -0,0 +1,13 @@
+services:
+ prowlarr:
+ image: lscr.io/linuxserver/prowlarr:latest
+ container_name: prowlarr
+ restart: unless-stopped
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${HTTP_PORT:-9696}:9696"
+ volumes:
+ - ${DATA_PATH:-/srv/prowlarr}:/config
diff --git a/backend/templates/prowlarr/template.json b/backend/templates/prowlarr/template.json
new file mode 100644
index 0000000..9ad645f
--- /dev/null
+++ b/backend/templates/prowlarr/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Prowlarr",
+ "description": "Indexer manager that feeds trackers and usenet indexers to the *arr apps.",
+ "tags": [
+ "media",
+ "automation",
+ "arr"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/qbittorrent/.env.example b/backend/templates/qbittorrent/.env.example
new file mode 100644
index 0000000..0323a26
--- /dev/null
+++ b/backend/templates/qbittorrent/.env.example
@@ -0,0 +1,7 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+DATA_PATH=/srv/qbittorrent
+DOWNLOADS_PATH=/srv/downloads
+HTTP_PORT=8080
+TORRENT_PORT=6881
diff --git a/backend/templates/qbittorrent/compose.yaml b/backend/templates/qbittorrent/compose.yaml
new file mode 100644
index 0000000..030c299
--- /dev/null
+++ b/backend/templates/qbittorrent/compose.yaml
@@ -0,0 +1,18 @@
+services:
+ qbittorrent:
+ image: lscr.io/linuxserver/qbittorrent:latest
+ container_name: qbittorrent
+ restart: unless-stopped
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ - TZ=${TZ:-Europe/Berlin}
+ - WEBUI_PORT=${HTTP_PORT:-8080}
+ - TORRENTING_PORT=${TORRENT_PORT:-6881}
+ ports:
+ - "${HTTP_PORT:-8080}:${HTTP_PORT:-8080}"
+ - "${TORRENT_PORT:-6881}:${TORRENT_PORT:-6881}"
+ - "${TORRENT_PORT:-6881}:${TORRENT_PORT:-6881}/udp"
+ volumes:
+ - ${DATA_PATH:-/srv/qbittorrent}:/config
+ - ${DOWNLOADS_PATH:-/srv/downloads}:/downloads
diff --git a/backend/templates/qbittorrent/template.json b/backend/templates/qbittorrent/template.json
new file mode 100644
index 0000000..8b52309
--- /dev/null
+++ b/backend/templates/qbittorrent/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "qBittorrent",
+ "description": "BitTorrent client with a web UI. The temporary admin password is printed to the container log on first start.",
+ "tags": [
+ "downloads",
+ "media"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/radarr/.env.example b/backend/templates/radarr/.env.example
new file mode 100644
index 0000000..0845023
--- /dev/null
+++ b/backend/templates/radarr/.env.example
@@ -0,0 +1,7 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+DATA_PATH=/srv/radarr
+MEDIA_PATH=/srv/media
+DOWNLOADS_PATH=/srv/downloads
+HTTP_PORT=7878
diff --git a/backend/templates/radarr/compose.yaml b/backend/templates/radarr/compose.yaml
new file mode 100644
index 0000000..ad266a3
--- /dev/null
+++ b/backend/templates/radarr/compose.yaml
@@ -0,0 +1,15 @@
+services:
+ radarr:
+ image: lscr.io/linuxserver/radarr:latest
+ container_name: radarr
+ restart: unless-stopped
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${HTTP_PORT:-7878}:7878"
+ volumes:
+ - ${DATA_PATH:-/srv/radarr}:/config
+ - ${MEDIA_PATH:-/srv/media}:/media
+ - ${DOWNLOADS_PATH:-/srv/downloads}:/downloads
diff --git a/backend/templates/radarr/template.json b/backend/templates/radarr/template.json
new file mode 100644
index 0000000..bd52e4b
--- /dev/null
+++ b/backend/templates/radarr/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Radarr",
+ "description": "Automates finding, downloading and organising movies.",
+ "tags": [
+ "media",
+ "automation",
+ "arr"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/redis/.env.example b/backend/templates/redis/.env.example
new file mode 100644
index 0000000..e0a5254
--- /dev/null
+++ b/backend/templates/redis/.env.example
@@ -0,0 +1,4 @@
+DATA_PATH=/srv/redis
+REDIS_PORT=6379
+# Required.
+REDIS_PASSWORD=
diff --git a/backend/templates/redis/compose.yaml b/backend/templates/redis/compose.yaml
new file mode 100644
index 0000000..ac9aa99
--- /dev/null
+++ b/backend/templates/redis/compose.yaml
@@ -0,0 +1,20 @@
+services:
+ redis:
+ image: redis:7-alpine
+ container_name: redis
+ restart: unless-stopped
+ environment:
+ - REDIS_PASSWORD=${REDIS_PASSWORD:?redis password required}
+ command: >
+ redis-server
+ --appendonly yes
+ --requirepass ${REDIS_PASSWORD}
+ healthcheck:
+ test: ["CMD-SHELL", "redis-cli --no-auth-warning -a \"$$REDIS_PASSWORD\" ping | grep -q PONG"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ ports:
+ - "${REDIS_PORT:-6379}:6379"
+ volumes:
+ - ${DATA_PATH:-/srv/redis}:/data
diff --git a/backend/templates/redis/template.json b/backend/templates/redis/template.json
new file mode 100644
index 0000000..7620d11
--- /dev/null
+++ b/backend/templates/redis/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Redis",
+ "description": "In-memory cache and queue with append-only persistence, for apps that need one.",
+ "tags": [
+ "database",
+ "dev"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/sabnzbd/.env.example b/backend/templates/sabnzbd/.env.example
new file mode 100644
index 0000000..e729be9
--- /dev/null
+++ b/backend/templates/sabnzbd/.env.example
@@ -0,0 +1,7 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+DATA_PATH=/srv/sabnzbd
+DOWNLOADS_PATH=/srv/downloads
+INCOMPLETE_PATH=/srv/downloads/incomplete
+HTTP_PORT=8880
diff --git a/backend/templates/sabnzbd/compose.yaml b/backend/templates/sabnzbd/compose.yaml
new file mode 100644
index 0000000..bfd55fb
--- /dev/null
+++ b/backend/templates/sabnzbd/compose.yaml
@@ -0,0 +1,15 @@
+services:
+ sabnzbd:
+ image: lscr.io/linuxserver/sabnzbd:latest
+ container_name: sabnzbd
+ restart: unless-stopped
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${HTTP_PORT:-8880}:8080"
+ volumes:
+ - ${DATA_PATH:-/srv/sabnzbd}:/config
+ - ${DOWNLOADS_PATH:-/srv/downloads}:/downloads
+ - ${INCOMPLETE_PATH:-/srv/downloads/incomplete}:/incomplete-downloads
diff --git a/backend/templates/sabnzbd/template.json b/backend/templates/sabnzbd/template.json
new file mode 100644
index 0000000..33042a2
--- /dev/null
+++ b/backend/templates/sabnzbd/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "SABnzbd",
+ "description": "Usenet binary downloader that hands finished jobs to the *arr apps.",
+ "tags": [
+ "downloads",
+ "media"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/scrutiny/.env.example b/backend/templates/scrutiny/.env.example
new file mode 100644
index 0000000..76f4b1e
--- /dev/null
+++ b/backend/templates/scrutiny/.env.example
@@ -0,0 +1,2 @@
+DATA_PATH=/srv/scrutiny
+HTTP_PORT=8085
diff --git a/backend/templates/scrutiny/compose.yaml b/backend/templates/scrutiny/compose.yaml
new file mode 100644
index 0000000..8243283
--- /dev/null
+++ b/backend/templates/scrutiny/compose.yaml
@@ -0,0 +1,19 @@
+services:
+ scrutiny:
+ image: ghcr.io/analogj/scrutiny:master-omnibus
+ container_name: scrutiny
+ restart: unless-stopped
+ cap_add:
+ - SYS_RAWIO
+ ports:
+ - "${HTTP_PORT:-8085}:8080"
+ volumes:
+ - /run/udev:/run/udev:ro
+ - ${DATA_PATH:-/srv/scrutiny}/config:/opt/scrutiny/config
+ - ${DATA_PATH:-/srv/scrutiny}/influxdb:/opt/scrutiny/influxdb
+ devices:
+ # List every disk you want monitored.
+ - /dev/sda:/dev/sda
+ # - /dev/sdb:/dev/sdb
+ # NVMe drives additionally need: cap_add: [SYS_ADMIN]
+ # - /dev/nvme0:/dev/nvme0
diff --git a/backend/templates/scrutiny/template.json b/backend/templates/scrutiny/template.json
new file mode 100644
index 0000000..13f0e6a
--- /dev/null
+++ b/backend/templates/scrutiny/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Scrutiny",
+ "description": "S.M.A.R.T. disk health dashboard with failure-rate context. Edit the devices list before deploying.",
+ "tags": [
+ "monitoring",
+ "storage"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/searxng/.env.example b/backend/templates/searxng/.env.example
new file mode 100644
index 0000000..7b19185
--- /dev/null
+++ b/backend/templates/searxng/.env.example
@@ -0,0 +1,5 @@
+DATA_PATH=/srv/searxng
+HTTP_PORT=8099
+BASE_URL=http://localhost:8099
+# Required. Generate with: openssl rand -hex 32
+SEARXNG_SECRET=
diff --git a/backend/templates/searxng/compose.yaml b/backend/templates/searxng/compose.yaml
new file mode 100644
index 0000000..9109db5
--- /dev/null
+++ b/backend/templates/searxng/compose.yaml
@@ -0,0 +1,20 @@
+services:
+ searxng:
+ image: searxng/searxng:latest
+ container_name: searxng
+ restart: unless-stopped
+ environment:
+ - SEARXNG_BASE_URL=${BASE_URL:-http://localhost:8099}/
+ - SEARXNG_SECRET=${SEARXNG_SECRET:?secret required}
+ - UWSGI_WORKERS=4
+ - UWSGI_THREADS=4
+ ports:
+ - "${HTTP_PORT:-8099}:8080"
+ volumes:
+ - ${DATA_PATH:-/srv/searxng}:/etc/searxng
+ cap_drop:
+ - ALL
+ cap_add:
+ - CHOWN
+ - SETGID
+ - SETUID
diff --git a/backend/templates/searxng/template.json b/backend/templates/searxng/template.json
new file mode 100644
index 0000000..20d3f26
--- /dev/null
+++ b/backend/templates/searxng/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "SearXNG",
+ "description": "Private meta search engine that queries dozens of engines without tracking you.",
+ "tags": [
+ "search",
+ "privacy"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/sonarr/.env.example b/backend/templates/sonarr/.env.example
new file mode 100644
index 0000000..1641fc7
--- /dev/null
+++ b/backend/templates/sonarr/.env.example
@@ -0,0 +1,7 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+DATA_PATH=/srv/sonarr
+MEDIA_PATH=/srv/media
+DOWNLOADS_PATH=/srv/downloads
+HTTP_PORT=8989
diff --git a/backend/templates/sonarr/compose.yaml b/backend/templates/sonarr/compose.yaml
new file mode 100644
index 0000000..2d2c9e6
--- /dev/null
+++ b/backend/templates/sonarr/compose.yaml
@@ -0,0 +1,15 @@
+services:
+ sonarr:
+ image: lscr.io/linuxserver/sonarr:latest
+ container_name: sonarr
+ restart: unless-stopped
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${HTTP_PORT:-8989}:8989"
+ volumes:
+ - ${DATA_PATH:-/srv/sonarr}:/config
+ - ${MEDIA_PATH:-/srv/media}:/media
+ - ${DOWNLOADS_PATH:-/srv/downloads}:/downloads
diff --git a/backend/templates/sonarr/template.json b/backend/templates/sonarr/template.json
new file mode 100644
index 0000000..7754695
--- /dev/null
+++ b/backend/templates/sonarr/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Sonarr",
+ "description": "Automates finding, downloading and organising TV series.",
+ "tags": [
+ "media",
+ "automation",
+ "arr"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/speedtest-tracker/.env.example b/backend/templates/speedtest-tracker/.env.example
new file mode 100644
index 0000000..f53c1a7
--- /dev/null
+++ b/backend/templates/speedtest-tracker/.env.example
@@ -0,0 +1,10 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+DATA_PATH=/srv/speedtest-tracker
+HTTP_PORT=8086
+APP_URL=http://localhost:8086
+# Required. Generate with: openssl rand -base64 32
+APP_KEY=
+SPEEDTEST_SCHEDULE=0 */6 * * *
+PRUNE_DAYS=90
diff --git a/backend/templates/speedtest-tracker/compose.yaml b/backend/templates/speedtest-tracker/compose.yaml
new file mode 100644
index 0000000..af49460
--- /dev/null
+++ b/backend/templates/speedtest-tracker/compose.yaml
@@ -0,0 +1,20 @@
+services:
+ speedtest-tracker:
+ image: lscr.io/linuxserver/speedtest-tracker:latest
+ container_name: speedtest-tracker
+ restart: unless-stopped
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ - TZ=${TZ:-Europe/Berlin}
+ # Generate with: openssl rand -base64 32
+ - APP_KEY=${APP_KEY:?app key required}
+ - APP_URL=${APP_URL:-http://localhost:8086}
+ - DB_CONNECTION=sqlite
+ - SPEEDTEST_SCHEDULE=${SPEEDTEST_SCHEDULE:-0 */6 * * *}
+ - DISPLAY_TIMEZONE=${TZ:-Europe/Berlin}
+ - PRUNE_RESULTS_OLDER_THAN=${PRUNE_DAYS:-90}
+ ports:
+ - "${HTTP_PORT:-8086}:80"
+ volumes:
+ - ${DATA_PATH:-/srv/speedtest-tracker}:/config
diff --git a/backend/templates/speedtest-tracker/template.json b/backend/templates/speedtest-tracker/template.json
new file mode 100644
index 0000000..9caa551
--- /dev/null
+++ b/backend/templates/speedtest-tracker/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Speedtest Tracker",
+ "description": "Runs scheduled internet speed tests and charts the history. First login: admin@example.com / password.",
+ "tags": [
+ "monitoring",
+ "network"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/stirling-pdf/.env.example b/backend/templates/stirling-pdf/.env.example
new file mode 100644
index 0000000..920a06e
--- /dev/null
+++ b/backend/templates/stirling-pdf/.env.example
@@ -0,0 +1,3 @@
+DATA_PATH=/srv/stirling-pdf
+HTTP_PORT=8092
+LANGS=en_GB,de_DE
diff --git a/backend/templates/stirling-pdf/compose.yaml b/backend/templates/stirling-pdf/compose.yaml
new file mode 100644
index 0000000..b90f442
--- /dev/null
+++ b/backend/templates/stirling-pdf/compose.yaml
@@ -0,0 +1,13 @@
+services:
+ stirling-pdf:
+ image: ghcr.io/stirling-tools/stirling-pdf:latest
+ container_name: stirling-pdf
+ restart: unless-stopped
+ environment:
+ - DOCKER_ENABLE_SECURITY=false
+ - LANGS=${LANGS:-en_GB,de_DE}
+ ports:
+ - "${HTTP_PORT:-8092}:8080"
+ volumes:
+ - ${DATA_PATH:-/srv/stirling-pdf}/configs:/configs
+ - ${DATA_PATH:-/srv/stirling-pdf}/tessdata:/usr/share/tessdata
diff --git a/backend/templates/stirling-pdf/template.json b/backend/templates/stirling-pdf/template.json
new file mode 100644
index 0000000..b3fcca2
--- /dev/null
+++ b/backend/templates/stirling-pdf/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Stirling PDF",
+ "description": "Split, merge, OCR, sign and convert PDFs entirely in your own browser session.",
+ "tags": [
+ "documents",
+ "utilities"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/syncthing/.env.example b/backend/templates/syncthing/.env.example
new file mode 100644
index 0000000..0272e3a
--- /dev/null
+++ b/backend/templates/syncthing/.env.example
@@ -0,0 +1,9 @@
+PUID=1000
+PGID=1000
+TZ=Europe/Berlin
+SYNC_HOSTNAME=homelab
+DATA_PATH=/srv/syncthing
+SYNC_PATH=/srv/sync
+HTTP_PORT=8384
+SYNC_PORT=22000
+DISCOVERY_PORT=21027
diff --git a/backend/templates/syncthing/compose.yaml b/backend/templates/syncthing/compose.yaml
new file mode 100644
index 0000000..806aff0
--- /dev/null
+++ b/backend/templates/syncthing/compose.yaml
@@ -0,0 +1,18 @@
+services:
+ syncthing:
+ image: lscr.io/linuxserver/syncthing:latest
+ container_name: syncthing
+ restart: unless-stopped
+ hostname: ${SYNC_HOSTNAME:-homelab}
+ environment:
+ - PUID=${PUID:-1000}
+ - PGID=${PGID:-1000}
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${HTTP_PORT:-8384}:8384"
+ - "${SYNC_PORT:-22000}:22000/tcp"
+ - "${SYNC_PORT:-22000}:22000/udp"
+ - "${DISCOVERY_PORT:-21027}:21027/udp"
+ volumes:
+ - ${DATA_PATH:-/srv/syncthing}:/config
+ - ${SYNC_PATH:-/srv/sync}:/data
diff --git a/backend/templates/syncthing/template.json b/backend/templates/syncthing/template.json
new file mode 100644
index 0000000..0a979e5
--- /dev/null
+++ b/backend/templates/syncthing/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Syncthing",
+ "description": "Continuous peer-to-peer file sync between your own devices — no server in the middle.",
+ "tags": [
+ "files",
+ "sync",
+ "backup"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/tailscale/.env.example b/backend/templates/tailscale/.env.example
new file mode 100644
index 0000000..f1873c2
--- /dev/null
+++ b/backend/templates/tailscale/.env.example
@@ -0,0 +1,5 @@
+DATA_PATH=/srv/tailscale
+TS_HOSTNAME=homelab
+TS_AUTHKEY=
+TS_ROUTES=
+TS_EXTRA_ARGS=--accept-dns=false
diff --git a/backend/templates/tailscale/compose.yaml b/backend/templates/tailscale/compose.yaml
new file mode 100644
index 0000000..f1bfc18
--- /dev/null
+++ b/backend/templates/tailscale/compose.yaml
@@ -0,0 +1,20 @@
+services:
+ tailscale:
+ image: tailscale/tailscale:latest
+ container_name: tailscale
+ restart: unless-stopped
+ hostname: ${TS_HOSTNAME:-homelab}
+ network_mode: host
+ environment:
+ # Generate at https://login.tailscale.com/admin/settings/keys
+ - TS_AUTHKEY=${TS_AUTHKEY:-}
+ - TS_STATE_DIR=/var/lib/tailscale
+ # Advertise your LAN so tailnet clients can reach it, e.g. 192.168.1.0/24
+ - TS_ROUTES=${TS_ROUTES:-}
+ - TS_EXTRA_ARGS=${TS_EXTRA_ARGS:---accept-dns=false}
+ volumes:
+ - ${DATA_PATH:-/srv/tailscale}:/var/lib/tailscale
+ - /dev/net/tun:/dev/net/tun
+ cap_add:
+ - NET_ADMIN
+ - NET_RAW
diff --git a/backend/templates/tailscale/template.json b/backend/templates/tailscale/template.json
new file mode 100644
index 0000000..ea186b5
--- /dev/null
+++ b/backend/templates/tailscale/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Tailscale",
+ "description": "Mesh VPN node, optionally advertising your LAN as a subnet router.",
+ "tags": [
+ "network",
+ "vpn",
+ "security"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/traefik/.env.example b/backend/templates/traefik/.env.example
new file mode 100644
index 0000000..25cacfd
--- /dev/null
+++ b/backend/templates/traefik/.env.example
@@ -0,0 +1,6 @@
+DATA_PATH=/srv/traefik
+HTTP_PORT=80
+HTTPS_PORT=443
+DASHBOARD_PORT=8180
+DASHBOARD_HOST=traefik.example.com
+ACME_EMAIL=admin@example.com
diff --git a/backend/templates/traefik/compose.yaml b/backend/templates/traefik/compose.yaml
new file mode 100644
index 0000000..2c8f8cb
--- /dev/null
+++ b/backend/templates/traefik/compose.yaml
@@ -0,0 +1,29 @@
+services:
+ traefik:
+ image: traefik:v3
+ container_name: traefik
+ restart: unless-stopped
+ command:
+ - --api.dashboard=true
+ - --providers.docker=true
+ - --providers.docker.exposedbydefault=false
+ - --entrypoints.web.address=:80
+ - --entrypoints.web.http.redirections.entrypoint.to=websecure
+ - --entrypoints.web.http.redirections.entrypoint.scheme=https
+ - --entrypoints.websecure.address=:443
+ - --certificatesresolvers.le.acme.email=${ACME_EMAIL:-admin@example.com}
+ - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
+ - --certificatesresolvers.le.acme.tlschallenge=true
+ ports:
+ - "${HTTP_PORT:-80}:80"
+ - "${HTTPS_PORT:-443}:443"
+ - "${DASHBOARD_PORT:-8180}:8080"
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ - ${DATA_PATH:-/srv/traefik}/letsencrypt:/letsencrypt
+ labels:
+ - traefik.enable=true
+ - traefik.http.routers.dashboard.rule=Host(`${DASHBOARD_HOST:-traefik.example.com}`)
+ - traefik.http.routers.dashboard.service=api@internal
+ - traefik.http.routers.dashboard.entrypoints=websecure
+ - traefik.http.routers.dashboard.tls.certresolver=le
diff --git a/backend/templates/traefik/template.json b/backend/templates/traefik/template.json
new file mode 100644
index 0000000..354d0d5
--- /dev/null
+++ b/backend/templates/traefik/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Traefik",
+ "description": "Reverse proxy that discovers containers via Docker labels and issues Let's Encrypt certificates.",
+ "tags": [
+ "network",
+ "proxy"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/trilium/.env.example b/backend/templates/trilium/.env.example
new file mode 100644
index 0000000..7a9e05d
--- /dev/null
+++ b/backend/templates/trilium/.env.example
@@ -0,0 +1,3 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/trilium
+HTTP_PORT=8101
diff --git a/backend/templates/trilium/compose.yaml b/backend/templates/trilium/compose.yaml
new file mode 100644
index 0000000..cdbfa77
--- /dev/null
+++ b/backend/templates/trilium/compose.yaml
@@ -0,0 +1,11 @@
+services:
+ trilium:
+ image: triliumnext/trilium:latest
+ container_name: trilium
+ restart: unless-stopped
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${HTTP_PORT:-8101}:8080"
+ volumes:
+ - ${DATA_PATH:-/srv/trilium}:/home/node/trilium-data
diff --git a/backend/templates/trilium/template.json b/backend/templates/trilium/template.json
new file mode 100644
index 0000000..d1d98ff
--- /dev/null
+++ b/backend/templates/trilium/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Trilium Notes",
+ "description": "Hierarchical note taking for large personal knowledge bases, with sync and scripting.",
+ "tags": [
+ "notes",
+ "productivity"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/vikunja/.env.example b/backend/templates/vikunja/.env.example
new file mode 100644
index 0000000..710eb87
--- /dev/null
+++ b/backend/templates/vikunja/.env.example
@@ -0,0 +1,6 @@
+DATA_PATH=/srv/vikunja
+HTTP_PORT=3456
+PUBLIC_URL=http://localhost:3456
+DB_PASSWORD=change-me
+# Required. Generate with: openssl rand -hex 32
+SERVICE_SECRET=
diff --git a/backend/templates/vikunja/compose.yaml b/backend/templates/vikunja/compose.yaml
new file mode 100644
index 0000000..26dda03
--- /dev/null
+++ b/backend/templates/vikunja/compose.yaml
@@ -0,0 +1,36 @@
+services:
+ vikunja:
+ image: vikunja/vikunja:latest
+ container_name: vikunja
+ restart: unless-stopped
+ depends_on:
+ db:
+ condition: service_healthy
+ environment:
+ - VIKUNJA_SERVICE_PUBLICURL=${PUBLIC_URL:-http://localhost:3456}
+ - VIKUNJA_SERVICE_SECRET=${SERVICE_SECRET:?service secret required}
+ - VIKUNJA_DATABASE_TYPE=postgres
+ - VIKUNJA_DATABASE_HOST=db
+ - VIKUNJA_DATABASE_DATABASE=vikunja
+ - VIKUNJA_DATABASE_USER=vikunja
+ - VIKUNJA_DATABASE_PASSWORD=${DB_PASSWORD:-vikunja}
+ ports:
+ - "${HTTP_PORT:-3456}:3456"
+ volumes:
+ - ${DATA_PATH:-/srv/vikunja}/files:/app/vikunja/files
+
+ db:
+ image: postgres:16-alpine
+ container_name: vikunja-db
+ restart: unless-stopped
+ environment:
+ - POSTGRES_DB=vikunja
+ - POSTGRES_USER=vikunja
+ - POSTGRES_PASSWORD=${DB_PASSWORD:-vikunja}
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U vikunja"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+ volumes:
+ - ${DATA_PATH:-/srv/vikunja}/db:/var/lib/postgresql/data
diff --git a/backend/templates/vikunja/template.json b/backend/templates/vikunja/template.json
new file mode 100644
index 0000000..68f1ecf
--- /dev/null
+++ b/backend/templates/vikunja/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Vikunja",
+ "description": "To-do and project management with list, gantt, table and kanban views.",
+ "tags": [
+ "productivity",
+ "tasks"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/watchtower/.env.example b/backend/templates/watchtower/.env.example
new file mode 100644
index 0000000..569ab1d
--- /dev/null
+++ b/backend/templates/watchtower/.env.example
@@ -0,0 +1,5 @@
+TZ=Europe/Berlin
+# Six-field cron (seconds first). Default: every day at 04:00.
+SCHEDULE=0 0 4 * * *
+MONITOR_ONLY=false
+NOTIFICATION_URL=
diff --git a/backend/templates/watchtower/compose.yaml b/backend/templates/watchtower/compose.yaml
new file mode 100644
index 0000000..228793b
--- /dev/null
+++ b/backend/templates/watchtower/compose.yaml
@@ -0,0 +1,16 @@
+services:
+ watchtower:
+ image: containrrr/watchtower:latest
+ container_name: watchtower
+ restart: unless-stopped
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ - WATCHTOWER_CLEANUP=true
+ - WATCHTOWER_INCLUDE_RESTARTING=true
+ - WATCHTOWER_SCHEDULE=${SCHEDULE:-0 0 4 * * *}
+ # Report only, never pull: set to true for a dry run.
+ - WATCHTOWER_MONITOR_ONLY=${MONITOR_ONLY:-false}
+ # e.g. shoutrrr URL for ntfy/Gotify/Discord notifications
+ - WATCHTOWER_NOTIFICATION_URL=${NOTIFICATION_URL:-}
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
diff --git a/backend/templates/watchtower/template.json b/backend/templates/watchtower/template.json
new file mode 100644
index 0000000..9d60e33
--- /dev/null
+++ b/backend/templates/watchtower/template.json
@@ -0,0 +1,9 @@
+{
+ "name": "Watchtower",
+ "description": "Watches running containers and pulls new images on a schedule.",
+ "tags": [
+ "docker",
+ "automation"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/wg-easy/.env.example b/backend/templates/wg-easy/.env.example
new file mode 100644
index 0000000..9a4e9e7
--- /dev/null
+++ b/backend/templates/wg-easy/.env.example
@@ -0,0 +1,3 @@
+DATA_PATH=/srv/wg-easy
+WG_PORT=51820
+HTTP_PORT=51821
diff --git a/backend/templates/wg-easy/compose.yaml b/backend/templates/wg-easy/compose.yaml
new file mode 100644
index 0000000..dd81308
--- /dev/null
+++ b/backend/templates/wg-easy/compose.yaml
@@ -0,0 +1,17 @@
+services:
+ wg-easy:
+ image: ghcr.io/wg-easy/wg-easy:15
+ container_name: wg-easy
+ restart: unless-stopped
+ ports:
+ - "${WG_PORT:-51820}:51820/udp"
+ - "${HTTP_PORT:-51821}:51821/tcp"
+ volumes:
+ - ${DATA_PATH:-/srv/wg-easy}:/etc/wireguard
+ - /lib/modules:/lib/modules:ro
+ cap_add:
+ - NET_ADMIN
+ - SYS_MODULE
+ sysctls:
+ - net.ipv4.ip_forward=1
+ - net.ipv4.conf.all.src_valid_mark=1
diff --git a/backend/templates/wg-easy/template.json b/backend/templates/wg-easy/template.json
new file mode 100644
index 0000000..f9201dd
--- /dev/null
+++ b/backend/templates/wg-easy/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "WG-Easy",
+ "description": "WireGuard VPN server with a web UI for clients and QR codes. Admin account is created on first visit.",
+ "tags": [
+ "network",
+ "vpn",
+ "security"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/wiki-js/.env.example b/backend/templates/wiki-js/.env.example
new file mode 100644
index 0000000..f784b68
--- /dev/null
+++ b/backend/templates/wiki-js/.env.example
@@ -0,0 +1,3 @@
+DATA_PATH=/srv/wiki-js
+HTTP_PORT=3080
+DB_PASSWORD=change-me
diff --git a/backend/templates/wiki-js/compose.yaml b/backend/templates/wiki-js/compose.yaml
new file mode 100644
index 0000000..4179a75
--- /dev/null
+++ b/backend/templates/wiki-js/compose.yaml
@@ -0,0 +1,27 @@
+services:
+ wiki:
+ image: ghcr.io/requarks/wiki:2
+ container_name: wiki-js
+ restart: unless-stopped
+ depends_on:
+ - db
+ environment:
+ - DB_TYPE=postgres
+ - DB_HOST=db
+ - DB_PORT=5432
+ - DB_NAME=wiki
+ - DB_USER=wiki
+ - DB_PASS=${DB_PASSWORD:-wiki}
+ ports:
+ - "${HTTP_PORT:-3080}:3000"
+
+ db:
+ image: postgres:16-alpine
+ container_name: wiki-js-db
+ restart: unless-stopped
+ environment:
+ - POSTGRES_DB=wiki
+ - POSTGRES_USER=wiki
+ - POSTGRES_PASSWORD=${DB_PASSWORD:-wiki}
+ volumes:
+ - ${DATA_PATH:-/srv/wiki-js}/db:/var/lib/postgresql/data
diff --git a/backend/templates/wiki-js/template.json b/backend/templates/wiki-js/template.json
new file mode 100644
index 0000000..bd53d3b
--- /dev/null
+++ b/backend/templates/wiki-js/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Wiki.js",
+ "description": "Modern wiki with Markdown and WYSIWYG editors, git sync and fine-grained permissions.",
+ "tags": [
+ "documents",
+ "wiki",
+ "productivity"
+ ],
+ "gpu": null
+}
diff --git a/backend/templates/zigbee2mqtt/.env.example b/backend/templates/zigbee2mqtt/.env.example
new file mode 100644
index 0000000..5f2ae53
--- /dev/null
+++ b/backend/templates/zigbee2mqtt/.env.example
@@ -0,0 +1,4 @@
+TZ=Europe/Berlin
+DATA_PATH=/srv/zigbee2mqtt
+HTTP_PORT=8095
+ZIGBEE_DEVICE=/dev/ttyUSB0
diff --git a/backend/templates/zigbee2mqtt/compose.yaml b/backend/templates/zigbee2mqtt/compose.yaml
new file mode 100644
index 0000000..48a2e6a
--- /dev/null
+++ b/backend/templates/zigbee2mqtt/compose.yaml
@@ -0,0 +1,18 @@
+services:
+ zigbee2mqtt:
+ image: koenkk/zigbee2mqtt:latest
+ container_name: zigbee2mqtt
+ restart: unless-stopped
+ environment:
+ - TZ=${TZ:-Europe/Berlin}
+ ports:
+ - "${HTTP_PORT:-8095}:8080"
+ volumes:
+ # The config lives with the stack so you can edit it here; the directory
+ # underneath holds the device database Zigbee2MQTT builds as you pair.
+ - ${DATA_PATH:-/srv/zigbee2mqtt}:/app/data
+ - ./configuration.yaml:/app/data/configuration.yaml
+ - /run/udev:/run/udev:ro
+ devices:
+ # Your Zigbee coordinator. Find it with: ls -l /dev/serial/by-id/
+ - ${ZIGBEE_DEVICE:-/dev/ttyUSB0}:/dev/ttyACM0
diff --git a/backend/templates/zigbee2mqtt/configuration.yaml b/backend/templates/zigbee2mqtt/configuration.yaml
new file mode 100644
index 0000000..73d5d4e
--- /dev/null
+++ b/backend/templates/zigbee2mqtt/configuration.yaml
@@ -0,0 +1,13 @@
+# Point `server` at your MQTT broker. Zigbee2MQTT also writes back to this file
+# as you pair devices, so keep it in the stack folder.
+homeassistant:
+ enabled: true
+frontend:
+ port: 8080
+mqtt:
+ base_topic: zigbee2mqtt
+ server: mqtt://mosquitto:1883
+serial:
+ port: /dev/ttyACM0
+advanced:
+ log_level: info
diff --git a/backend/templates/zigbee2mqtt/template.json b/backend/templates/zigbee2mqtt/template.json
new file mode 100644
index 0000000..23eadfe
--- /dev/null
+++ b/backend/templates/zigbee2mqtt/template.json
@@ -0,0 +1,10 @@
+{
+ "name": "Zigbee2MQTT",
+ "description": "Bridges Zigbee devices to MQTT without a vendor hub. Point it at your broker and coordinator stick.",
+ "tags": [
+ "home-automation",
+ "iot",
+ "mqtt"
+ ],
+ "gpu": null
+}
diff --git a/backend/version.py b/backend/version.py
index 62ebe57..6a55ffd 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
-APP_VERSION = "0.42.2"
+APP_VERSION = "0.43.0"
diff --git a/frontend/package.json b/frontend/package.json
index 0c862f9..04068a4 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
- "version": "0.42.2",
+ "version": "0.43.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/pages/Templates.tsx b/frontend/src/pages/Templates.tsx
index 76ea29d..0394772 100644
--- a/frontend/src/pages/Templates.tsx
+++ b/frontend/src/pages/Templates.tsx
@@ -1,7 +1,7 @@
-import { useState } from "react";
+import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { LayoutTemplate, Cpu, Package, Trash2, FileCode } from "lucide-react";
+import { LayoutTemplate, Cpu, Package, Trash2, FileCode, Search } from "lucide-react";
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { templatesApi, type TemplateDetail, type TemplateSummary } from "@/api/templates";
@@ -18,8 +18,31 @@ export function Templates() {
const queryClient = useQueryClient();
const [selected, setSelected] = useState(null);
const [toDelete, setToDelete] = useState(null);
+ const [search, setSearch] = useState("");
+ const [tag, setTag] = useState(null);
const { data, isLoading } = useQuery({ queryKey: ["templates"], queryFn: templatesApi.list });
+ // Tags sorted by how many templates carry them — the useful ones surface first.
+ const tags = useMemo(() => {
+ const counts = new Map();
+ for (const t of data ?? []) {
+ for (const tg of t.tags) counts.set(tg, (counts.get(tg) ?? 0) + 1);
+ }
+ return [...counts.entries()]
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+ .map(([name, count]) => ({ name, count }));
+ }, [data]);
+
+ const shown = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ return (data ?? []).filter((t) => {
+ if (tag && !t.tags.includes(tag)) return false;
+ if (!q) return true;
+ const haystack = `${t.name} ${t.description ?? ""} ${t.tags.join(" ")}`.toLowerCase();
+ return haystack.includes(q);
+ });
+ }, [data, search, tag]);
+
const open = async (t: TemplateSummary) => {
try {
setSelected(await templatesApi.get(t.id));
@@ -50,8 +73,36 @@ export function Templates() {
Templates are stored as ready-to-run stack folders. Pull one to copy it into a new stack,
then edit it like any other.
+
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
+ setTag(null)} />
+ {tags.map((tg) => (
+ setTag(tag === tg.name ? null : tg.name)}
+ />
+ ))}
+
+
+
+ {shown.length === 0 && (
+ No template matches that filter.
+ )}
+
- {data?.map((t) => (
+ {shown.map((t) => (
{t.source === "custom" ? (
@@ -64,10 +115,15 @@ export function Templates() {
{t.description && {t.description}
}
- {t.tags.map((tag) => (
-
- {tag}
-
+ {t.tags.map((tg) => (
+
))}
{t.gpu && (
@@ -109,6 +165,30 @@ export function Templates() {
);
}
+function TagChip({
+ label,
+ active,
+ onClick,
+}: {
+ label: string;
+ active: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
+
function UseTemplateDialog({
template,
onClose,