Phase 25: templates as stack folders (0.31.0)

Templates are now stack-shaped folders (compose.yaml + .env.example +
template.json) instead of DB rows + manifest.json + {{VAR}} rendering.
Pull copies the folder into a new stack; custom templates persist under
DATA_DIR/templates. Adds POST /api/templates/from-stack and a one-time
startup migration for pre-0.31 DB templates (drops the template table).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-12 07:29:54 +00:00
co-authored by Claude Fable 5
parent 34cb215266
commit 1609b8bcc3
29 changed files with 564 additions and 329 deletions
+1 -1
View File
@@ -66,7 +66,7 @@ def _map_docker(exc: DockerError):
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
raise exc # falls through to the global 502 DockerError handler
AGENT_VERSION = "0.30.0"
AGENT_VERSION = "0.31.0"
# --------------------------------------------------------------------------- #
+8 -2
View File
@@ -35,7 +35,7 @@ from routers import (
volumes,
ws,
)
from services import schedule_service, update_service
from services import schedule_service, template_service, update_service
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("stackpilot")
@@ -50,6 +50,12 @@ async def lifespan(app: FastAPI):
stacks.sync_discovered_stacks(session)
except Exception as exc: # noqa: BLE001
logger.warning("Stack discovery failed: %s", exc)
try:
moved = template_service.migrate_legacy_db_templates()
if moved:
logger.info("Migrated %d custom template(s) from the database to folders", moved)
except Exception as exc: # noqa: BLE001
logger.warning("Legacy template migration failed: %s", exc)
update_task = asyncio.create_task(update_service.background_loop())
schedule_task = asyncio.create_task(schedule_service.scheduler_loop())
logger.info("StackPilot backend ready on port %s", settings.PORT)
@@ -58,7 +64,7 @@ async def lifespan(app: FastAPI):
schedule_task.cancel()
app = FastAPI(title="StackPilot", version="0.30.0", lifespan=lifespan)
app = FastAPI(title="StackPilot", version="0.31.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
+1 -2
View File
@@ -6,10 +6,9 @@ from models.backup_destination import BackupDestination
from models.backup_schedule import BackupSchedule
from models.setting import Setting, Webhook
from models.stack import Stack
from models.template import Template
from models.user import User
__all__ = [
"User", "Stack", "AuditLog", "Template", "Setting", "Webhook", "Agent",
"User", "Stack", "AuditLog", "Setting", "Webhook", "Agent",
"BackupDestination", "BackupSchedule", "AutoUpdate",
]
+15 -30
View File
@@ -1,34 +1,11 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
from sqlmodel import Field, SQLModel
from sqlmodel import SQLModel
def _now() -> datetime:
return datetime.now(timezone.utc)
class Template(SQLModel, table=True):
"""User-saved custom template (bundled ones live on disk)."""
id: Optional[int] = Field(default=None, primary_key=True)
slug: str = Field(index=True, unique=True)
name: str
description: Optional[str] = None
tags: str = "" # comma separated
yaml: str = ""
created_at: datetime = Field(default_factory=_now)
# --- API schemas ---
class TemplateVariable(SQLModel):
name: str
description: str = ""
default: str = ""
# Templates are stored as stack-shaped folders on disk (see
# services/template_service.py), not in the database. These are API schemas only.
class TemplateSummary(SQLModel):
@@ -41,18 +18,26 @@ class TemplateSummary(SQLModel):
class TemplateDetail(TemplateSummary):
yaml: str
variables: list[TemplateVariable] = []
compose: str = ""
env: str = ""
files: list[str] = [] # relative paths the template ships
class TemplateSaveRequest(SQLModel):
name: str
description: Optional[str] = None
tags: list[str] = []
yaml: str
gpu: Optional[str] = None
compose: str
env: str = ""
class TemplateFromStackRequest(SQLModel):
stack_id: str
name: str
description: Optional[str] = None
class TemplateInstantiateRequest(SQLModel):
name: str # new stack name
values: dict[str, str] = {}
agent_id: int | None = None # None = local host; otherwise deploy to a remote agent
+47 -16
View File
@@ -1,4 +1,8 @@
"""Template library endpoints."""
"""Template library endpoints.
Templates are stack-shaped folders on disk. Listing reads them; "instantiate"
(pull) copies the whole folder into a new stack, which is then editable.
"""
from __future__ import annotations
import os
@@ -10,7 +14,11 @@ from auth import get_current_user, require_admin
from database import get_session
from models.agent import Agent
from models.stack import Stack
from models.template import TemplateInstantiateRequest, TemplateSaveRequest
from models.template import (
TemplateFromStackRequest,
TemplateInstantiateRequest,
TemplateSaveRequest,
)
from models.user import User
from services import agent_service, audit_service, compose_service, template_service
from services.agent_service import AgentError
@@ -24,19 +32,17 @@ def _ip(request: Request) -> str:
@router.get("")
def list_templates(
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> list[dict]:
return template_service.list_templates(session)
return template_service.list_templates()
@router.get("/{template_id}")
def get_template(
template_id: str,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
tpl = template_service.get_template(session, template_id)
tpl = template_service.get_template(template_id)
if not tpl:
raise HTTPException(status_code=404, detail="Template not found")
return tpl
@@ -49,13 +55,32 @@ def save_template(
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
tpl = template_service.save_custom(
session, body.name, body.yaml, body.description or "", body.tags
slug = template_service.save_custom(
body.name, body.compose, body.env, body.description or "", body.tags, body.gpu
)
audit_service.record(
session, user=user.username, action="template.save", target=tpl.slug, ip=_ip(request)
session, user=user.username, action="template.save", target=slug, ip=_ip(request)
)
return {"id": f"custom:{tpl.slug}", "name": tpl.name}
return {"id": f"custom:{slug}", "name": body.name}
@router.post("/from-stack")
def save_from_stack(
body: TemplateFromStackRequest,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
if not session.get(Stack, body.stack_id) and not os.path.isdir(
compose_service.stack_dir(body.stack_id)
):
raise HTTPException(status_code=404, detail=f"Stack '{body.stack_id}' not found")
slug = template_service.save_from_stack(body.stack_id, body.name, body.description or "")
audit_service.record(
session, user=user.username, action="template.save",
target=slug, detail=body.stack_id, ip=_ip(request),
)
return {"id": f"custom:{slug}", "name": body.name}
@router.delete("/custom/{slug}")
@@ -65,7 +90,7 @@ def delete_template(
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
if not template_service.delete_custom(session, slug):
if not template_service.delete_custom(slug):
raise HTTPException(status_code=404, detail="Custom template not found")
audit_service.record(
session, user=user.username, action="template.delete", target=slug, ip=_ip(request)
@@ -81,12 +106,11 @@ async def instantiate(
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
tpl = template_service.get_template(session, template_id)
tpl = template_service.get_template(template_id)
if not tpl:
raise HTTPException(status_code=404, detail="Template not found")
rendered = template_service.render(tpl["yaml"], body.values)
# Remote host: agents don't share our filesystem, so ship compose + env.
if body.agent_id is not None:
agent = session.get(Agent, body.agent_id)
if not agent:
@@ -94,7 +118,7 @@ async def instantiate(
try:
result = await agent_service.call(
session, agent, "POST", "/agent/stacks",
json={"name": body.name, "yaml": rendered, "env": None},
json={"name": body.name, "yaml": tpl["compose"], "env": tpl["env"] or None},
)
except AgentError as exc:
raise HTTPException(
@@ -107,11 +131,18 @@ async def instantiate(
)
return {"id": result.get("id"), "name": body.name, "agent_id": agent.id}
# Local host: copy the whole template folder into a new stack.
stack_id = compose_service.slugify(body.name)
if session.get(Stack, stack_id) or os.path.isdir(compose_service.stack_dir(stack_id)):
raise HTTPException(status_code=409, detail=f"Stack '{stack_id}' already exists")
compose_service.write_compose(stack_id, rendered)
try:
template_service.copy_into_stack(template_id, stack_id)
except FileExistsError:
raise HTTPException(status_code=409, detail=f"Stack '{stack_id}' already exists")
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Template not found")
stack = Stack(id=stack_id, name=body.name, description=tpl.get("description"))
session.add(stack)
session.commit()
+220 -125
View File
@@ -1,32 +1,44 @@
"""Template library — bundled (on-disk) + custom (DB)."""
"""Template library — stack-shaped folders.
A template is just a directory laid out like a real stack (``compose.yaml`` plus
optional ``.env.example`` and any extra files), accompanied by a small
``template.json`` describing it. "Pulling" a template copies the whole folder
into a new stack, which is then editable like any other stack.
Two roots are scanned:
* **bundled** — ``backend/templates/`` ships in the image / git repo (read-only).
* **custom** — ``${DATA_DIR}/templates/`` is writable and persists on the data
volume; this is where "save stack as template" writes to.
"""
from __future__ import annotations
import json
import os
import re
from functools import lru_cache
import shutil
from typing import Optional
from sqlmodel import Session, select
from config import settings
from services import compose_service
from models.template import Template
BUNDLED_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates")
_TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates")
_VAR_RE = re.compile(r"\{\{\s*([A-Za-z0-9_]+)\s*\}\}")
_META_NAME = "template.json"
_ENV_EXAMPLE = ".env.example"
_CUSTOM_PREFIX = "custom:"
@lru_cache(maxsize=1)
def _manifest() -> list[dict]:
path = os.path.join(_TEMPLATES_DIR, "manifest.json")
try:
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
except (OSError, json.JSONDecodeError):
return []
def custom_dir() -> str:
return os.path.join(settings.DATA_DIR, "templates")
def _read_template_file(filename: str) -> str:
path = os.path.join(_TEMPLATES_DIR, filename)
# --------------------------------------------------------------------------- #
# Low-level helpers
# --------------------------------------------------------------------------- #
def _read_file(path: str) -> str:
try:
with open(path, "r", encoding="utf-8") as fh:
return fh.read()
@@ -34,134 +46,217 @@ def _read_template_file(filename: str) -> str:
return ""
def extract_variables(yaml_str: str) -> list[str]:
seen: list[str] = []
for m in _VAR_RE.finditer(yaml_str):
if m.group(1) not in seen:
seen.append(m.group(1))
return seen
def _load_meta(folder: str, slug: str) -> dict:
"""Metadata from template.json, with sensible fallbacks."""
meta: dict = {}
raw = _read_file(os.path.join(folder, _META_NAME))
if raw:
try:
meta = json.loads(raw)
except json.JSONDecodeError:
meta = {}
return {
"name": meta.get("name") or slug.replace("-", " ").title(),
"description": meta.get("description"),
"tags": meta.get("tags") or [],
"gpu": meta.get("gpu"),
}
def render(yaml_str: str, values: dict[str, str]) -> str:
def repl(m: re.Match) -> str:
key = m.group(1)
return str(values.get(key, m.group(0)))
return _VAR_RE.sub(repl, yaml_str)
def _is_template(folder: str) -> bool:
return os.path.isdir(folder) and compose_service.find_compose_file(folder) is not None
# --------------------------------------------------------------------------- #
# Listing
# --------------------------------------------------------------------------- #
def _list_files(folder: str) -> list[str]:
"""Relative paths shipped by the template (excludes the metadata file)."""
out: list[str] = []
for root, _dirs, files in os.walk(folder):
for fn in sorted(files):
rel = os.path.relpath(os.path.join(root, fn), folder)
if rel == _META_NAME:
continue
out.append(rel)
return sorted(out)
def list_templates(session: Session) -> list[dict]:
def _resolve_dir(template_id: str) -> Optional[str]:
"""Map a template id to its on-disk folder (guards against traversal)."""
if template_id.startswith(_CUSTOM_PREFIX):
slug = template_id[len(_CUSTOM_PREFIX):]
base = custom_dir()
else:
slug = template_id
base = BUNDLED_DIR
slug = os.path.basename(slug.strip())
if not slug:
return None
path = os.path.join(base, slug)
return path if _is_template(path) else None
def _scan(base: str, source: str) -> list[dict]:
out: list[dict] = []
for entry in _manifest():
if not os.path.isdir(base):
return out
for slug in sorted(os.listdir(base)):
folder = os.path.join(base, slug)
if not _is_template(folder):
continue
meta = _load_meta(folder, slug)
out.append(
{
"id": entry["id"],
"name": entry["name"],
"description": entry.get("description"),
"tags": entry.get("tags", []),
"gpu": entry.get("gpu"),
"source": "bundled",
}
)
for tpl in session.exec(select(Template)).all():
out.append(
{
"id": f"custom:{tpl.slug}",
"name": tpl.name,
"description": tpl.description,
"tags": [t for t in tpl.tags.split(",") if t],
"gpu": None,
"source": "custom",
"id": f"{_CUSTOM_PREFIX}{slug}" if source == "custom" else slug,
"name": meta["name"],
"description": meta["description"],
"tags": meta["tags"],
"gpu": meta["gpu"],
"source": source,
}
)
return out
def get_template(session: Session, template_id: str) -> Optional[dict]:
if template_id.startswith("custom:"):
slug = template_id.split(":", 1)[1]
tpl = session.exec(select(Template).where(Template.slug == slug)).first()
if not tpl:
return None
variables = [
{"name": v, "description": "", "default": ""}
for v in extract_variables(tpl.yaml)
]
return {
"id": template_id,
"name": tpl.name,
"description": tpl.description,
"tags": [t for t in tpl.tags.split(",") if t],
"gpu": None,
"source": "custom",
"yaml": tpl.yaml,
"variables": variables,
}
# --------------------------------------------------------------------------- #
# Listing / detail
# --------------------------------------------------------------------------- #
for entry in _manifest():
if entry["id"] == template_id:
yaml_str = _read_template_file(entry["file"])
declared = {v["name"]: v for v in entry.get("variables", [])}
# Merge declared metadata with any vars actually present.
variables = []
for name in extract_variables(yaml_str):
meta = declared.get(name, {})
variables.append(
{
"name": name,
"description": meta.get("description", ""),
"default": meta.get("default", ""),
}
)
return {
"id": entry["id"],
"name": entry["name"],
"description": entry.get("description"),
"tags": entry.get("tags", []),
"gpu": entry.get("gpu"),
"source": "bundled",
"yaml": yaml_str,
"variables": variables,
}
return None
def list_templates() -> list[dict]:
return _scan(BUNDLED_DIR, "bundled") + _scan(custom_dir(), "custom")
def get_template(template_id: str) -> Optional[dict]:
folder = _resolve_dir(template_id)
if not folder:
return None
slug = os.path.basename(folder)
meta = _load_meta(folder, slug)
compose_file = compose_service.find_compose_file(folder)
return {
"id": template_id,
"name": meta["name"],
"description": meta["description"],
"tags": meta["tags"],
"gpu": meta["gpu"],
"source": "custom" if template_id.startswith(_CUSTOM_PREFIX) else "bundled",
"compose": _read_file(compose_file) if compose_file else "",
"env": _read_file(os.path.join(folder, _ENV_EXAMPLE)),
"files": _list_files(folder),
}
# --------------------------------------------------------------------------- #
# Pull (instantiate) — copy the whole folder into a new stack
# --------------------------------------------------------------------------- #
def copy_into_stack(template_id: str, stack_id: str, override: Optional[str] = None) -> None:
"""Copy a template folder into a fresh stack directory.
The ``template.json`` is left behind and any ``.env.example`` is promoted to
a real ``.env`` so the pulled stack is immediately runnable + editable.
"""
src = _resolve_dir(template_id)
if not src:
raise FileNotFoundError(f"Template '{template_id}' not found")
dst = compose_service.stack_dir(stack_id, override)
if os.path.exists(dst):
raise FileExistsError(f"Stack '{stack_id}' already exists")
shutil.copytree(src, dst, ignore=shutil.ignore_patterns(_META_NAME))
example = os.path.join(dst, _ENV_EXAMPLE)
env = os.path.join(dst, ".env")
if os.path.isfile(example) and not os.path.isfile(env):
os.replace(example, env)
# --------------------------------------------------------------------------- #
# Save / delete custom templates (folder-based, persisted on the data volume)
# --------------------------------------------------------------------------- #
def save_custom(
session: Session, name: str, yaml_str: str, description: str = "", tags: list[str] | None = None
) -> Template:
slug = re.sub(r"[^a-z0-9_-]+", "-", name.strip().lower()).strip("-") or "template"
existing = session.exec(select(Template).where(Template.slug == slug)).first()
if existing:
existing.name = name
existing.description = description
existing.tags = ",".join(tags or [])
existing.yaml = yaml_str
session.add(existing)
session.commit()
session.refresh(existing)
return existing
tpl = Template(
slug=slug,
name=name,
description=description,
tags=",".join(tags or []),
yaml=yaml_str,
)
session.add(tpl)
session.commit()
session.refresh(tpl)
return tpl
name: str,
compose: str,
env: str = "",
description: str = "",
tags: list[str] | None = None,
gpu: str | None = None,
) -> str:
"""Write a custom template folder; returns its slug. Overwrites if it exists."""
slug = compose_service.slugify(name)
folder = os.path.join(custom_dir(), slug)
os.makedirs(folder, exist_ok=True)
meta = {
"name": name,
"description": description or None,
"tags": tags or [],
"gpu": gpu,
}
with open(os.path.join(folder, _META_NAME), "w", encoding="utf-8") as fh:
json.dump(meta, fh, indent=2)
fh.write("\n")
with open(os.path.join(folder, compose_service.DEFAULT_COMPOSE_NAME), "w", encoding="utf-8") as fh:
fh.write(compose or "services:\n")
example = os.path.join(folder, _ENV_EXAMPLE)
if env.strip():
with open(example, "w", encoding="utf-8") as fh:
fh.write(env)
elif os.path.isfile(example):
os.remove(example)
return slug
def delete_custom(session: Session, slug: str) -> bool:
tpl = session.exec(select(Template).where(Template.slug == slug)).first()
if not tpl:
def save_from_stack(stack_id: str, name: str, description: str = "") -> str:
"""Snapshot an existing stack's compose + env into a custom template."""
compose = compose_service.read_compose(stack_id)
env = compose_service.read_env(stack_id)
return save_custom(name, compose, env, description=description)
def delete_custom(slug: str) -> bool:
folder = os.path.join(custom_dir(), os.path.basename(slug.strip()))
if not _is_template(folder):
return False
session.delete(tpl)
session.commit()
shutil.rmtree(folder)
return True
# --------------------------------------------------------------------------- #
# Legacy migration (pre-0.31 custom templates lived in the database)
# --------------------------------------------------------------------------- #
_LEGACY_VAR_RE = re.compile(r"\{\{\s*([A-Za-z0-9_]+)\s*\}\}")
def migrate_legacy_db_templates() -> int:
"""One-time: move custom templates out of the dropped ``template`` table.
Old templates used ``{{VAR}}`` placeholders; compose interpolates ``${VAR}``
from ``.env``, so placeholders are rewritten and the variable names land in
the template's ``.env.example``. Returns the number of templates moved.
"""
from sqlalchemy import inspect, text
from database import engine
if not inspect(engine).has_table("template"):
return 0
moved = 0
with engine.begin() as conn:
rows = conn.execute(
text("SELECT name, description, tags, yaml FROM template")
).all()
for name, description, tags, yaml_str in rows:
compose = _LEGACY_VAR_RE.sub(r"${\1}", yaml_str or "")
variables = dict.fromkeys(_LEGACY_VAR_RE.findall(yaml_str or ""))
env = "".join(f"{v}=\n" for v in variables)
save_custom(
name or "template",
compose,
env,
description=description or "",
tags=[t for t in (tags or "").split(",") if t],
)
moved += 1
conn.execute(text("DROP TABLE template"))
return moved
+5
View File
@@ -0,0 +1,5 @@
PUID=1000
PGID=1000
DATA_PATH=/srv/gitea
HTTP_PORT=3000
SSH_PORT=2222
@@ -4,12 +4,12 @@ services:
container_name: gitea
restart: unless-stopped
environment:
- USER_UID={{PUID}}
- USER_GID={{PGID}}
- USER_UID=${PUID:-1000}
- USER_GID=${PGID:-1000}
ports:
- "{{HTTP_PORT}}:3000"
- "{{SSH_PORT}}:22"
- "${HTTP_PORT:-3000}:3000"
- "${SSH_PORT:-2222}:22"
volumes:
- {{DATA_PATH}}:/data
- ${DATA_PATH:-/srv/gitea}:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
+9
View File
@@ -0,0 +1,9 @@
{
"name": "Gitea",
"description": "Lightweight self-hosted Git service.",
"tags": [
"git",
"dev"
],
"gpu": null
}
-14
View File
@@ -1,14 +0,0 @@
services:
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
restart: unless-stopped
environment:
- PUID={{PUID}}
- PGID={{PGID}}
- TZ={{TZ}}
ports:
- "{{HTTP_PORT}}:8096"
volumes:
- {{CONFIG_PATH}}:/config
- {{MEDIA_PATH}}:/media
+6
View File
@@ -0,0 +1,6 @@
PUID=1000
PGID=1000
TZ=Europe/Berlin
CONFIG_PATH=/srv/jellyfin/config
MEDIA_PATH=/srv/media
HTTP_PORT=8096
+14
View File
@@ -0,0 +1,14 @@
services:
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
restart: unless-stopped
environment:
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- TZ=${TZ:-Europe/Berlin}
ports:
- "${HTTP_PORT:-8096}:8096"
volumes:
- ${CONFIG_PATH:-/srv/jellyfin/config}:/config
- ${MEDIA_PATH:-/srv/media}:/media
+9
View File
@@ -0,0 +1,9 @@
{
"name": "Jellyfin",
"description": "Free media streaming server with optional hardware transcoding.",
"tags": [
"media",
"streaming"
],
"gpu": "NVIDIA / Intel optional"
}
-74
View File
@@ -1,74 +0,0 @@
[
{
"id": "jellyfin",
"name": "Jellyfin",
"description": "Free media streaming server with optional hardware transcoding.",
"tags": ["media", "streaming"],
"gpu": "NVIDIA / Intel optional",
"file": "jellyfin.yaml",
"variables": [
{ "name": "PUID", "description": "User ID", "default": "1000" },
{ "name": "PGID", "description": "Group ID", "default": "1000" },
{ "name": "TZ", "description": "Timezone", "default": "Europe/Berlin" },
{ "name": "CONFIG_PATH", "description": "Host path for config", "default": "/srv/jellyfin/config" },
{ "name": "MEDIA_PATH", "description": "Host path to media library", "default": "/srv/media" },
{ "name": "HTTP_PORT", "description": "Web UI port", "default": "8096" }
]
},
{
"id": "vaultwarden",
"name": "Vaultwarden",
"description": "Lightweight Bitwarden-compatible password manager.",
"tags": ["password-manager", "security"],
"gpu": null,
"file": "vaultwarden.yaml",
"variables": [
{ "name": "TZ", "description": "Timezone", "default": "Europe/Berlin" },
{ "name": "DATA_PATH", "description": "Host path for data", "default": "/srv/vaultwarden" },
{ "name": "HTTP_PORT", "description": "Web UI port", "default": "8200" },
{ "name": "ADMIN_TOKEN", "description": "Admin panel token", "default": "change-me" }
]
},
{
"id": "uptime-kuma",
"name": "Uptime Kuma",
"description": "Self-hosted uptime / status monitoring tool.",
"tags": ["monitoring"],
"gpu": null,
"file": "uptime-kuma.yaml",
"variables": [
{ "name": "DATA_PATH", "description": "Host path for data", "default": "/srv/uptime-kuma" },
{ "name": "HTTP_PORT", "description": "Web UI port", "default": "3001" }
]
},
{
"id": "paperless-ngx",
"name": "Paperless-NGX",
"description": "Document management system that indexes scanned documents.",
"tags": ["documents"],
"gpu": null,
"file": "paperless-ngx.yaml",
"variables": [
{ "name": "TZ", "description": "Timezone", "default": "Europe/Berlin" },
{ "name": "DATA_PATH", "description": "Host path base", "default": "/srv/paperless" },
{ "name": "HTTP_PORT", "description": "Web UI port", "default": "8000" },
{ "name": "ADMIN_USER", "description": "Admin username", "default": "admin" },
{ "name": "ADMIN_PASSWORD", "description": "Admin password", "default": "change-me" }
]
},
{
"id": "gitea",
"name": "Gitea",
"description": "Lightweight self-hosted Git service.",
"tags": ["git", "dev"],
"gpu": null,
"file": "gitea.yaml",
"variables": [
{ "name": "PUID", "description": "User ID", "default": "1000" },
{ "name": "PGID", "description": "Group ID", "default": "1000" },
{ "name": "DATA_PATH", "description": "Host path for data", "default": "/srv/gitea" },
{ "name": "HTTP_PORT", "description": "Web UI port", "default": "3000" },
{ "name": "SSH_PORT", "description": "SSH port", "default": "2222" }
]
}
]
@@ -0,0 +1,5 @@
TZ=Europe/Berlin
DATA_PATH=/srv/paperless
HTTP_PORT=8000
ADMIN_USER=admin
ADMIN_PASSWORD=change-me
@@ -4,7 +4,7 @@ services:
container_name: paperless-redis
restart: unless-stopped
volumes:
- {{DATA_PATH}}/redis:/data
- ${DATA_PATH:-/srv/paperless}/redis:/data
db:
image: postgres:16-alpine
@@ -15,7 +15,7 @@ services:
- POSTGRES_USER=paperless
- POSTGRES_PASSWORD=paperless
volumes:
- {{DATA_PATH}}/db:/var/lib/postgresql/data
- ${DATA_PATH:-/srv/paperless}/db:/var/lib/postgresql/data
webserver:
image: ghcr.io/paperless-ngx/paperless-ngx:latest
@@ -27,12 +27,12 @@ services:
environment:
- PAPERLESS_REDIS=redis://broker:6379
- PAPERLESS_DBHOST=db
- PAPERLESS_TIME_ZONE={{TZ}}
- PAPERLESS_ADMIN_USER={{ADMIN_USER}}
- PAPERLESS_ADMIN_PASSWORD={{ADMIN_PASSWORD}}
- PAPERLESS_TIME_ZONE=${TZ:-Europe/Berlin}
- PAPERLESS_ADMIN_USER=${ADMIN_USER:-admin}
- PAPERLESS_ADMIN_PASSWORD=${ADMIN_PASSWORD:-change-me}
ports:
- "{{HTTP_PORT}}:8000"
- "${HTTP_PORT:-8000}:8000"
volumes:
- {{DATA_PATH}}/data:/usr/src/paperless/data
- {{DATA_PATH}}/media:/usr/src/paperless/media
- {{DATA_PATH}}/consume:/usr/src/paperless/consume
- ${DATA_PATH:-/srv/paperless}/data:/usr/src/paperless/data
- ${DATA_PATH:-/srv/paperless}/media:/usr/src/paperless/media
- ${DATA_PATH:-/srv/paperless}/consume:/usr/src/paperless/consume
@@ -0,0 +1,8 @@
{
"name": "Paperless-NGX",
"description": "Document management system that indexes scanned documents.",
"tags": [
"documents"
],
"gpu": null
}
@@ -0,0 +1,2 @@
DATA_PATH=/srv/uptime-kuma
HTTP_PORT=3001
@@ -4,6 +4,6 @@ services:
container_name: uptime-kuma
restart: unless-stopped
ports:
- "{{HTTP_PORT}}:3001"
- "${HTTP_PORT:-3001}:3001"
volumes:
- {{DATA_PATH}}:/app/data
- ${DATA_PATH:-/srv/uptime-kuma}:/app/data
@@ -0,0 +1,8 @@
{
"name": "Uptime Kuma",
"description": "Self-hosted uptime / status monitoring tool.",
"tags": [
"monitoring"
],
"gpu": null
}
@@ -0,0 +1,4 @@
TZ=Europe/Berlin
DATA_PATH=/srv/vaultwarden
HTTP_PORT=8200
ADMIN_TOKEN=change-me
@@ -4,10 +4,10 @@ services:
container_name: vaultwarden
restart: unless-stopped
environment:
- TZ={{TZ}}
- ADMIN_TOKEN={{ADMIN_TOKEN}}
- TZ=${TZ:-Europe/Berlin}
- ADMIN_TOKEN=${ADMIN_TOKEN:-change-me}
- WEBSOCKET_ENABLED=true
ports:
- "{{HTTP_PORT}}:80"
- "${HTTP_PORT:-8200}:80"
volumes:
- {{DATA_PATH}}:/data
- ${DATA_PATH:-/srv/vaultwarden}:/data
@@ -0,0 +1,9 @@
{
"name": "Vaultwarden",
"description": "Lightweight Bitwarden-compatible password manager.",
"tags": [
"password-manager",
"security"
],
"gpu": null
}